@jskit-ai/assistant-runtime 0.1.193 → 0.1.195

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/fixtures/canonical-conversation/App.vue +188 -0
  2. package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
  3. package/fixtures/canonical-conversation/index.html +1 -0
  4. package/fixtures/canonical-conversation/main.js +61 -0
  5. package/fixtures/canonical-conversation/socket.js +50 -0
  6. package/fixtures/canonical-conversation/vite.config.mjs +178 -0
  7. package/fixtures/conversation/vite.config.mjs +12 -2
  8. package/package.json +23 -10
  9. package/src/client/components/AssistantConversationClientElement.vue +57 -0
  10. package/src/client/composables/useAssistantConversation.js +874 -0
  11. package/src/client/index.js +2 -0
  12. package/src/client/support/assistantRuntimeState.js +1 -1
  13. package/src/client/support/subscribeAssistantConversation.js +205 -0
  14. package/src/server/AssistantProvider.js +82 -106
  15. package/src/server/actionIds.js +10 -0
  16. package/src/server/actions.js +287 -79
  17. package/src/server/createAssistantRuntime.js +138 -0
  18. package/src/server/createAssistantSettingsActions.js +89 -0
  19. package/src/server/index.js +6 -0
  20. package/src/server/inputSchemas.js +90 -0
  21. package/src/server/registerConversationSubscriptions.js +163 -0
  22. package/src/server/registerFastifyConversations.js +76 -0
  23. package/src/server/registerRoutes.js +220 -198
  24. package/src/server/registerSettingsRoutes.js +113 -0
  25. package/src/server/repositories/conversationsRepository.js +12 -5
  26. package/src/server/repositories/messagesRepository.js +12 -0
  27. package/src/server/services/chatService.js +88 -694
  28. package/src/server/services/transcriptService.js +56 -52
  29. package/src/server/sqlConversationStorage.js +192 -0
  30. package/src/server/support/assistantRouteContext.js +91 -0
  31. package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
  32. package/src/shared/assistantSurfaces.js +14 -0
  33. package/src/shared/conversationRealtime.js +9 -0
  34. package/src/shared/index.js +1 -0
  35. package/test/actionDefinitions.test.js +44 -0
  36. package/test/aiConnections.test.js +1 -1
  37. package/test/assistantConversationBinding.browser.test.js +1868 -0
  38. package/test/assistantConversationOperations.test.js +459 -0
  39. package/test/assistantConversationSubscriptions.test.js +692 -0
  40. package/test/assistantIntegrationBoundary.test.js +103 -0
  41. package/test/assistantRuntimeComposition.test.js +68 -0
  42. package/test/assistantSurfacePattern.test.js +1 -1
  43. package/test/assistantSurfaces.test.js +23 -0
  44. package/test/chatServiceLifecycle.test.js +12 -1
  45. package/test/featureRuntime.test.js +238 -0
  46. package/test/registerFastifyConversations.test.js +169 -0
  47. package/test/sqlConversationStorage.test.js +205 -0
  48. package/test/support/memoryAssistantRows.js +51 -0
  49. package/test/turnRequests.test.js +11 -2
  50. package/src/shared/assistantResponseText.js +0 -29
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createRouter } from "../../kernel/server/http/lib/router.js";
4
+ import { actionIds } from "../src/server/actionIds.js";
5
+ import { createAssistantSettingsActions } from "../src/server/createAssistantSettingsActions.js";
6
+ import { registerSettingsRoutes } from "../src/server/registerSettingsRoutes.js";
7
+ import { resolveRouteRequestState } from "../src/server/support/assistantRouteContext.js";
8
+
9
+ const config = {
10
+ surfaceDefinitions: {
11
+ home: { enabled: true, requiresWorkspace: false },
12
+ console: { enabled: true, requiresWorkspace: false, accessPolicyId: "console_owner" },
13
+ admin: { enabled: true, requiresWorkspace: true }
14
+ },
15
+ assistantSurfaces: {
16
+ home: { settingsSurfaceId: "console", configScope: "global" },
17
+ admin: { settingsSurfaceId: "admin", configScope: "workspace" }
18
+ }
19
+ };
20
+
21
+ function request(surfaceId, hostSurfaceId = surfaceId) {
22
+ return {
23
+ headers: { "x-jskit-surface": hostSurfaceId },
24
+ input: { params: { surfaceId, workspaceSlug: "trusted-workspace" } }
25
+ };
26
+ }
27
+
28
+ test("the shared route boundary derives surface and workspace only from the existing host inputs", () => {
29
+ const input = request("admin");
30
+ input.input.body = { targetSurfaceId: "home", workspaceSlug: "other-workspace", actor: { id: "other-user" } };
31
+ const resolved = resolveRouteRequestState(input, {
32
+ resolveCurrentAppConfig: () => config,
33
+ requiresWorkspace: true,
34
+ workspaceScopeSupport: {
35
+ buildInputFromRouteParams(params) { return { workspaceSlug: params.workspaceSlug }; }
36
+ }
37
+ });
38
+ assert.equal(resolved.hostSurfaceId, "admin");
39
+ assert.deepEqual(resolved.actionInput, { targetSurfaceId: "admin", workspaceSlug: "trusted-workspace" });
40
+ assert.ok(Object.isFrozen(resolved.actionInput));
41
+ const settings = resolveRouteRequestState(request("home", "console"), {
42
+ resolveCurrentAppConfig: () => config,
43
+ kind: "settings"
44
+ });
45
+ assert.equal(settings.hostSurfaceId, "console");
46
+ assert.deepEqual(settings.actionInput, { targetSurfaceId: "home" });
47
+ });
48
+
49
+ test("the shared route boundary retains surface and workspace denial errors", () => {
50
+ for (const [input, options, status, message] of [
51
+ [request("home", ""), {}, 400, "Assistant surface header x-jskit-surface is required."],
52
+ [request("home", "admin"), {}, 403, "Assistant route is not available on this surface."],
53
+ [request("home"), { kind: "settings" }, 403, "Assistant route is not available on this surface."],
54
+ [request("admin"), {}, 404, "Assistant route not found."],
55
+ [request("missing"), {}, 404, "Assistant not found."]
56
+ ]) {
57
+ assert.throws(() => resolveRouteRequestState(input, {
58
+ resolveCurrentAppConfig: () => config,
59
+ ...options
60
+ }), { status, message });
61
+ }
62
+ assert.throws(() => resolveRouteRequestState(request("admin"), {
63
+ resolveCurrentAppConfig: () => config,
64
+ requiresWorkspace: true
65
+ }), /Assistant workspace routes require workspace server scope support/);
66
+ });
67
+
68
+ test("settings routes and actions compose independently of the chat service", async () => {
69
+ const calls = [];
70
+ const definitions = createAssistantSettingsActions({
71
+ settingsSurfaces: ["console"],
72
+ assistantConfigService: {
73
+ getSettings(input, options) { calls.push({ input, options }); return {}; },
74
+ updateSettings(input, patch, options) { calls.push({ input, patch, options }); return {}; }
75
+ }
76
+ });
77
+ assert.deepEqual(definitions.map(({ id }) => id), [actionIds.settingsRead, actionIds.settingsUpdate]);
78
+ assert.ok(definitions.every(({ permission }) => permission.require === "authenticated"));
79
+ assert.ok(definitions.every(({ extensions }) => extensions.assistant.exclude.includes("self-configuration is disabled")));
80
+ const router = createRouter();
81
+ registerSettingsRoutes(router, () => config);
82
+ const routes = router.list();
83
+ assert.deepEqual(routes.map(({ method, path }) => [method, path]), [
84
+ ["GET", "/api/assistant/:surfaceId/settings"],
85
+ ["PATCH", "/api/assistant/:surfaceId/settings"]
86
+ ]);
87
+ const context = { actor: { id: "authenticated-user" } };
88
+ const patch = { systemPrompt: "Use the existing settings service." };
89
+ for (const route of routes) {
90
+ await route.handler({
91
+ ...request("home", "console"),
92
+ input: { params: { surfaceId: "home" }, body: patch },
93
+ executeAction({ actionId, surface, input }) {
94
+ assert.equal(surface, "console");
95
+ return definitions.find(({ id }) => id === actionId).execute(input, context);
96
+ }
97
+ }, { code(status) { assert.equal(status, 200); return this; }, send() {} });
98
+ }
99
+ assert.deepEqual(calls, [
100
+ { input: { targetSurfaceId: "home" }, options: { context } },
101
+ { input: { targetSurfaceId: "home", patch }, patch, options: { context } }
102
+ ]);
103
+ });
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import test from "node:test";
4
+
5
+ test("assistant services compose with supplied repositories without database imports", () => {
6
+ const result = spawnSync(process.execPath, ["--input-type=module", "-e", `
7
+ import assert from "node:assert/strict";
8
+ import { registerHooks } from "node:module";
9
+
10
+ registerHooks({ resolve(specifier, context, nextResolve) {
11
+ if (["@jskit-ai/database-runtime", "knex"].some((name) => specifier === name || specifier.startsWith(name + "/"))) {
12
+ throw new Error("Unexpected database import: " + specifier);
13
+ }
14
+ return nextResolve(specifier, context);
15
+ } });
16
+
17
+ for (const name of ["@jskit-ai/database-runtime", "knex"]) {
18
+ await assert.rejects(() => import(name), /Unexpected database import/);
19
+ }
20
+
21
+ const { createAssistantRuntime } = await import(${JSON.stringify(new URL("../src/server/createAssistantRuntime.js", import.meta.url).href)});
22
+ let record = { targetSurfaceId: "home", workspaceId: null, settings: { systemPrompt: "Initial prompt" } };
23
+ const scope = { targetSurfaceId: "home", workspaceId: null };
24
+ const repositories = {
25
+ config: {
26
+ async findByScope(input) {
27
+ assert.deepEqual(input, scope);
28
+ return record;
29
+ },
30
+ async upsertByScope({ patch, ...input }) {
31
+ assert.deepEqual(input, scope);
32
+ record = { ...record, settings: patch };
33
+ return record;
34
+ },
35
+ createDefaultRecord() { throw new Error("The supplied record should be used."); }
36
+ },
37
+ conversations: {},
38
+ messages: {},
39
+ turnRequests: {}
40
+ };
41
+ const assistant = createAssistantRuntime({
42
+ repositories,
43
+ actionCatalogue: { listDefinitions: () => [], async execute() {} },
44
+ config: {
45
+ surfaceDefinitions: { home: { enabled: true, requiresWorkspace: false } },
46
+ assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
47
+ },
48
+ env: {}
49
+ });
50
+
51
+ for (const [name, repository] of Object.entries(repositories)) {
52
+ assert.equal(assistant.repositories[name], repository);
53
+ }
54
+ const options = { context: { actor: { id: 23 } } };
55
+ assert.equal(await assistant.services.config.getSettings({ targetSurfaceId: "home" }, options), record);
56
+ const updated = await assistant.services.config.updateSettings(
57
+ { targetSurfaceId: "home" }, { systemPrompt: "Updated prompt" }, options
58
+ );
59
+ assert.equal(updated, record);
60
+ assert.equal(await assistant.services.config.resolveSystemPrompt({ targetSurfaceId: "home" }), "Updated prompt");
61
+ assert.equal(typeof assistant.services.chat.streamChat, "function");
62
+ assert.equal(typeof assistant.services.transcript.getConversationMessagesForUser, "function");
63
+ assert.equal(typeof assistant.aiClientFactory.resolveClient, "function");
64
+ assert.equal(typeof assistant.toolCatalog.resolveToolSet, "function");
65
+ `], { encoding: "utf8" });
66
+
67
+ assert.equal(result.status, 0, result.stderr);
68
+ });
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import test from "node:test";
4
- import { createAssistantAiClientFactory } from "../src/server/AssistantProvider.js";
4
+ import { createAssistantAiClientFactory } from "../src/server/createAssistantRuntime.js";
5
5
  import { parse as parseSfc } from "@vue/compiler-sfc";
