@jskit-ai/assistant-runtime 0.1.192 → 0.1.194
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,459 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { createSchema } from "json-rest-schema";
|
|
9
|
+
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
10
|
+
import { createConversationRuntime, createConversationTranscript, createFileConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
|
|
11
|
+
import { createRouter } from "../../kernel/server/http/lib/router.js";
|
|
12
|
+
import { actionIds } from "../src/server/actionIds.js";
|
|
13
|
+
import { createAssistantActions } from "../src/server/actions.js";
|
|
14
|
+
import { registerRoutes } from "../src/server/registerRoutes.js";
|
|
15
|
+
|
|
16
|
+
const config = {
|
|
17
|
+
surfaceDefinitions: {
|
|
18
|
+
home: { enabled: true, requiresWorkspace: false },
|
|
19
|
+
admin: { enabled: true, requiresWorkspace: true }
|
|
20
|
+
},
|
|
21
|
+
assistantSurfaces: {
|
|
22
|
+
home: { settingsSurfaceId: "home", configScope: "global" },
|
|
23
|
+
admin: { settingsSurfaceId: "admin", configScope: "workspace" }
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function fixture(t, { workspace = false, conversationDataSchema = null, applicationChoices = false } = {}) {
|
|
28
|
+
const directory = await mkdtemp(join(tmpdir(), "assistant-operations-"));
|
|
29
|
+
const trustedWorkspace = { id: "workspace-one", slug: "trusted-workspace" };
|
|
30
|
+
const surfaceId = workspace ? "admin" : "home";
|
|
31
|
+
const context = {
|
|
32
|
+
actor: { id: "owner" }, surface: surfaceId, channel: "api",
|
|
33
|
+
requestMeta: workspace ? { resolvedWorkspaceContext: { workspace: trustedWorkspace } } : {}
|
|
34
|
+
};
|
|
35
|
+
const requests = [];
|
|
36
|
+
const dispatched = [Promise.withResolvers(), Promise.withResolvers()];
|
|
37
|
+
const scopeInputs = [];
|
|
38
|
+
const knownIds = new Set(["chat:one", "chat:two"]);
|
|
39
|
+
const access = { allowed: true };
|
|
40
|
+
const storage = createFileConversationStorage({ directory });
|
|
41
|
+
const runtime = createConversationRuntime({
|
|
42
|
+
storage,
|
|
43
|
+
authorize: ({ context: actor, conversationId }) => access.allowed && actor?.actor?.id === "owner" && knownIds.has(conversationId) &&
|
|
44
|
+
(!workspace || actor.workspace?.id === trustedWorkspace.id),
|
|
45
|
+
connections: {
|
|
46
|
+
async resolve({ context: actor }) {
|
|
47
|
+
assert.equal(actor.actor.id, "owner");
|
|
48
|
+
return { providerId: "test", model: "exact-model", sdkPackage: "@ai-sdk/openai-compatible",
|
|
49
|
+
apiKey: "test", baseURL: "http://test.invalid/v1" };
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
fetch: (_url, request) => {
|
|
53
|
+
requests.push(request);
|
|
54
|
+
dispatched[requests.length - 1]?.resolve();
|
|
55
|
+
return new Promise((_resolve, reject) => {
|
|
56
|
+
request.signal.throwIfAborted();
|
|
57
|
+
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
t.after(async () => { await runtime.close(); await rm(directory, { recursive: true, force: true }); });
|
|
62
|
+
const conversations = new Map();
|
|
63
|
+
for (const id of knownIds) {
|
|
64
|
+
conversations.set(id, await runtime.open({
|
|
65
|
+
id,
|
|
66
|
+
context: workspace ? { ...context, workspace: trustedWorkspace } : context,
|
|
67
|
+
configuration: { systemPrompt: "Answer briefly.", integrationId: "assistant" }
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
const workspaceScopeSupport = workspace ? {
|
|
71
|
+
params: { schema: createSchema({ workspaceSlug: { type: "string", required: true } }), mode: "patch" },
|
|
72
|
+
buildInputFromRouteParams(params) { return { workspaceSlug: params.workspaceSlug }; },
|
|
73
|
+
resolveWorkspace(actor, input) {
|
|
74
|
+
scopeInputs.push(input);
|
|
75
|
+
return actor.requestMeta.resolvedWorkspaceContext?.workspace;
|
|
76
|
+
}
|
|
77
|
+
} : null;
|
|
78
|
+
const options = { config, conversationRuntime: applicationChoices ? {
|
|
79
|
+
async open(input) {
|
|
80
|
+
const conversation = await runtime.open(input);
|
|
81
|
+
return { ...conversation, async select({ modelChoice }) {
|
|
82
|
+
assert.equal(modelChoice, "alternate");
|
|
83
|
+
const current = await conversation.read();
|
|
84
|
+
return conversation.select({ operationId: "select-alternate", expectedSegmentId: current.segmentId,
|
|
85
|
+
engine: "api", configuration: { ...current.configuration, integrationId: "alternate", model: "next-model" } });
|
|
86
|
+
}, async replace({ operationId, expectedSegmentId, choice }) {
|
|
87
|
+
assert.equal(choice, "renew");
|
|
88
|
+
return conversation.replace({ operationId, expectedSegmentId, reason: "renewal",
|
|
89
|
+
briefing: "The server's approved continuity briefing." });
|
|
90
|
+
} };
|
|
91
|
+
}
|
|
92
|
+
} : runtime, workspaceScopeSupport, conversationDataSchema,
|
|
93
|
+
...(applicationChoices ? {
|
|
94
|
+
conversationConfigurationSchema: createSchema({ effort: { type: "string", enum: ["low", "high"], required: true } }),
|
|
95
|
+
conversationSelectionSchema: createSchema({ modelChoice: { type: "string", enum: ["alternate"], required: true } }),
|
|
96
|
+
conversationReplacementSchema: createSchema({
|
|
97
|
+
operationId: { type: "string", required: true, minLength: 1, maxLength: 128 },
|
|
98
|
+
expectedSegmentId: { type: "string", required: true, minLength: 1, maxLength: 128 },
|
|
99
|
+
choice: { type: "string", enum: ["renew"], required: true }
|
|
100
|
+
})
|
|
101
|
+
} : {}) };
|
|
102
|
+
const catalogue = createActionCatalogue();
|
|
103
|
+
catalogue.register({ contributorId: "test.assistant", domain: "assistant", actions: createAssistantActions(options) });
|
|
104
|
+
const router = createRouter();
|
|
105
|
+
registerRoutes(router, options);
|
|
106
|
+
const routes = router.list();
|
|
107
|
+
const basePath = workspace
|
|
108
|
+
? "/api/w/:workspaceSlug/assistant/:surfaceId/conversations/:conversationId"
|
|
109
|
+
: "/api/assistant/:surfaceId/conversations/:conversationId";
|
|
110
|
+
|
|
111
|
+
async function invoke(method, suffix = "", { id = "chat:one", messageId = "first", body = {}, query = {}, actor = context, header = surfaceId } = {}) {
|
|
112
|
+
const route = routes.find(candidate => candidate.method === method && candidate.path === basePath + suffix);
|
|
113
|
+
assert.ok(route, `Missing ${method} ${basePath + suffix}`);
|
|
114
|
+
const raw = new EventEmitter();
|
|
115
|
+
const reply = { code(status) { this.status = status; return this; }, send(payload) { this.payload = payload; } };
|
|
116
|
+
await route.handler({
|
|
117
|
+
raw,
|
|
118
|
+
headers: { "x-jskit-surface": header },
|
|
119
|
+
input: { params: { conversationId: id, messageId, surfaceId, workspaceSlug: trustedWorkspace.slug }, body, query },
|
|
120
|
+
executeAction({ actionId, surface, input }) {
|
|
121
|
+
return catalogue.execute({ actionId, input, context: { ...actor, surface } });
|
|
122
|
+
}
|
|
123
|
+
}, reply);
|
|
124
|
+
return { ...reply, raw };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { invoke, requests, dispatched, conversations, directory, storage, scopeInputs, context, catalogue, routes, options, access };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test("conversation read queries preserve original chronological pages and current authorization", async t => {
|
|
131
|
+
const f = await fixture(t, { workspace: true });
|
|
132
|
+
const transcript = createConversationTranscript({ storage: f.storage });
|
|
133
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
134
|
+
await transcript.writeConversationUserMessage("chat:one", { messageId: `question-${index}`, text: `Question ${index}` });
|
|
135
|
+
await transcript.writeConversationAssistantMessage("chat:one", { messageId: `answer-${index}`, text: `Answer ${index}` });
|
|
136
|
+
}
|
|
137
|
+
const full = (await f.invoke("GET")).payload;
|
|
138
|
+
assert.equal(full.conversationLog.length, 3);
|
|
139
|
+
assert.equal(Object.hasOwn(full, "pagination"), false, "An ordinary read retains its full-history default");
|
|
140
|
+
const latest = (await f.invoke("GET", "", { query: {
|
|
141
|
+
limit: "2", conversationId: "chat:two", workspaceSlug: "forged-workspace",
|
|
142
|
+
actor: { id: "another-person" }, context: { actor: { id: "another-person" } }, host: { nativeTools: true }
|
|
143
|
+
} })).payload;
|
|
144
|
+
assert.deepEqual(latest.conversationLog, full.conversationLog.slice(1));
|
|
145
|
+
assert.deepEqual(latest.pagination, {
|
|
146
|
+
beforeTurnId: "", count: 2, hasMoreBefore: true, limit: 2,
|
|
147
|
+
newestTurnId: full.conversationLog[2].turnId,
|
|
148
|
+
nextBeforeTurnId: full.conversationLog[1].turnId,
|
|
149
|
+
oldestTurnId: full.conversationLog[1].turnId, totalTurnCount: 3
|
|
150
|
+
});
|
|
151
|
+
const older = (await f.invoke("GET", "", { query: { beforeTurnId: latest.pagination.nextBeforeTurnId, limit: "2" } })).payload;
|
|
152
|
+
assert.deepEqual(older.conversationLog, full.conversationLog.slice(0, 1));
|
|
153
|
+
assert.equal(older.pagination.hasMoreBefore, false);
|
|
154
|
+
assert.equal(older.pagination.nextBeforeTurnId, "");
|
|
155
|
+
assert.deepEqual((await f.invoke("GET")).payload.conversationLog, full.conversationLog);
|
|
156
|
+
const other = (await f.invoke("GET", "", { id: "chat:two", query: { limit: "2" } })).payload;
|
|
157
|
+
assert.deepEqual(other.conversationLog, []);
|
|
158
|
+
assert.equal(other.pagination.totalTurnCount, 0);
|
|
159
|
+
await assert.rejects(f.invoke("GET", "", { query: { limit: "2" }, actor: { ...f.context, actor: { id: "another-person" } } }),
|
|
160
|
+
{ code: "conversation_forbidden" });
|
|
161
|
+
await assert.rejects(f.invoke("GET", "", { query: { limit: "2" }, id: "unknown" }), { code: "conversation_forbidden" });
|
|
162
|
+
await assert.rejects(f.invoke("GET", "", { query: { beforeTurnId: { path: "/private/history" }, limit: "2" } }),
|
|
163
|
+
{ code: "ACTION_VALIDATION_FAILED" });
|
|
164
|
+
assert.ok(f.scopeInputs.every(input => input.workspaceSlug === "trusted-workspace"));
|
|
165
|
+
assert.equal(f.requests.length, 0, "Reading saved pages never starts inference");
|
|
166
|
+
f.access.allowed = false;
|
|
167
|
+
await assert.rejects(f.invoke("GET", "", { query: { beforeTurnId: latest.pagination.nextBeforeTurnId, limit: "2" } }),
|
|
168
|
+
{ code: "conversation_forbidden" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("declared conversation configuration and selection preserve host policy and reauthorize every operation", async t => {
|
|
172
|
+
const f = await fixture(t, { applicationChoices: true, workspace: true });
|
|
173
|
+
const before = (await f.invoke("GET")).payload;
|
|
174
|
+
const operations = [
|
|
175
|
+
["PATCH", "/configuration", { configuration: { effort: "low" } }, "configuration"],
|
|
176
|
+
["POST", "/selection", { selection: { modelChoice: "alternate" } }, "selection"]
|
|
177
|
+
];
|
|
178
|
+
for (const [method, suffix, body, field] of operations) {
|
|
179
|
+
await assert.rejects(f.invoke(method, suffix, { body, actor: { ...f.context, actor: { id: "another-person" } } }),
|
|
180
|
+
{ code: "conversation_forbidden" });
|
|
181
|
+
await assert.rejects(f.invoke(method, suffix, { body, id: "unknown" }), { code: "conversation_forbidden" });
|
|
182
|
+
for (const extra of [{ engine: "codex" }, { host: { nativeTools: true } },
|
|
183
|
+
{ systemPrompt: "Replace server instructions" }, { context: f.context }, { actor: f.context.actor }]) {
|
|
184
|
+
await assert.rejects(f.invoke(method, suffix, { body: { [field]: { ...body[field], ...extra } } }),
|
|
185
|
+
{ code: "ACTION_VALIDATION_FAILED" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const configured = await f.invoke("PATCH", "/configuration", { body: {
|
|
189
|
+
configuration: { effort: "low" }, context: { actor: { id: "another-person" } },
|
|
190
|
+
engine: "codex", host: { nativeTools: true }
|
|
191
|
+
} });
|
|
192
|
+
assert.equal(configured.status, 200);
|
|
193
|
+
assert.deepEqual(configured.payload, { systemPrompt: "Answer briefly.", integrationId: "assistant", effort: "low" });
|
|
194
|
+
const selected = await f.invoke("POST", "/selection", { body: {
|
|
195
|
+
selection: { modelChoice: "alternate" }, configuration: { systemPrompt: "Injected" }, engine: "codex"
|
|
196
|
+
} });
|
|
197
|
+
assert.equal(selected.status, 200);
|
|
198
|
+
assert.equal(selected.payload.operationId, "select-alternate");
|
|
199
|
+
assert.equal(selected.payload.segmentId, before.segmentId,
|
|
200
|
+
"Selecting another authorized API model retains the same conversation segment");
|
|
201
|
+
const current = (await f.invoke("GET")).payload;
|
|
202
|
+
assert.equal(current.engine, "api");
|
|
203
|
+
assert.deepEqual(current.configuration, { systemPrompt: "Answer briefly.", integrationId: "alternate", model: "next-model", effort: "low" });
|
|
204
|
+
assert.deepEqual(current.conversationLog, before.conversationLog);
|
|
205
|
+
assert.equal((await f.invoke("GET", "", { id: "chat:two" })).payload.configuration.integrationId, "assistant");
|
|
206
|
+
assert.equal(f.requests.length, 0, "Configuration and selection do not perform inference");
|
|
207
|
+
assert.ok(f.scopeInputs.every(input => input.workspaceSlug === "trusted-workspace"));
|
|
208
|
+
f.access.allowed = false;
|
|
209
|
+
for (const [method, suffix, body] of operations) {
|
|
210
|
+
await assert.rejects(f.invoke(method, suffix, { body }), { code: "conversation_forbidden" });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("file-backed conversation routes return admission receipts and cancel one conversation independently", async t => {
|
|
215
|
+
const f = await fixture(t);
|
|
216
|
+
assert.deepEqual(f.catalogue.listDefinitions().map(({ id }) => id), [
|
|
217
|
+
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
|
|
218
|
+
actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
|
|
219
|
+
]);
|
|
220
|
+
assert.equal(f.routes.length, 6, "A supplied runtime does not register the old chat or optional settings routes");
|
|
221
|
+
const initial = await f.invoke("GET");
|
|
222
|
+
assert.equal(initial.status, 200);
|
|
223
|
+
assert.equal(initial.payload.id, "chat:one");
|
|
224
|
+
assert.equal(initial.payload.status, "ready");
|
|
225
|
+
|
|
226
|
+
const first = await f.invoke("POST", "/messages", { body: { messageId: "first", text: "First question" } });
|
|
227
|
+
const second = await f.invoke("POST", "/messages", { id: "chat:two", body: { messageId: "second", text: "Second question" } });
|
|
228
|
+
assert.equal(first.status, 202);
|
|
229
|
+
assert.equal(first.payload.status, "accepted");
|
|
230
|
+
assert.equal(second.payload.status, "accepted");
|
|
231
|
+
await Promise.all(f.dispatched.map(({ promise }) => promise));
|
|
232
|
+
first.raw.emit("close");
|
|
233
|
+
assert.equal(f.requests[0].signal.aborted, false, "HTTP close does not cancel admitted work");
|
|
234
|
+
const during = await f.invoke("GET");
|
|
235
|
+
assert.equal(during.payload.status, "working");
|
|
236
|
+
assert.equal(during.payload.conversationLog[0].user.messageId, "first");
|
|
237
|
+
assert.equal(during.payload.conversationLog[0].assistant, null);
|
|
238
|
+
const repeat = await f.invoke("POST", "/messages", { body: { messageId: "first", text: "First question" } });
|
|
239
|
+
assert.equal(repeat.payload.duplicate, true);
|
|
240
|
+
assert.equal(repeat.payload.turnId, first.payload.turnId);
|
|
241
|
+
assert.equal(f.requests.length, 2);
|
|
242
|
+
|
|
243
|
+
const canceled = await f.invoke("POST", "/cancel");
|
|
244
|
+
assert.deepEqual(canceled.payload, { stopped: true });
|
|
245
|
+
assert.equal(f.requests[0].signal.aborted, true);
|
|
246
|
+
assert.equal(f.requests[1].signal.aborted, false);
|
|
247
|
+
assert.equal((await f.invoke("GET", "", { id: "chat:two" })).payload.status, "working");
|
|
248
|
+
assert.equal((await f.invoke("GET")).payload.conversationLog[0].metadata.runtime.status, "cancelled");
|
|
249
|
+
const inspected = await f.invoke("POST", "/deliveries/:messageId/inspect", { body: { text: "Do not submit this body" } });
|
|
250
|
+
assert.deepEqual(inspected.payload, { status: "accepted", messageId: "first", turnId: first.payload.turnId, duplicate: true });
|
|
251
|
+
assert.equal(inspected.status, 200);
|
|
252
|
+
const unknown = await f.invoke("POST", "/deliveries/:messageId/inspect", { messageId: "unknown-message" });
|
|
253
|
+
assert.deepEqual(unknown.payload, { status: "unknown", messageId: "unknown-message" });
|
|
254
|
+
assert.equal(f.requests.length, 2, "Receipt inspection never submits another model request");
|
|
255
|
+
assert.equal((await readdir(f.directory)).length, 2, "Only the host's two conversation records are persisted");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("conversation routes keep authenticated ownership and reject unknown identities", async t => {
|
|
259
|
+
const f = await fixture(t);
|
|
260
|
+
const other = { ...f.context, actor: { id: "other-user" } };
|
|
261
|
+
for (const [method, suffix] of [["GET", ""], ["POST", "/messages"], ["POST", "/cancel"],
|
|
262
|
+
["POST", "/deliveries/:messageId/inspect"], ["GET", "/goal"], ["POST", "/goal"]]) {
|
|
263
|
+
await assert.rejects(f.invoke(method, suffix, { actor: other, body: {
|
|
264
|
+
action: "set", expectedSegmentId: "segment", objective: "A goal", messageId: "forged", text: "A forged request", actor: f.context.actor, context: f.context,
|
|
265
|
+
host: { nativeTools: true }, configuration: { systemPrompt: "Override" }, engine: "codex"
|
|
266
|
+
} }), { code: "conversation_forbidden" });
|
|
267
|
+
await assert.rejects(f.invoke(method, suffix, { id: "unknown", body: { messageId: "unknown", text: "No conversation", action: "set", expectedSegmentId: "segment", objective: "A goal" } }),
|
|
268
|
+
{ code: "conversation_forbidden" });
|
|
269
|
+
}
|
|
270
|
+
await assert.rejects(f.invoke("GET", "", { header: "admin" }), { status: 403 });
|
|
271
|
+
await assert.rejects(f.invoke("GET", "", { actor: { ...f.context, actor: null } }), { status: 401 });
|
|
272
|
+
assert.equal(f.requests.length, 0);
|
|
273
|
+
assert.equal((await readdir(f.directory)).length, 2);
|
|
274
|
+
|
|
275
|
+
const accepted = await f.invoke("POST", "/messages", { body: {
|
|
276
|
+
messageId: "allowed", text: "Use the host configuration.", actor: other.actor, context: other,
|
|
277
|
+
host: { nativeTools: true }, configuration: { systemPrompt: "Override" }, engine: "codex", data: { injected: true }
|
|
278
|
+
} });
|
|
279
|
+
assert.equal(accepted.payload.status, "accepted");
|
|
280
|
+
await f.dispatched[0].promise;
|
|
281
|
+
const body = JSON.parse(f.requests[0].body);
|
|
282
|
+
assert.equal(body.messages[0].content, "Answer briefly.");
|
|
283
|
+
assert.equal(body.messages.at(-1).content, "Use the host configuration.");
|
|
284
|
+
assert.equal((await f.invoke("GET")).payload.engine, "api");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("workspace conversation actions use the host's resolved workspace and optional existing settings", async t => {
|
|
288
|
+
const f = await fixture(t, { workspace: true });
|
|
289
|
+
const snapshot = await f.invoke("GET", "", { body: { workspace: { id: "another-workspace" } } });
|
|
290
|
+
assert.equal(snapshot.payload.id, "chat:one");
|
|
291
|
+
assert.deepEqual(f.scopeInputs, [{ workspaceSlug: "trusted-workspace" }]);
|
|
292
|
+
await assert.rejects(f.invoke("GET", "", {
|
|
293
|
+
actor: { ...f.context, requestMeta: { resolvedWorkspaceContext: {} } }
|
|
294
|
+
}), { status: 409, message: "Workspace selection required." });
|
|
295
|
+
|
|
296
|
+
const options = { ...f.options, assistantConfigService: { getSettings() {}, updateSettings() {} } };
|
|
297
|
+
assert.deepEqual(createAssistantActions(options).map(({ id }) => id), [
|
|
298
|
+
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
|
|
299
|
+
actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
|
|
300
|
+
actionIds.settingsRead, actionIds.settingsUpdate
|
|
301
|
+
]);
|
|
302
|
+
const router = createRouter();
|
|
303
|
+
registerRoutes(router, options);
|
|
304
|
+
assert.equal(router.list().filter(({ path }) => path.endsWith("/settings")).length, 4);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("declared conversation data keeps the authored target without becoming authentication or host configuration", async t => {
|
|
308
|
+
const conversationDataSchema = createSchema({
|
|
309
|
+
clientId: { type: "string", required: true, minLength: 1, maxLength: 128 },
|
|
310
|
+
focus: { type: "object", required: true, schema: createSchema({
|
|
311
|
+
projectSlug: { type: "string", required: true, minLength: 1, maxLength: 256 }
|
|
312
|
+
}) }
|
|
313
|
+
});
|
|
314
|
+
const f = await fixture(t, { conversationDataSchema });
|
|
315
|
+
const data = { clientId: "original-browser", focus: { projectSlug: "original-project" } };
|
|
316
|
+
await assert.rejects(f.invoke("POST", "/messages", { actor: { ...f.context, actor: { id: "other-user" } }, body: {
|
|
317
|
+
messageId: "forged", text: "Untrusted authored data", data
|
|
318
|
+
} }), { code: "conversation_forbidden" });
|
|
319
|
+
await assert.rejects(f.invoke("POST", "/messages", { body: {
|
|
320
|
+
messageId: "undeclared", text: "Undeclared authored data", data: { ...data, actor: f.context.actor }
|
|
321
|
+
} }), { code: "ACTION_VALIDATION_FAILED" });
|
|
322
|
+
await assert.rejects(f.invoke("POST", "/messages", { body: {
|
|
323
|
+
messageId: "malformed", text: "Invalid authored target", data: { ...data, focus: { projectSlug: "x".repeat(257) } }
|
|
324
|
+
} }), { code: "ACTION_VALIDATION_FAILED" });
|
|
325
|
+
assert.equal(f.requests.length, 0);
|
|
326
|
+
|
|
327
|
+
const input = { messageId: "captured", text: "Discuss this project", data };
|
|
328
|
+
const accepted = await f.invoke("POST", "/messages", { body: { ...input,
|
|
329
|
+
actor: { id: "other-user" }, context: {}, host: { nativeTools: true }
|
|
330
|
+
} });
|
|
331
|
+
await f.dispatched[0].promise;
|
|
332
|
+
assert.equal(accepted.payload.status, "accepted");
|
|
333
|
+
const state = (await f.invoke("GET")).payload;
|
|
334
|
+
assert.deepEqual(state.conversationLog[0].user.data, data);
|
|
335
|
+
assert.equal(state.engine, "api");
|
|
336
|
+
assert.equal(JSON.parse(f.requests[0].body).messages[0].content, "Answer briefly.");
|
|
337
|
+
const repeated = await f.invoke("POST", "/messages", { body: input });
|
|
338
|
+
assert.equal(repeated.payload.duplicate, true);
|
|
339
|
+
assert.equal(repeated.payload.turnId, accepted.payload.turnId);
|
|
340
|
+
await assert.rejects(f.invoke("POST", "/messages", { body: { ...input,
|
|
341
|
+
data: { ...data, focus: { projectSlug: "new-project" } }
|
|
342
|
+
} }), { code: "conversation_message_conflict" });
|
|
343
|
+
assert.equal(f.requests.length, 1, "Retargeting a captured message cannot dispatch it again");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("goal operations retain capabilities and cannot forward native host or attachment objects", async t => {
|
|
347
|
+
const f = await fixture(t);
|
|
348
|
+
const absentGoal = await f.invoke("GET", "/goal");
|
|
349
|
+
assert.equal(absentGoal.payload, null);
|
|
350
|
+
assert.equal(absentGoal.status, 204, "An absent native goal uses the client's existing no-content response contract");
|
|
351
|
+
await assert.rejects(f.invoke("POST", "/goal", { body: {
|
|
352
|
+
action: "set", messageId: "goal-one", expectedSegmentId: "current", objective: "Do this"
|
|
353
|
+
} }), { code: "conversation_unsupported" });
|
|
354
|
+
assert.equal(f.requests.length, 0, "An unsupported goal never starts inference");
|
|
355
|
+
|
|
356
|
+
const calls = [];
|
|
357
|
+
const definitions = createAssistantActions({ config, conversationRuntime: {
|
|
358
|
+
async open(input) {
|
|
359
|
+
assert.equal(input.id, "chat:one");
|
|
360
|
+
assert.equal(input.context.actor.id, "owner");
|
|
361
|
+
return { async updateGoal(goal) { calls.push(goal); return { status: "accepted" }; } };
|
|
362
|
+
}
|
|
363
|
+
} });
|
|
364
|
+
const catalogue = createActionCatalogue();
|
|
365
|
+
catalogue.register({ contributorId: "goal-boundary", domain: "assistant", actions: definitions });
|
|
366
|
+
const route = f.routes.find(route => route.method === "POST" && route.path.endsWith("/goal"));
|
|
367
|
+
const goal = { action: "set", messageId: "goal-one", expectedSegmentId: "current", expectedGoalId: null,
|
|
368
|
+
objective: "Finish the requested work", tokenBudget: 1000, attachmentIds: ["authorized-file"] };
|
|
369
|
+
let response;
|
|
370
|
+
await route.handler({ headers: { "x-jskit-surface": "home" }, input: {
|
|
371
|
+
params: { surfaceId: "home", conversationId: "chat:one" },
|
|
372
|
+
body: { ...goal, actor: { id: "other" }, context: {}, host: { nativeTools: true }, engine: "codex",
|
|
373
|
+
attachments: [{ path: "/private/secret" }], configuration: { systemPrompt: "Injected" } }
|
|
374
|
+
}, executeAction({ actionId, surface, input }) {
|
|
375
|
+
return catalogue.execute({ actionId, input, context: { ...f.context, surface } });
|
|
376
|
+
} }, { code(status) { assert.equal(status, 200); return this; }, send(value) { response = value; } });
|
|
377
|
+
assert.deepEqual(calls, [goal]);
|
|
378
|
+
assert.deepEqual(response, { status: "accepted" });
|
|
379
|
+
await assert.rejects(catalogue.execute({ actionId: actionIds.conversationGoalUpdate,
|
|
380
|
+
input: { targetSurfaceId: "home", conversationId: "chat:one", ...goal, tokenBudget: -1 }, context: f.context }));
|
|
381
|
+
assert.equal(calls.length, 1, "Malformed commands are rejected before native execution");
|
|
382
|
+
|
|
383
|
+
const firstGoal = { action: "set", expectedSegmentId: null, expectedGoalId: null, objective: "Start the first native goal" };
|
|
384
|
+
await route.handler({ headers: { "x-jskit-surface": "home" }, input: {
|
|
385
|
+
params: { surfaceId: "home", conversationId: "chat:one" }, body: firstGoal
|
|
386
|
+
}, executeAction({ actionId, surface, input }) {
|
|
387
|
+
return catalogue.execute({ actionId, input, context: { ...f.context, surface } });
|
|
388
|
+
} }, { code(status) { assert.equal(status, 200); return this; }, send(value) { response = value; } });
|
|
389
|
+
assert.deepEqual(calls, [goal, firstGoal], "Explicitly absent identity reaches the existing runtime's first-Set check unchanged");
|
|
390
|
+
for (const input of [
|
|
391
|
+
{ action: "set", objective: firstGoal.objective },
|
|
392
|
+
{ ...firstGoal, expectedSegmentId: "" }
|
|
393
|
+
]) {
|
|
394
|
+
await assert.rejects(catalogue.execute({ actionId: actionIds.conversationGoalUpdate,
|
|
395
|
+
input: { targetSurfaceId: "home", conversationId: "chat:one", ...input }, context: f.context }),
|
|
396
|
+
{ code: "ACTION_VALIDATION_FAILED" });
|
|
397
|
+
}
|
|
398
|
+
assert.equal(calls.length, 2, "Missing and empty identity are not an observed absence");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("the supplied runtime route/action path imports without SQL or the old chat service", () => {
|
|
402
|
+
const result = spawnSync(process.execPath, ["--input-type=module", "-e", `
|
|
403
|
+
import assert from "node:assert/strict";
|
|
404
|
+
import { registerHooks } from "node:module";
|
|
405
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
406
|
+
if (["@jskit-ai/database-runtime", "knex"].some(name => specifier === name || specifier.startsWith(name + "/")) ||
|
|
407
|
+
specifier.endsWith("/chatService.js")) throw new Error("Unexpected legacy dependency: " + specifier);
|
|
408
|
+
return nextResolve(specifier, context);
|
|
409
|
+
} });
|
|
410
|
+
for (const name of ["@jskit-ai/database-runtime", "knex"]) {
|
|
411
|
+
await assert.rejects(() => import(name), /Unexpected legacy dependency/);
|
|
412
|
+
}
|
|
413
|
+
const { createAssistantActions } = await import(${JSON.stringify(new URL("../src/server/actions.js", import.meta.url).href)});
|
|
414
|
+
const { registerRoutes } = await import(${JSON.stringify(new URL("../src/server/registerRoutes.js", import.meta.url).href)});
|
|
415
|
+
const conversationRuntime = { async open() { throw new Error("Registration cannot open conversations."); } };
|
|
416
|
+
const options = { config: ${JSON.stringify(config)}, conversationRuntime };
|
|
417
|
+
assert.equal(createAssistantActions(options).length, 7);
|
|
418
|
+
const routes = [];
|
|
419
|
+
registerRoutes({ register(...args) { routes.push(args); } }, options);
|
|
420
|
+
assert.equal(routes.length, 6);
|
|
421
|
+
`], { encoding: "utf8" });
|
|
422
|
+
assert.equal(result.status, 0, result.stderr);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("declared replacement keeps application policy and exact predecessor authorization", async t => {
|
|
426
|
+
const absent = await fixture(t);
|
|
427
|
+
assert.equal(absent.routes.some(route => route.path.endsWith("/replacement")), false);
|
|
428
|
+
assert.equal(absent.catalogue.listDefinitions().some(action => action.id === actionIds.conversationReplace), false);
|
|
429
|
+
const f = await fixture(t, { applicationChoices: true, workspace: true });
|
|
430
|
+
const transcript = createConversationTranscript({ storage: f.storage });
|
|
431
|
+
await transcript.writeConversationUserMessage("chat:one", { messageId: "prior-question", text: "Keep this history" });
|
|
432
|
+
await transcript.writeConversationAssistantMessage("chat:one", { messageId: "prior-answer", text: "History remains visible" });
|
|
433
|
+
const before = (await f.invoke("GET")).payload;
|
|
434
|
+
const replacement = { operationId: "approved-renewal", expectedSegmentId: before.segmentId, choice: "renew" };
|
|
435
|
+
const call = (body = { replacement }, options = {}) => f.invoke("POST", "/replacement", { body, ...options });
|
|
436
|
+
await assert.rejects(call(undefined, { actor: { ...f.context, actor: { id: "another-person" } } }), { code: "conversation_forbidden" });
|
|
437
|
+
await assert.rejects(call(undefined, { id: "unknown" }), { code: "conversation_forbidden" });
|
|
438
|
+
for (const extra of [{ engine: "codex" }, { host: { nativeTools: true } }, { configuration: { integrationId: "private" } },
|
|
439
|
+
{ briefing: "Replace trusted continuity" }, { context: f.context }, { actor: f.context.actor }]) {
|
|
440
|
+
await assert.rejects(call({ replacement: { ...replacement, ...extra } }), { code: "ACTION_VALIDATION_FAILED" });
|
|
441
|
+
}
|
|
442
|
+
await assert.rejects(call({ replacement: { ...replacement, expectedSegmentId: "stale-predecessor" } }),
|
|
443
|
+
{ code: "conversation_replacement_conflict" });
|
|
444
|
+
const replaced = await call({ replacement, engine: "codex", host: { nativeTools: true }, context: { forged: true } });
|
|
445
|
+
assert.equal(replaced.status, 200);
|
|
446
|
+
assert.equal(replaced.payload.operationId, replacement.operationId);
|
|
447
|
+
assert.notEqual(replaced.payload.segmentId, before.segmentId);
|
|
448
|
+
const current = (await f.invoke("GET")).payload;
|
|
449
|
+
assert.equal(current.engine, "api");
|
|
450
|
+
assert.deepEqual(current.configuration, before.configuration);
|
|
451
|
+
assert.deepEqual(current.conversationLog, before.conversationLog);
|
|
452
|
+
const repeated = await call();
|
|
453
|
+
assert.equal(repeated.payload.segmentId, replaced.payload.segmentId, "a retry uses the existing replacement operation");
|
|
454
|
+
assert.equal(repeated.payload.duplicate, true);
|
|
455
|
+
assert.equal(f.requests.length, 0, "replacement does not send the briefing as an unrequested inference");
|
|
456
|
+
assert.ok(f.scopeInputs.every(input => input.workspaceSlug === "trusted-workspace"));
|
|
457
|
+
f.access.allowed = false;
|
|
458
|
+
await assert.rejects(call(), { code: "conversation_forbidden" });
|
|
459
|
+
});
|