@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.
- package/fixtures/canonical-conversation/App.vue +188 -0
- package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
- package/fixtures/canonical-conversation/index.html +1 -0
- package/fixtures/canonical-conversation/main.js +61 -0
- package/fixtures/canonical-conversation/socket.js +50 -0
- package/fixtures/canonical-conversation/vite.config.mjs +178 -0
- package/fixtures/conversation/vite.config.mjs +12 -2
- package/package.json +23 -10
- package/src/client/components/AssistantConversationClientElement.vue +57 -0
- package/src/client/composables/useAssistantConversation.js +874 -0
- package/src/client/index.js +2 -0
- package/src/client/support/assistantRuntimeState.js +1 -1
- package/src/client/support/subscribeAssistantConversation.js +205 -0
- package/src/server/AssistantProvider.js +82 -106
- package/src/server/actionIds.js +10 -0
- package/src/server/actions.js +287 -79
- package/src/server/createAssistantRuntime.js +138 -0
- package/src/server/createAssistantSettingsActions.js +89 -0
- package/src/server/index.js +6 -0
- package/src/server/inputSchemas.js +90 -0
- package/src/server/registerConversationSubscriptions.js +163 -0
- package/src/server/registerFastifyConversations.js +76 -0
- package/src/server/registerRoutes.js +220 -198
- package/src/server/registerSettingsRoutes.js +113 -0
- package/src/server/repositories/conversationsRepository.js +12 -5
- package/src/server/repositories/messagesRepository.js +12 -0
- package/src/server/services/chatService.js +88 -694
- package/src/server/services/transcriptService.js +56 -52
- package/src/server/sqlConversationStorage.js +192 -0
- package/src/server/support/assistantRouteContext.js +91 -0
- package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
- package/src/shared/assistantSurfaces.js +14 -0
- package/src/shared/conversationRealtime.js +9 -0
- package/src/shared/index.js +1 -0
- package/test/actionDefinitions.test.js +44 -0
- package/test/aiConnections.test.js +1 -1
- package/test/assistantConversationBinding.browser.test.js +1868 -0
- package/test/assistantConversationOperations.test.js +459 -0
- package/test/assistantConversationSubscriptions.test.js +692 -0
- package/test/assistantIntegrationBoundary.test.js +103 -0
- package/test/assistantRuntimeComposition.test.js +68 -0
- package/test/assistantSurfacePattern.test.js +1 -1
- package/test/assistantSurfaces.test.js +23 -0
- package/test/chatServiceLifecycle.test.js +12 -1
- package/test/featureRuntime.test.js +238 -0
- package/test/registerFastifyConversations.test.js +169 -0
- package/test/sqlConversationStorage.test.js +205 -0
- package/test/support/memoryAssistantRows.js +51 -0
- package/test/turnRequests.test.js +11 -2
- 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/
|
|
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
|
|
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
|
+
});
|