6
6
 
7
7
  const exampleRoot = new URL("../patterns/assistant-surface/example/", import.meta.url);
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
+ defineAssistantSurface,
4
5
  normalizeAssistantConfigScope,
5
6
  resolveAssistantSurfaceConfig
6
7
  } from "../src/shared/assistantSurfaces.js";
@@ -48,3 +49,25 @@ test("assistant surface config rejects invalid workspace-scoped assistant combin
48
49
  assert.equal(normalizeAssistantConfigScope("workspace"), "workspace");
49
50
  assert.equal(normalizeAssistantConfigScope("weird"), "global");
50
51
  });
52
+
53
+ test("single-surface shorthand preserves explicit routing, scope and disabled surface boundaries", () => {
54
+ const home = defineAssistantSurface("home");
55
+ assert.deepEqual(home, {
56
+ surfaceDefinitions: { home: { pagesRoot: "", requiresWorkspace: false } },
57
+ assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
58
+ });
59
+ assert.deepEqual(resolveAssistantSurfaceConfig(home, "home"), resolveAssistantSurfaceConfig({
60
+ surfaceDefinitions: { home: { enabled: true, pagesRoot: "", requiresWorkspace: false } },
61
+ assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
62
+ }, "home"));
63
+ assert.equal(resolveAssistantSurfaceConfig(home, "console"), null);
64
+ const workspace = defineAssistantSurface("admin", {
65
+ pagesRoot: "w/[workspaceSlug]/admin", requiresWorkspace: true, accessPolicyId: "workspace_member"
66
+ });
67
+ assert.equal(workspace.surfaceDefinitions.admin.pagesRoot, "w/[workspaceSlug]/admin");
68
+ assert.equal(workspace.surfaceDefinitions.admin.accessPolicyId, "workspace_member");
69
+ assert.equal(resolveAssistantSurfaceConfig(workspace, "admin").configScope, "workspace");
70
+ assert.equal(resolveAssistantSurfaceConfig(defineAssistantSurface("home", { enabled: false }), "home"), null);
71
+ assert.equal(resolveAssistantSurfaceConfig(defineAssistantSurface("home", { configScope: "workspace" }), "home"), null);
72
+ assert.throws(() => defineAssistantSurface(""), /surface id is required/u);
73
+ });
@@ -2,6 +2,8 @@ import { createMemoryTurnRequests } from "./support/memoryTurnRequests.js";
2
2
  import assert from "node:assert/strict";
