@jskit-ai/assistant-runtime 0.2.9 → 0.2.10
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/README.md +1 -1
- package/fixtures/canonical-conversation/main.js +2 -1
- package/fixtures/conversation/main.js +2 -1
- package/fixtures/conversation/vite.config.mjs +4 -3
- package/migrations/assistant_config_initial.cjs +1 -5
- package/migrations/assistant_transcripts_initial.cjs +3 -9
- package/migrations/assistant_transcripts_summary.cjs +28 -0
- package/migrations/assistant_turn_requests.cjs +2 -1
- package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
- package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
- package/package.json +17 -10
- package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
- package/src/client/components/AssistantConversationClientElement.vue +10 -4
- package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
- package/src/client/composables/conversationGoal.js +266 -0
- package/src/client/composables/conversationHistory.js +215 -0
- package/src/client/composables/conversationRuntime.js +624 -0
- package/src/client/composables/useAssistantConversation.js +208 -884
- package/src/client/composables/useAssistantRuntime.js +60 -45
- package/src/client/providers/AssistantClientProvider.js +1 -0
- package/src/client/support/assistantRuntimeState.js +6 -34
- package/src/client/support/conversationFailures.js +30 -0
- package/src/client/support/conversationRestoreSupport.js +13 -5
- package/src/client/support/subscribeAssistantConversation.js +32 -9
- package/src/client/support/workspaceScopeSupport.js +13 -22
- package/src/client/types.js +46 -0
- package/src/server/AssistantProvider.js +45 -17
- package/src/server/actions.js +162 -201
- package/src/server/conversationOperations.js +141 -0
- package/src/server/createAssistantRuntime.js +83 -44
- package/src/server/createAssistantSettingsActions.js +69 -47
- package/src/server/hostedAssistant.js +21 -0
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +34 -11
- package/src/server/registerFastifyConversations.js +18 -6
- package/src/server/registerRoutes.js +5 -2
- package/src/server/registerSettingsRoutes.js +1 -1
- package/src/server/repositories/assistantConfigRepository.js +10 -20
- package/src/server/repositories/conversationsRepository.js +35 -10
- package/src/server/repositories/messagesRepository.js +14 -10
- package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
- package/src/server/repositories/turnRequestsRepository.js +6 -1
- package/src/server/services/assistantConfigService.js +25 -23
- package/src/server/services/chatService.js +57 -61
- package/src/server/services/transcriptService.js +15 -7
- package/src/server/sqlConversationStorage.js +90 -18
- package/src/server/support/assistantServerConfig.js +1 -1
- package/src/server/support/assistantStreamWriter.js +7 -1
- package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
- package/src/server/types.js +38 -0
- package/src/shared/assistantSurfaces.js +2 -2
- package/test/assistantConversationOperations.test.js +5 -5
- package/test/assistantRuntimeState.test.js +1 -16
- package/test/chatServiceLifecycle.test.js +62 -154
- package/test/createSurfaceAwareToolCatalog.test.js +5 -0
- package/test/featureRuntime.test.js +12 -6
- package/test/lazyAppConfig.test.js +3 -5
- package/test/packageMetadata.test.js +7 -1
- package/test/sqlConversationStorage.mysql.test.js +89 -0
- package/test/sqlConversationStorage.test.js +161 -16
- package/test/support/memoryAssistantRows.js +6 -1
- package/test/support/scriptedModel.js +32 -0
- package/test/turnRequests.test.js +4 -2
- package/test/usersForeignKeysMigration.test.js +46 -0
- package/test/workspacesForeignKeysMigration.test.js +66 -0
- package/src/client/support/accessDenied.js +0 -6
|
@@ -4,6 +4,7 @@ import test from "node:test";
|
|
|
4
4
|
import { createChatService } from "../src/server/services/chatService.js";
|
|
5
5
|
import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
|
|
6
6
|
import { createMemoryAssistantRows } from "./support/memoryAssistantRows.js";
|
|
7
|
+
import { createScriptedModel } from "./support/scriptedModel.js";
|
|
7
8
|
|
|
8
9
|
const APP_CONFIG = Object.freeze({
|
|
9
10
|
surfaceDefinitions: {
|
|
@@ -73,7 +74,8 @@ async function* completionStream(completion = {}) {
|
|
|
73
74
|
yield { choices: [{ delta: {}, finish_reason: completion.toolCall ? "tool_calls" : "stop" }] };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime
|
|
77
|
+
function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime,
|
|
78
|
+
once } = {}) {
|
|
77
79
|
const pendingCompletions = [...completions];
|
|
78
80
|
const completionRequests = [];
|
|
79
81
|
const transcriptMessages = [];
|
|
@@ -102,15 +104,15 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
102
104
|
supportsAttachments,
|
|
103
105
|
provider: "test",
|
|
104
106
|
defaultModel: "test-model",
|
|
105
|
-
|
|
107
|
+
model: createScriptedModel(body => {
|
|
106
108
|
completionRequests.push({
|
|
107
|
-
messages: structuredClone(
|
|
108
|
-
tools: structuredClone(
|
|
109
|
+
messages: structuredClone(body.messages),
|
|
110
|
+
tools: structuredClone(body.tools)
|
|
109
111
|
});
|
|
110
112
|
const completion = pendingCompletions.shift();
|
|
111
113
|
assert.ok(completion, "Expected a queued assistant completion.");
|
|
112
114
|
return completion[Symbol.asyncIterator] ? completion : completionStream(completion);
|
|
113
|
-
}
|
|
115
|
+
}, { once })
|
|
114
116
|
};
|
|
115
117
|
}
|
|
116
118
|
},
|
|
@@ -212,6 +214,11 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
|
|
|
212
214
|
};
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
function messageText(message) {
|
|
218
|
+
return typeof message?.content === "string" ? message.content
|
|
219
|
+
: (message?.content || []).filter(part => part.type === "text").map(part => part.text).join("");
|
|
220
|
+
}
|
|
221
|
+
|
|
215
222
|
function assistantMessages(events) {
|
|
216
223
|
return events
|
|
217
224
|
.filter((event) => event.method === "sendAssistantMessage" && event.payload.status !== "streaming")
|
|
@@ -261,12 +268,12 @@ test("an empty authorized tool set does not advertise discovery or collection ac
|
|
|
261
268
|
await harness.run("List the available records.");
|
|
262
269
|
|
|
263
270
|
const request = harness.completionRequests[0];
|
|
264
|
-
assert.
|
|
271
|
+
assert.equal(request.tools, undefined, "The model is offered no tools");
|
|
265
272
|
assert.match(request.messages[0].content, /No tools are currently available for this user\/session/u);
|
|
266
273
|
assert.doesNotMatch(request.messages[0].content, /assistant_action_search|collection action/u);
|
|
267
274
|
});
|
|
268
275
|
|
|
269
|
-
test("
|
|
276
|
+
test("a reply that only announces work carries on once, and the state note gives the time without a clock action", async () => {
|
|
270
277
|
const harness = createHarness(
|
|
271
278
|
[
|
|
272
279
|
textCompletion("Let me query the current time."),
|
|
@@ -280,17 +287,10 @@ test("progress-only output is replaced on retry and current-time prompts require
|
|
|
280
287
|
additionalProperties: false,
|
|
281
288
|
properties: {}
|
|
282
289
|
},
|
|
283
|
-
outputSchema: { type: "object" }
|
|
284
|
-
preflight: ["current-time"]
|
|
290
|
+
outputSchema: { type: "object" }
|
|
285
291
|
}],
|
|
286
292
|
executeToolCall() {
|
|
287
|
-
return {
|
|
288
|
-
ok: true,
|
|
289
|
-
result: {
|
|
290
|
-
localDateTime: "2026-08-25T11:15:00+08:00",
|
|
291
|
-
timeZone: "Australia/Perth"
|
|
292
|
-
}
|
|
293
|
-
};
|
|
293
|
+
return { ok: true, result: { localDateTime: "2026-08-25T11:15:00+08:00", timeZone: "Australia/Perth" } };
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
);
|
|
@@ -298,27 +298,16 @@ test("progress-only output is replaced on retry and current-time prompts require
|
|
|
298
298
|
const result = await harness.run("What day is it today?");
|
|
299
299
|
|
|
300
300
|
assert.equal(result.status, "completed");
|
|
301
|
-
assert.deepEqual(harness.executedTools
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
)
|
|
301
|
+
assert.deepEqual(harness.executedTools, [], "No clock action runs before the model asks for one");
|
|
302
|
+
const system = harness.completionRequests[0].messages[0];
|
|
303
|
+
assert.equal(system.role, "system");
|
|
304
|
+
assert.match(system.content, /use the current time in the system's state note/u);
|
|
305
|
+
assert.match(system.content, /\n\nCurrent state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\./u,
|
|
306
|
+
"The state note at the end of the system prompt tells the model the current time");
|
|
307
|
+
assert.match(messageText(harness.completionRequests[1].messages.at(-1)), /^\(Check from the app: your reply ended by saying what you'd do next/u,
|
|
308
|
+
"The reply that only announced work carries on once with the engine's check");
|
|
308
309
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
|
|
309
310
|
assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
|
|
310
|
-
assert.match(
|
|
311
|
-
harness.completionRequests[0].messages[0].content,
|
|
312
|
-
/first use any available authoritative workspace clock action/u
|
|
313
|
-
);
|
|
314
|
-
assert.equal(
|
|
315
|
-
harness.completionRequests[1].messages.some((message) => message.content === "Let me query the current time."),
|
|
316
|
-
false
|
|
317
|
-
);
|
|
318
|
-
assert.equal(
|
|
319
|
-
harness.completionRequests[1].messages.some((message) => /Either call the required available tool now/u.test(message.content)),
|
|
320
|
-
true
|
|
321
|
-
);
|
|
322
311
|
assert.deepEqual(
|
|
323
312
|
harness.transcriptMessages
|
|
324
313
|
.filter((message) => message.kind === "chat")
|
|
@@ -327,50 +316,6 @@ test("progress-only output is replaced on retry and current-time prompts require
|
|
|
327
316
|
);
|
|
328
317
|
});
|
|
329
318
|
|
|
330
|
-
test("current-time preflight does not run for unrelated prompts", async () => {
|
|
331
|
-
const harness = createHarness(
|
|
332
|
-
[textCompletion("There are three active bookings.")],
|
|
333
|
-
{
|
|
334
|
-
tools: [{
|
|
335
|
-
name: "workspace_clock",
|
|
336
|
-
parameters: { type: "object", properties: {} },
|
|
337
|
-
outputSchema: { type: "object" },
|
|
338
|
-
preflight: ["current-time"]
|
|
339
|
-
}]
|
|
340
|
-
}
|
|
341
|
-
);
|
|
342
|
-
|
|
343
|
-
await harness.run("How many active bookings are there?");
|
|
344
|
-
|
|
345
|
-
assert.deepEqual(harness.executedTools, []);
|
|
346
|
-
assert.deepEqual(assistantMessages(harness.streamEvents), ["There are three active bookings."]);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
test("current-time preflight does not invent required tool input", async () => {
|
|
350
|
-
const harness = createHarness(
|
|
351
|
-
[textCompletion("Choose a timezone before asking for its local time.")],
|
|
352
|
-
{
|
|
353
|
-
tools: [{
|
|
354
|
-
name: "timezone_clock",
|
|
355
|
-
parameters: {
|
|
356
|
-
type: "object",
|
|
357
|
-
required: ["timeZone"],
|
|
358
|
-
properties: {
|
|
359
|
-
timeZone: { type: "string" }
|
|
360
|
-
}
|
|
361
|
-
},
|
|
362
|
-
outputSchema: { type: "object" },
|
|
363
|
-
preflight: ["current-time"]
|
|
364
|
-
}]
|
|
365
|
-
}
|
|
366
|
-
);
|
|
367
|
-
|
|
368
|
-
await harness.run("What time is it now?");
|
|
369
|
-
|
|
370
|
-
assert.deepEqual(harness.executedTools, []);
|
|
371
|
-
assert.deepEqual(assistantMessages(harness.streamEvents), ["Choose a timezone before asking for its local time."]);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
319
|
test("native search, contract, and execution workflows stream progress across more than four tool rounds", async () => {
|
|
375
320
|
const harness = createHarness([
|
|
376
321
|
toolCompletion("action_search", 1, { text: "I'll search first." }),
|
|
@@ -389,88 +334,47 @@ test("native search, contract, and execution workflows stream progress across mo
|
|
|
389
334
|
);
|
|
390
335
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["The requested operation completed successfully."]);
|
|
391
336
|
assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
assert.
|
|
396
|
-
|
|
337
|
+
const lastRequest = harness.completionRequests.at(-1).messages;
|
|
338
|
+
const assistantToolMessages = lastRequest.filter((message) => Array.isArray(message.tool_calls));
|
|
339
|
+
assert.equal(assistantToolMessages.length, 5);
|
|
340
|
+
assert.deepEqual(assistantToolMessages.map(message => message.content || ""),
|
|
341
|
+
["I'll search first.", "Let me inspect that contract.", "I'll execute it now.", "", ""],
|
|
342
|
+
"Each step's words stay with its action calls in the model's history");
|
|
397
343
|
});
|
|
398
344
|
|
|
399
|
-
test("
|
|
400
|
-
const mainRounds = Array.from({ length: 16 }, (_, index) =>
|
|
401
|
-
toolCompletion("action_execute", index + 1)
|
|
402
|
-
);
|
|
345
|
+
test("at the step limit the model gives its own account of where it got to", async () => {
|
|
403
346
|
const harness = createHarness(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
textCompletion("Let me prepare the answer."),
|
|
407
|
-
textCompletion("I'll summarize the result."),
|
|
408
|
-
textCompletion("Checking the final output.")
|
|
409
|
-
],
|
|
410
|
-
{
|
|
411
|
-
executeToolCall(_request, sequence) {
|
|
412
|
-
return {
|
|
413
|
-
ok: true,
|
|
414
|
-
result: {
|
|
415
|
-
sequence,
|
|
416
|
-
payload: sequence === 16 ? "x".repeat(10_000) : "ok"
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
}
|
|
347
|
+
Array.from({ length: 32 }, (_, index) => toolCompletion("action_execute", index + 1)),
|
|
348
|
+
{ once: body => /personal assistant/u.test(JSON.stringify(body.messages)) ? "I ran 32 actions and stopped before the last check." : "" }
|
|
421
349
|
);
|
|
422
350
|
|
|
423
351
|
await harness.run();
|
|
424
352
|
|
|
425
|
-
|
|
426
|
-
assert.equal(harness.
|
|
427
|
-
assert.
|
|
428
|
-
assert.
|
|
429
|
-
assert.ok(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"));
|
|
353
|
+
assert.equal(harness.executedTools.length, 32, "The step limit stays within the turn's 32 application action calls");
|
|
354
|
+
assert.equal(harness.completionRequests.length, 32);
|
|
355
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["I ran 32 actions and stopped before the last check."]);
|
|
356
|
+
assert.doesNotMatch(JSON.stringify(harness.streamEvents), /Limit reached/u);
|
|
430
357
|
});
|
|
431
358
|
|
|
432
|
-
test("
|
|
433
|
-
const mainRounds = Array.from({ length: 16 }, (_, index) =>
|
|
434
|
-
toolCompletion(index === 1 ? "action_search" : "action_execute", index + 1)
|
|
435
|
-
);
|
|
359
|
+
test("a failed action's error goes back to the model, and the action stays available", async () => {
|
|
436
360
|
const harness = createHarness(
|
|
437
|
-
[
|
|
438
|
-
...mainRounds,
|
|
439
|
-
textCompletion("Let me prepare the answer."),
|
|
440
|
-
textCompletion("I'll summarize the result."),
|
|
441
|
-
textCompletion("Checking the final output.")
|
|
442
|
-
],
|
|
361
|
+
[toolCompletion("action_search", 1), toolCompletion("action_search", 2), textCompletion("The second search found it.")],
|
|
443
362
|
{
|
|
444
|
-
executeToolCall(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
error: {
|
|
449
|
-
code: "assistant_tool_failed",
|
|
450
|
-
message: "Tool call failed."
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
return {
|
|
455
|
-
ok: true,
|
|
456
|
-
result: {
|
|
457
|
-
sequence,
|
|
458
|
-
payload: sequence === 16 ? "x".repeat(10_000) : "ok"
|
|
459
|
-
}
|
|
460
|
-
};
|
|
363
|
+
executeToolCall(_request, sequence) {
|
|
364
|
+
return sequence === 1
|
|
365
|
+
? { ok: false, error: { code: "assistant_tool_failed", message: "Tool call failed." } }
|
|
366
|
+
: { ok: true, result: { found: 1 } };
|
|
461
367
|
}
|
|
462
368
|
}
|
|
463
369
|
);
|
|
464
370
|
|
|
465
371
|
await harness.run();
|
|
466
372
|
|
|
467
|
-
|
|
468
|
-
assert.
|
|
469
|
-
|
|
470
|
-
assert.
|
|
471
|
-
assert.
|
|
472
|
-
assert.match(fallback, /…\[truncated\]$/u);
|
|
473
|
-
assert.doesNotMatch(fallback, /Limit reached/u);
|
|
373
|
+
assert.deepEqual(harness.executedTools.map(request => request.toolName), ["action_search", "action_search"]);
|
|
374
|
+
assert.deepEqual(JSON.parse(harness.completionRequests[1].messages.at(-1).content),
|
|
375
|
+
{ error: { code: "assistant_tool_failed", message: "Tool call failed." } });
|
|
376
|
+
assert.ok(harness.completionRequests[1].tools.some(tool => tool.function.name === "action_search"), "The failed action is not hidden");
|
|
377
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["The second search found it."]);
|
|
474
378
|
});
|
|
475
379
|
|
|
476
380
|
|
|
@@ -487,6 +391,8 @@ test("answer text reaches the client before the provider finishes, then persists
|
|
|
487
391
|
const harness = createHarness([response()]);
|
|
488
392
|
const running = harness.run();
|
|
489
393
|
await firstChunk.promise;
|
|
394
|
+
const deltas = () => harness.streamEvents.filter(event => event.method === "sendAssistantDelta");
|
|
395
|
+
for (let tries = 0; tries < 200 && !deltas().length; tries += 1) await new Promise(resolve => setTimeout(resolve, 5));
|
|
490
396
|
try {
|
|
491
397
|
assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The first sentence."]);
|
|
492
398
|
assert.deepEqual(harness.completedConversations, []);
|
|
@@ -500,7 +406,7 @@ test("answer text reaches the client before the provider finishes, then persists
|
|
|
500
406
|
assert.deepEqual(harness.transcriptMessages.filter(message => message.role === "assistant").map(message => message.contentText), ["The first sentence. The second sentence."]);
|
|
501
407
|
});
|
|
502
408
|
|
|
503
|
-
test("
|
|
409
|
+
test("tool calls written as text never run, never become answer deltas, and the model carries on once", async () => {
|
|
504
410
|
async function* response() {
|
|
505
411
|
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>"]) {
|
|
506
412
|
yield { choices: [{ delta: { content } }] };
|
|
@@ -509,13 +415,13 @@ test("split internal tags and tool arguments never become answer deltas", async
|
|
|
509
415
|
}
|
|
510
416
|
const harness = createHarness([response(), textCompletion("The search is complete.")]);
|
|
511
417
|
await harness.run();
|
|
512
|
-
assert.deepEqual(harness.executedTools
|
|
513
|
-
assert.
|
|
418
|
+
assert.deepEqual(harness.executedTools, [], "A tool call written as text is not run");
|
|
419
|
+
assert.match(messageText(harness.completionRequests[1].messages.at(-1)), /^\(Check from the app: your last message contained raw markup/u);
|
|
420
|
+
assert.doesNotMatch(JSON.stringify(harness.streamEvents), /DSML|private reasoning|private arguments/u);
|
|
514
421
|
assert.deepEqual(assistantMessages(harness.streamEvents), ["The search is complete."]);
|
|
515
422
|
});
|
|
516
423
|
|
|
517
|
-
|
|
518
|
-
test("application-authorized attachments reach the model and survive transcript restore", async () => {
|
|
424
|
+
test("application-authorized attachments reach the model and are authorized again from saved history", async () => {
|
|
519
425
|
const calls = [];
|
|
520
426
|
const receipt = { attachmentId: "file-one", fileName: "notes.txt", size: 12 };
|
|
521
427
|
const attachments = { async resolve(request) {
|
|
@@ -525,15 +431,17 @@ test("application-authorized attachments reach the model and survive transcript
|
|
|
525
431
|
if (request.attachmentIds[0] !== "file-one") throw new Error("Attachment access denied");
|
|
526
432
|
return { attachments: [receipt], content: [{ type: "text", text: "Authorized file bytes" }] };
|
|
527
433
|
} };
|
|
528
|
-
const harness = createHarness([textCompletion("I read your notes.")], { attachments });
|
|
529
|
-
await harness.run("Read this", { attachmentIds: ["file-one"]
|
|
530
|
-
|
|
531
|
-
] });
|
|
532
|
-
assert.equal(calls.length, 2, "Both current and historical files must be authorized");
|
|
434
|
+
const harness = createHarness([textCompletion("I read your notes."), textCompletion("They mention the plan.")], { attachments });
|
|
435
|
+
await harness.run("Read this", { attachmentIds: ["file-one"] });
|
|
436
|
+
assert.equal(calls.length, 1, "The new message's file is authorized before admission");
|
|
533
437
|
assert.deepEqual(harness.transcriptMessages[0].metadata.attachments, [receipt]);
|
|
534
438
|
assert.deepEqual(harness.completionRequests[0].messages.at(-1).content, [
|
|
535
439
|
{ type: "text", text: "Read this" }, { type: "text", text: "Authorized file bytes" }
|
|
536
440
|
]);
|
|
441
|
+
await harness.run("What do they say?", { messageId: "message_2" });
|
|
442
|
+
assert.equal(calls.length, 2, "The saved file is authorized again when the next turn reads its history");
|
|
443
|
+
assert.ok(harness.completionRequests[1].messages.some(message => message.role === "user" && Array.isArray(message.content) &&
|
|
444
|
+
message.content.some(part => part.text === "Authorized file bytes")), "The saved history gives the model the earlier file");
|
|
537
445
|
const denied = createHarness([textCompletion("Must not run")], { attachments });
|
|
538
446
|
await assert.rejects(denied.run("Read this", { attachmentIds: ["another-users-file"] }), /access denied/);
|
|
539
447
|
assert.deepEqual(denied.completionRequests, []);
|
|
@@ -77,3 +77,8 @@ test("surface-aware tool catalog applies per-surface skip and barred configurati
|
|
|
77
77
|
skipActionPrefixes: ["assistant.", "demo.admin."]
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
|
+
|
|
81
|
+
test("an application reaches the surface-aware tool catalog through the package's server entry", async () => {
|
|
82
|
+
const server = await import("@jskit-ai/assistant-runtime/server");
|
|
83
|
+
assert.equal(server.createSurfaceAwareToolCatalog, createSurfaceAwareToolCatalog);
|
|
84
|
+
});
|
|
@@ -4,7 +4,9 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { EventEmitter } from "node:events";
|
|
5
5
|
import { createSchema } from "json-rest-schema";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { ActionProvider } from "@jskit-ai/kernel/server/actions";
|
|
8
|
+
import { EventProvider } from "@jskit-ai/kernel/server/runtime";
|
|
9
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
8
10
|
import { attachDirectRequestActionExecutor } from "@jskit-ai/kernel/server/http";
|
|
9
11
|
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
10
12
|
import { AssistantFeature } from "../src/server/AssistantProvider.js";
|
|
@@ -36,8 +38,9 @@ test("AssistantFeature composes chat, transcripts, settings, routes, and action
|
|
|
36
38
|
}
|
|
37
39
|
};
|
|
38
40
|
const runtime = createCapabilityRuntime({
|
|
41
|
+
inputs: { "runtime.logger": silentLogger },
|
|
39
42
|
providers: [
|
|
40
|
-
|
|
43
|
+
EventProvider, ActionProvider,
|
|
41
44
|
valueProvider("test.config", "runtime.config", config),
|
|
42
45
|
valueProvider("test.database", "runtime.database", { knex: Object.assign(() => {}, {}) }),
|
|
43
46
|
valueProvider("test.env", "runtime.env", {}),
|
|
@@ -100,7 +103,8 @@ test("AssistantFeature uses a supplied file runtime without loading database or
|
|
|
100
103
|
}
|
|
101
104
|
const { AssistantFeature } = await import("@jskit-ai/assistant-runtime/server");
|
|
102
105
|
const { createConversationRuntime, createFileConversationStorage } = await import("@jskit-ai/assistant-core/server/conversation");
|
|
103
|
-
const {
|
|
106
|
+
const { ActionProvider } = await import("@jskit-ai/kernel/server/actions");
|
|
107
|
+
const { silentLogger } = await import("@jskit-ai/kernel/server/runtime/logger");
|
|
104
108
|
const { createCapabilityRuntime, defineProvider } = await import("@jskit-ai/kernel/shared/capabilities");
|
|
105
109
|
const directory = await mkdtemp(join(tmpdir(), "assistant-feature-"));
|
|
106
110
|
const runtime = createConversationRuntime({ storage: createFileConversationStorage({ directory }),
|
|
@@ -118,7 +122,7 @@ test("AssistantFeature uses a supplied file runtime without loading database or
|
|
|
118
122
|
for (const unrelatedDatabase of [false, true]) {
|
|
119
123
|
const routes = [];
|
|
120
124
|
let assistant, actions;
|
|
121
|
-
const host = createCapabilityRuntime({ providers: [
|
|
125
|
+
const host = createCapabilityRuntime({ inputs: { "runtime.logger": silentLogger }, providers: [ActionProvider,
|
|
122
126
|
valueProvider("runtime.config", config), valueProvider("runtime.env", {}),
|
|
123
127
|
valueProvider("runtime.http", { router: { register(...route) { routes.push(route); } } }),
|
|
124
128
|
valueProvider("assistant.conversations", runtime),
|
|
@@ -207,7 +211,7 @@ test("one AssistantFeature composes host access and declared data while settings
|
|
|
207
211
|
requestPolicy: "host"
|
|
208
212
|
};
|
|
209
213
|
const host = createCapabilityRuntime({
|
|
210
|
-
providers: [
|
|
214
|
+
providers: [ActionProvider, AssistantFeature, defineProvider({
|
|
211
215
|
id: "test.host.observer", requires: { catalogue: "runtime.actions", assistant: "assistant.runtime" },
|
|
212
216
|
setup({ catalogue, assistant }) {
|
|
213
217
|
actions = catalogue;
|
|
@@ -216,6 +220,7 @@ test("one AssistantFeature composes host access and declared data while settings
|
|
|
216
220
|
}
|
|
217
221
|
})],
|
|
218
222
|
inputs: {
|
|
223
|
+
"runtime.logger": silentLogger,
|
|
219
224
|
"runtime.config": config, "runtime.env": {}, "runtime.http": { router },
|
|
220
225
|
"assistant.conversations": conversationRuntime, "assistant.conversation.access": access,
|
|
221
226
|
"assistant.settings": {}, "runtime.events": { async publish() {} },
|
|
@@ -302,7 +307,8 @@ test("AssistantFeature rejects incomplete host access before registering routes
|
|
|
302
307
|
[null, validAccess]
|
|
303
308
|
]) {
|
|
304
309
|
let registrations = 0;
|
|
305
|
-
const host = createCapabilityRuntime({ providers: [
|
|
310
|
+
const host = createCapabilityRuntime({ providers: [EventProvider, ActionProvider, AssistantFeature], inputs: {
|
|
311
|
+
"runtime.logger": silentLogger,
|
|
306
312
|
"runtime.config": {}, "runtime.env": {},
|
|
307
313
|
"runtime.http": { router: { register() { registrations++; } } },
|
|
308
314
|
"assistant.conversation.access": access,
|
|
@@ -311,8 +311,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
|
|
|
311
311
|
},
|
|
312
312
|
body: {
|
|
313
313
|
messageId: "msg_1",
|
|
314
|
-
input: "hello"
|
|
315
|
-
history: []
|
|
314
|
+
input: "hello"
|
|
316
315
|
}
|
|
317
316
|
},
|
|
318
317
|
executeAction: async ({ input }) => {
|
|
@@ -333,8 +332,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
|
|
|
333
332
|
targetSurfaceId: "admin",
|
|
334
333
|
workspaceSlug: "dogandgroom",
|
|
335
334
|
messageId: "msg_1",
|
|
336
|
-
input: "hello"
|
|
337
|
-
history: []
|
|
335
|
+
input: "hello"
|
|
338
336
|
});
|
|
339
337
|
|
|
340
338
|
// Coded conversation failures keep their per-sentence messageCode, like the kernel payload.
|
|
@@ -351,7 +349,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
|
|
|
351
349
|
await route.handler({
|
|
352
350
|
raw: { on() {}, off() {} },
|
|
353
351
|
headers: { "x-jskit-surface": "admin" },
|
|
354
|
-
input: { params: { workspaceSlug: "dogandgroom", surfaceId: "admin" }, body: { messageId: "msg_2", input: "hello"
|
|
352
|
+
input: { params: { workspaceSlug: "dogandgroom", surfaceId: "admin" }, body: { messageId: "msg_2", input: "hello" } },
|
|
355
353
|
executeAction: async () => { throw failure; }
|
|
356
354
|
}, reply);
|
|
357
355
|
assert.equal(reply.statusCode, status);
|
|
@@ -23,7 +23,13 @@ test("assistant-runtime registers providers without install-time authoring machi
|
|
|
23
23
|
assert.deepEqual((await readdir(path.join(PACKAGE_ROOT, "migrations"))).sort(), [
|
|
24
24
|
"assistant_config_initial.cjs",
|
|
25
25
|
"assistant_transcripts_initial.cjs",
|
|
26
|
-
"
|
|
26
|
+
"assistant_transcripts_summary.cjs",
|
|
27
|
+
"assistant_turn_requests.cjs",
|
|
28
|
+
"constraints"
|
|
29
|
+
]);
|
|
30
|
+
assert.deepEqual((await readdir(path.join(PACKAGE_ROOT, "migrations", "constraints"))).sort(), [
|
|
31
|
+
"assistant_users_foreign_keys.cjs",
|
|
32
|
+
"assistant_workspaces_foreign_keys.cjs"
|
|
27
33
|
]);
|
|
28
34
|
|
|
29
35
|
const publicConfig = await readFile(
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import knex from "knex";
|
|
4
|
+
import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
|
|
5
|
+
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
6
|
+
import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
|
|
7
|
+
import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
|
|
8
|
+
import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
|
|
9
|
+
import { createSqlConversationStorage } from "../src/server/sqlConversationStorage.js";
|
|
10
|
+
|
|
11
|
+
test("the SQL conversation storage keeps its contract, cache and summary on MySQL", {
|
|
12
|
+
skip: !process.env.TEST_DB_NAME || !process.env.DB_HOST
|
|
13
|
+
}, async () => {
|
|
14
|
+
const environment = process.env;
|
|
15
|
+
const databaseName = `${environment.TEST_DB_NAME}_assistant_transcripts`;
|
|
16
|
+
const connection = { host: environment.DB_HOST, port: Number(environment.DB_PORT), user: environment.DB_USER,
|
|
17
|
+
password: environment.DB_PASSWORD };
|
|
18
|
+
const admin = knex({ client: "mysql2", connection });
|
|
19
|
+
const clients = [];
|
|
20
|
+
const connect = () => {
|
|
21
|
+
const db = knex({ client: "mysql2", connection: { ...connection, database: databaseName } });
|
|
22
|
+
clients.push(db);
|
|
23
|
+
const repositories = { conversationsRepository: createConversationsRepository(db), messagesRepository: createMessagesRepository(db) };
|
|
24
|
+
return { db, ...repositories, storage: createSqlConversationStorage(repositories) };
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [databaseName]);
|
|
28
|
+
await admin.raw("CREATE DATABASE ??", [databaseName]);
|
|
29
|
+
const f = connect();
|
|
30
|
+
await transcriptsMigration.up(f.db);
|
|
31
|
+
await summaryMigration.up(f.db);
|
|
32
|
+
await summaryMigration.up(f.db);
|
|
33
|
+
const conversation = actor => f.conversationsRepository.create({ createdByUserId: actor, surfaceId: "assistant", metadata: {} });
|
|
34
|
+
|
|
35
|
+
const first = await conversation("1");
|
|
36
|
+
const other = await conversation("2");
|
|
37
|
+
const scopes = new Map([["actor-a:workspace-a:conversation-a", first.id], ["actor-b:workspace-a:conversation-a", other.id]]);
|
|
38
|
+
await verifyConversationStorageContract({
|
|
39
|
+
read: (scope, callback) => f.storage.read(scopes.get(scope), callback),
|
|
40
|
+
write: (scope, callback) => f.storage.write(scopes.get(scope), callback)
|
|
41
|
+
}, { runtime: true });
|
|
42
|
+
|
|
43
|
+
const long = await conversation("1");
|
|
44
|
+
await f.storage.write(long.id, async transaction => {
|
|
45
|
+
for (let index = 0; index < 100; index += 1) {
|
|
46
|
+
const turnId = await transaction.nextTurnId();
|
|
47
|
+
const at = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString();
|
|
48
|
+
await transaction.appendMessage(turnId, { role: "user", messageId: `question-${index}`, text: `Question ${index}`, at });
|
|
49
|
+
await transaction.appendMessage(turnId, { role: "assistant", messageId: `answer-${index}`, text: `Answer ${index}`, at });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const servers = [connect(), connect()];
|
|
53
|
+
for (const server of servers) await server.storage.read(long.id, transaction => transaction.listTurnIds());
|
|
54
|
+
const queries = [];
|
|
55
|
+
const listener = query => queries.push(query.sql);
|
|
56
|
+
servers[0].db.on("query", listener);
|
|
57
|
+
const appended = await Promise.all(servers.map((server, index) => server.storage.write(long.id, async transaction => {
|
|
58
|
+
const turnId = await transaction.nextTurnId();
|
|
59
|
+
await transaction.appendMessage(turnId, { role: "user", messageId: `concurrent-${index}`, text: `Concurrent ${index}`,
|
|
60
|
+
at: "2026-02-01T00:00:00.000Z" });
|
|
61
|
+
return turnId;
|
|
62
|
+
})));
|
|
63
|
+
servers[0].db.removeListener("query", listener);
|
|
64
|
+
assert.deepEqual(appended.sort(), ["000101", "000102"], "Concurrent servers allocate distinct turns under the conversation lock");
|
|
65
|
+
const wholeReads = queries.filter(sql => /from `assistant_messages`/u.test(sql) && /order by/u.test(sql));
|
|
66
|
+
assert.ok(wholeReads.length <= 1, "Only a server that lost the race to the other's save reads the conversation again");
|
|
67
|
+
for (const server of servers) {
|
|
68
|
+
assert.deepEqual((await server.storage.read(long.id, transaction => transaction.listTurnIds())).slice(-2), ["000101", "000102"]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const text = "A long summary. ".repeat(8_000);
|
|
72
|
+
await servers[1].storage.write(long.id, transaction => transaction.writeSummary({ text, throughTurnId: "000100" }));
|
|
73
|
+
const summarised = await servers[0].storage.read(long.id, async transaction => ({
|
|
74
|
+
summary: await transaction.readSummary(), after: (await transaction.readTurnsAfter("000100")).map(turn => turn.turnId)
|
|
75
|
+
}));
|
|
76
|
+
assert.deepEqual(summarised, { summary: { text, throughTurnId: "000100" }, after: ["000101", "000102"] });
|
|
77
|
+
|
|
78
|
+
await summaryMigration.down(f.db);
|
|
79
|
+
for (const column of ["summary_text", "summary_through_turn_id", "transcript_revision"]) {
|
|
80
|
+
assert.equal(await f.db.schema.hasColumn("assistant_conversations", column), false);
|
|
81
|
+
}
|
|
82
|
+
await summaryMigration.up(f.db);
|
|
83
|
+
assert.equal(await f.db.schema.hasColumn("assistant_conversations", "transcript_revision"), true);
|
|
84
|
+
} finally {
|
|
85
|
+
for (const client of clients) await client.destroy();
|
|
86
|
+
await admin.raw("DROP DATABASE IF EXISTS ??", [databaseName]);
|
|
87
|
+
await admin.destroy();
|
|
88
|
+
}
|
|
89
|
+
});
|