3
3
  import test from "node:test";
4
4
  import { createChatService } from "../src/server/services/chatService.js";
5
+ import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
6
+ import { createMemoryAssistantRows } from "./support/memoryAssistantRows.js";
5
7
 
6
8
  const APP_CONFIG = Object.freeze({
7
9
  surfaceDefinitions: {
@@ -68,6 +70,7 @@ async function* completionStream(completion = {}) {
68
70
  ]
69
71
  };
70
72
  }
73
+ yield { choices: [{ delta: {}, finish_reason: completion.toolCall ? "tool_calls" : "stop" }] };
71
74
  }
72
75
 
73
76
  function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true } = {}) {
@@ -89,7 +92,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
89
92
  }
90
93
  }));
91
94
 
92
- const chatService = createChatService({
95
+ const dependencies = {
93
96
  turnRequests: createMemoryTurnRequests(),
94
97
  attachments,
95
98
  aiClientFactory: {
@@ -127,6 +130,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
127
130
  }
128
131
  },
129
132
  serviceToolCatalog: {
133
+ limits: { maxToolArgumentBytes: 64 * 1024 },
130
134
  resolveToolSet() {
131
135
  return { tools };
132
136
  },
@@ -159,7 +163,12 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
159
163
  }
160
164
  },
161
165
  appConfig: APP_CONFIG
166
+ };
167
+ const conversationRuntime = createAssistantConversationRuntime({
168
+ ...createMemoryAssistantRows({ messages: transcriptMessages }),
169
+ aiClientFactory: dependencies.aiClientFactory, toolCatalog: dependencies.serviceToolCatalog, attachments
162
170
  });
171
+ const chatService = createChatService({ ...dependencies, conversationRuntime });
163
172
 
164
173
  const streamWriter = {};
165
174
  for (const method of [
@@ -476,6 +485,7 @@ test("answer text reaches the client before the provider finishes, then persists
476
485
  firstChunk.resolve();
477
486
  await continueResponse.promise;
478
487
  yield { choices: [{ delta: { content: " The second sentence." } }] };
488
+ yield { choices: [{ delta: {}, finish_reason: "stop" }] };
479
489
  }
480
490
  const harness = createHarness([response()]);
481
491
  const running = harness.run();
@@ -498,6 +508,7 @@ test("split internal tags and tool arguments never become answer deltas", async
498
508
  for (const content of ["<th", "ink>private reasoning", "</thi", "nk>", "<|DSML|function_", "calls><|DSML|invoke name=\"action_search\">", '{"secret":"private arguments"}', "</|DSML|invoke></|DSML|function_calls>"]) {
499
509
  yield { choices: [{ delta: { content } }] };
500
510
  }
511
+ yield { choices: [{ delta: {}, finish_reason: "stop" }] };
501
512
  }
502
513
  const harness = createHarness([response(), textCompletion("The search is complete.")]);
503
514
  await harness.run();
@@ -1,9 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { spawnSync } from "node:child_process";
4
+ import { EventEmitter } from "node:events";
5
+ import { createSchema } from "json-rest-schema";
3
6
 
4
7
  import { createActionProvider } from "@jskit-ai/kernel/server/actions";
8
+ import { attachDirectRequestActionExecutor } from "@jskit-ai/kernel/server/http";
5
9
  import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
6
10
  import { AssistantFeature } from "../src/server/AssistantProvider.js";
11
+ import { actionIds } from "../src/server/actionIds.js";
12
+ import { ASSISTANT_CONVERSATION_SUBSCRIBE } from "../src/shared/conversationRealtime.js";
7
13
 
8
14
  function valueProvider(id, capability, value) {
9
15
  return defineProvider({
@@ -74,3 +80,235 @@ test("AssistantFeature composes chat, transcripts, settings, routes, and action
74
80
 
75
81
  await runtime.shutdown();
76
82
  });
83
+
84
+ test("AssistantFeature uses a supplied file runtime without loading database or legacy chat integration", () => {
85
+ const result = spawnSync(process.execPath, ["--input-type=module", "-e", `
86
+ import assert from "node:assert/strict";
87
+ import { registerHooks } from "node:module";
88
+ import { mkdtemp, rm } from "node:fs/promises";
89
+ import { tmpdir } from "node:os";
90
+ import { join } from "node:path";
91
+ registerHooks({ resolve(specifier, context, nextResolve) {
92
+ if (["@jskit-ai/database-runtime", "knex"].some(name => specifier === name || specifier.startsWith(name + "/")) ||
93
+ specifier.endsWith("/chatService.js") || specifier.endsWith("/createAssistantRuntime.js")) {
94
+ throw new Error("Unexpected database/legacy import: " + specifier);
95
+ }
96
+ return nextResolve(specifier, context);
97
+ } });
98
+ for (const name of ["@jskit-ai/database-runtime", "knex"]) {
99
+ await assert.rejects(() => import(name), /Unexpected database/);
100
+ }
101
+ const { AssistantFeature } = await import("@jskit-ai/assistant-runtime/server");
102
+ const { createConversationRuntime, createFileConversationStorage } = await import("@jskit-ai/assistant-core/server/conversation");
103
+ const { createActionProvider } = await import("@jskit-ai/kernel/server/actions");
104
+ const { createCapabilityRuntime, defineProvider } = await import("@jskit-ai/kernel/shared/capabilities");
105
+ const directory = await mkdtemp(join(tmpdir(), "assistant-feature-"));
106
+ const runtime = createConversationRuntime({ storage: createFileConversationStorage({ directory }),
107
+ authorize: ({ context }) => context.actor?.id === "42",
108
+ connections: { resolve() { throw new Error("Reading a conversation must not invoke inference."); } } });
109
+ const context = { actor: { id: "42" } };
110
+ const conversation = await runtime.open({ id: "chat", context,
111
+ configuration: { systemPrompt: "Answer briefly.", integrationId: "selected" } });
112
+ const config = { surfaceDefinitions: { home: { enabled: true, requiresWorkspace: false } },
113
+ assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } } };
114
+ const valueProvider = (capability, value) => defineProvider({ id: capability,
115
+ provides: { value: capability }, setup: () => ({ value }) });
116
+ let registrations = 0, detachments = 0;
117
+ try {
118
+ for (const unrelatedDatabase of [false, true]) {
119
+ const routes = [];
120
+ let assistant, actions;
121
+ const host = createCapabilityRuntime({ providers: [createActionProvider(),
122
+ valueProvider("runtime.config", config), valueProvider("runtime.env", {}),
123
+ valueProvider("runtime.http", { router: { register(...route) { routes.push(route); } } }),
124
+ valueProvider("assistant.conversations", runtime),
125
+ valueProvider("runtime.realtime", { onConnection(listener) {
126
+ assert.equal(typeof listener, "function"); registrations++;
127
+ return () => { detachments++; };
128
+ } }),
129
+ valueProvider("runtime.events", { publish() {} }),
130
+ ...(unrelatedDatabase ? [valueProvider("runtime.database", { get knex() {
131
+ throw new Error("Supplied conversation storage must not touch the host database.");
132
+ } })] : []),
133
+ AssistantFeature,
134
+ defineProvider({ id: "observer", requires: { assistant: "assistant.runtime", actions: "runtime.actions" },
135
+ setup(values) { ({ assistant, actions } = values); return {}; } })
136
+ ] });
137
+ try {
138
+ await host.start();
139
+ assert.equal(assistant.conversationRuntime, runtime);
140
+ assert.equal(assistant.services.conversations, runtime);
141
+ assert.equal(assistant.services.config, null);
142
+ assert.equal(assistant.services.chat, undefined);
143
+ assert.ok(actions.listDefinitions().some(entry => entry.id === "assistant.conversation.read"));
144
+ assert.ok(!actions.listDefinitions().some(entry => entry.id === "assistant.chat.stream"));
145
+ assert.ok(routes.some(([method, path]) => method === "GET" && path.endsWith("/:conversationId")));
146
+ assert.ok(routes.every(([, path]) => !path.endsWith("/settings") && !path.endsWith("/chat/stream")));
147
+ } finally { await host.shutdown(); }
148
+ assert.equal((await conversation.read()).id, "chat");
149
+ }
150
+ assert.equal(registrations, 2);
151
+ assert.equal(detachments, 2);
152
+ } finally {
153
+ await runtime.close();
154
+ await rm(directory, { recursive: true, force: true });
155
+ }
156
+ `], { encoding: "utf8" });
157
+ assert.equal(result.status, 0, result.stderr);
158
+ });
159
+
160
+ test("one AssistantFeature composes host access and declared data while settings retain authenticated access", { timeout: 10_000 }, async () => {
161
+ const routes = [], wrapped = [], sent = [], choices = [], observers = new Set();
162
+ let actions, connection, releaseConnection;
163
+ let registrations = 0, releases = 0, requestReads = 0;
164
+ const config = { surfaceDefinitions: { home: { enabled: true, requiresWorkspace: false } },
165
+ assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } } };
166
+ const conversation = {
167
+ read: async () => ({ id: "persisted-local", status: "ready", conversationLog: [] }),
168
+ async send(input) { sent.push(input); return { messageId: input.messageId, status: "accepted" }; },
169
+ async configure(input) { choices.push({ configuration: input }); return input; },
170
+ async select(input) { choices.push({ selection: input }); return input; },
171
+ async replace(input) { choices.push({ replacement: input }); return input; },
172
+ async subscribe(listener) { observers.add(listener); return () => observers.delete(listener); }
173
+ };
174
+ const conversationRuntime = {
175
+ conversationDataSchema: createSchema({ clientId: { type: "string", required: true, minLength: 1 } }),
176
+ conversationConfigurationSchema: createSchema({ effort: { type: "string", required: true, enum: ["low"] } }),
177
+ conversationSelectionSchema: createSchema({ modelChoice: { type: "string", required: true, enum: ["offered"] } }),
178
+ conversationReplacementSchema: createSchema({ choice: { type: "string", required: true, enum: ["renew"] } }),
179
+ async open({ id, context }) {
180
+ assert.equal(id, "persisted-local");
181
+ assert.equal(context.hostAuthorized, true);
182
+ assert.equal(context.actor, null, "Host access must not fabricate an authenticated local actor.");
183
+ return conversation;
184
+ }
185
+ };
186
+ const router = { register(method, path, contract, handler) { routes.push({ method, path, contract, handler }); } };
187
+ function requireHostRequest(request) {
188
+ if (request?.headers?.["x-fixture-access"] !== "allowed") {
189
+ throw Object.assign(new Error("Host request denied."), { statusCode: 403 });
190
+ }
191
+ }
192
+ const access = {
193
+ router: { register(method, path, contract, handler) {
194
+ router.register(method, path, { ...contract, auth: "public" }, (request, reply) => {
195
+ requireHostRequest(request);
196
+ return handler(request, reply);
197
+ });
198
+ } },
199
+ wrapAction(definition) {
200
+ wrapped.push(definition.id);
201
+ return { ...definition, permission: { require: "none" }, execute(input, context, deps) {
202
+ requireHostRequest(context.requestMeta?.request);
203
+ return definition.execute(input, { ...context, hostAuthorized: true }, deps);
204
+ } };
205
+ },
206
+ subscribeActionId: "test.host.conversation.subscribe",
207
+ requestPolicy: "host"
208
+ };
209
+ const host = createCapabilityRuntime({
210
+ providers: [createActionProvider(), AssistantFeature, defineProvider({
211
+ id: "test.host.observer", requires: { catalogue: "runtime.actions", assistant: "assistant.runtime" },
212
+ setup({ catalogue, assistant }) {
213
+ actions = catalogue;
214
+ assert.equal(assistant.conversationRuntime, conversationRuntime);
215
+ return {};
216
+ }
217
+ })],
218
+ inputs: {
219
+ "runtime.config": config, "runtime.env": {}, "runtime.http": { router },
220
+ "assistant.conversations": conversationRuntime, "assistant.conversation.access": access,
221
+ "assistant.settings": {}, "runtime.events": { async publish() {} },
222
+ "runtime.realtime": { onConnection(listener) {
223
+ registrations++;
224
+ connection = listener;
225
+ return () => { releases++; releaseConnection?.(); };
226
+ } }
227
+ }
228
+ });
229
+ await host.start();
230
+ try {
231
+ assert.equal(registrations, 1);
232
+ assert.equal(wrapped.length, 10);
233
+ assert.ok(wrapped.includes(access.subscribeActionId));
234
+ assert.ok(!wrapped.includes(actionIds.conversationSubscribe));
235
+ assert.equal(actions.listDefinitions().length, 12);
236
+ assert.equal(routes.length, 11);
237
+ assert.ok(routes.filter(route => route.path.endsWith("/settings")).every(route => route.contract.auth === "required"));
238
+ assert.ok(routes.filter(route => !route.path.endsWith("/settings")).every(route => route.contract.auth === "public"));
239
+ assert.equal(actions.getDefinition(actionIds.settingsRead).permission.require, "authenticated");
240
+ const sendRoute = routes.find(route => route.path.endsWith("/:conversationId/messages"));
241
+ const body = { messageId: "authored-message", text: "A question", data: { clientId: "original-client" } };
242
+ assert.deepEqual(sendRoute.contract.body.schema.patch(body).errors, {});
243
+ const forged = { ...body, data: { ...body.data, actor: "other-user" } };
244
+ assert.notDeepEqual(sendRoute.contract.body.schema.patch(forged).errors, {});
245
+ const request = { headers: { "x-jskit-surface": "home", "x-fixture-access": "allowed" },
246
+ input: { params: { surfaceId: "home", conversationId: "persisted-local" }, body } };
247
+ attachDirectRequestActionExecutor({ actions, request });
248
+ const reply = { code(status) { this.status = status; return this; }, send(value) { this.value = value; } };
249
+ await sendRoute.handler(request, reply);
250
+ assert.equal(reply.status, 202);
251
+ assert.deepEqual(sent, [body]);
252
+ await assert.rejects(async () => sendRoute.handler({ ...request, headers: {} }, reply), /Host request denied/);
253
+ const context = { channel: "api", surface: "home", requestMeta: { request } };
254
+ await assert.rejects(actions.execute({ actionId: actionIds.conversationSend,
255
+ input: { targetSurfaceId: "home", conversationId: "persisted-local", ...forged }, context }),
256
+ error => error.code === "ACTION_VALIDATION_FAILED");
257
+ await assert.rejects(actions.execute({ actionId: actionIds.conversationRead,
258
+ input: { targetSurfaceId: "home", conversationId: "persisted-local" },
259
+ context: { channel: "api", surface: "home" } }), /Host request denied/);
260
+ await assert.rejects(actions.execute({ actionId: actionIds.settingsRead,
261
+ input: { targetSurfaceId: "home" }, context }), error => error.code === "ACTION_AUTHENTICATION_REQUIRED");
262
+ for (const [suffix, body] of [["configuration", { configuration: { effort: "low" } }],
263
+ ["selection", { selection: { modelChoice: "offered" } }],
264
+ ["replacement", { replacement: { choice: "renew" } }]]) {
265
+ const route = routes.find(route => route.path.endsWith(`/:conversationId/${suffix}`));
266
+ assert.deepEqual(route.contract.body.schema.patch(body).errors, {});
267
+ const choiceRequest = { ...request, input: { ...request.input, body } };
268
+ attachDirectRequestActionExecutor({ actions, request: choiceRequest });
269
+ await route.handler(choiceRequest, reply);
270
+ assert.equal(reply.status, 200);
271
+ await assert.rejects(async () => route.handler({ ...choiceRequest, headers: {} }, reply), /Host request denied/);
272
+ }
273
+ assert.deepEqual(choices, [{ configuration: { effort: "low" } }, { selection: { modelChoice: "offered" } },
274
+ { replacement: { choice: "renew" } }]);
275
+ const socket = Object.assign(new EventEmitter(), { id: "host-socket", connected: true });
276
+ releaseConnection = connection({ socket,
277
+ authenticate() { assert.fail("Explicit host policy must use the revalidating host request facility."); },
278
+ async readRequest() { requestReads++; return { headers: { "x-fixture-access": "allowed" } }; }
279
+ });
280
+ const subscribed = await new Promise(resolve => socket.emit(ASSISTANT_CONVERSATION_SUBSCRIBE, {
281
+ subscriptionId: "host-view", conversationId: "persisted-local", targetSurfaceId: "home", hostSurfaceId: "home"
282
+ }, resolve));
283
+ assert.equal(subscribed.ok, true);
284
+ assert.equal(subscribed.state.id, "persisted-local");
285
+ assert.equal(requestReads, 1);
286
+ assert.equal(observers.size, 1);
287
+ assert.equal(socket.listenerCount(ASSISTANT_CONVERSATION_SUBSCRIBE), 1);
288
+ } finally { await host.shutdown(); }
289
+ assert.equal(releases, 1);
290
+ assert.equal(observers.size, 0);
291
+ assert.equal((await conversation.read()).id, "persisted-local");
292
+ });
293
+
294
+ test("AssistantFeature rejects incomplete host access before registering routes or actions", async () => {
295
+ const validAccess = { router: { register() {} }, wrapAction: definition => definition,
296
+ subscribeActionId: "test.host.conversation.subscribe", requestPolicy: "host" };
297
+ for (const [conversationRuntime, access] of [
298
+ [{ open() {} }, { ...validAccess, router: null }],
299
+ [{ open() {} }, { ...validAccess, wrapAction: null }],
300
+ [{ open() {} }, { ...validAccess, requestPolicy: "public" }],
301
+ [{ open() {} }, { ...validAccess, subscribeActionId: actionIds.conversationSubscribe }],
302
+ [null, validAccess]
303
+ ]) {
304
+ let registrations = 0;
305
+ const host = createCapabilityRuntime({ providers: [createActionProvider(), AssistantFeature], inputs: {
306
+ "runtime.config": {}, "runtime.env": {},
307
+ "runtime.http": { router: { register() { registrations++; } } },
308
+ "assistant.conversation.access": access,
309
+ ...(conversationRuntime ? { "assistant.conversations": conversationRuntime } : {})
310
+ } });
311
+ await assert.rejects(host.start(), /assistant\.conversation\.access/);
312
+ assert.equal(registrations, 0);
313
+ }
314
+ });