@jskit-ai/assistant-runtime 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/conversation/main.js +2 -1
  4. package/fixtures/conversation/vite.config.mjs +4 -3
  5. package/migrations/assistant_config_initial.cjs +1 -5
  6. package/migrations/assistant_transcripts_initial.cjs +3 -9
  7. package/migrations/assistant_transcripts_summary.cjs +28 -0
  8. package/migrations/assistant_turn_requests.cjs +2 -1
  9. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  10. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  11. package/package.json +17 -10
  12. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  13. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  14. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  15. package/src/client/composables/conversationGoal.js +266 -0
  16. package/src/client/composables/conversationHistory.js +215 -0
  17. package/src/client/composables/conversationRuntime.js +624 -0
  18. package/src/client/composables/useAssistantConversation.js +208 -884
  19. package/src/client/composables/useAssistantRuntime.js +60 -45
  20. package/src/client/providers/AssistantClientProvider.js +1 -0
  21. package/src/client/support/assistantRuntimeState.js +6 -34
  22. package/src/client/support/conversationFailures.js +30 -0
  23. package/src/client/support/conversationRestoreSupport.js +13 -5
  24. package/src/client/support/subscribeAssistantConversation.js +32 -9
  25. package/src/client/support/workspaceScopeSupport.js +13 -22
  26. package/src/client/types.js +46 -0
  27. package/src/server/AssistantProvider.js +45 -17
  28. package/src/server/actions.js +162 -201
  29. package/src/server/conversationOperations.js +141 -0
  30. package/src/server/createAssistantRuntime.js +83 -44
  31. package/src/server/createAssistantSettingsActions.js +69 -47
  32. package/src/server/hostedAssistant.js +21 -0
  33. package/src/server/index.js +1 -0
  34. package/src/server/registerConversationSubscriptions.js +34 -11
  35. package/src/server/registerFastifyConversations.js +18 -6
  36. package/src/server/registerRoutes.js +5 -2
  37. package/src/server/registerSettingsRoutes.js +1 -1
  38. package/src/server/repositories/assistantConfigRepository.js +10 -20
  39. package/src/server/repositories/conversationsRepository.js +35 -10
  40. package/src/server/repositories/messagesRepository.js +14 -10
  41. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  42. package/src/server/repositories/turnRequestsRepository.js +6 -1
  43. package/src/server/services/assistantConfigService.js +25 -23
  44. package/src/server/services/chatService.js +57 -61
  45. package/src/server/services/transcriptService.js +15 -7
  46. package/src/server/sqlConversationStorage.js +90 -18
  47. package/src/server/support/assistantServerConfig.js +1 -1
  48. package/src/server/support/assistantStreamWriter.js +7 -1
  49. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  50. package/src/server/types.js +38 -0
  51. package/src/shared/assistantSurfaces.js +2 -2
  52. package/test/assistantConversationOperations.test.js +5 -5
  53. package/test/assistantRuntimeState.test.js +1 -16
  54. package/test/chatServiceLifecycle.test.js +62 -154
  55. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  56. package/test/featureRuntime.test.js +12 -6
  57. package/test/lazyAppConfig.test.js +3 -5
  58. package/test/packageMetadata.test.js +7 -1
  59. package/test/sqlConversationStorage.mysql.test.js +89 -0
  60. package/test/sqlConversationStorage.test.js +161 -16
  61. package/test/support/memoryAssistantRows.js +6 -1
  62. package/test/support/scriptedModel.js +32 -0
  63. package/test/turnRequests.test.js +4 -2
  64. package/test/usersForeignKeysMigration.test.js +46 -0
  65. package/test/workspacesForeignKeysMigration.test.js +66 -0
  66. 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
- async createChatCompletionStream(request) {
107
+ model: createScriptedModel(body => {
106
108
  completionRequests.push({
107
- messages: structuredClone(request.messages),
108
- tools: structuredClone(request.tools)
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.deepEqual(request.tools, []);
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("progress-only output is replaced on retry and current-time prompts require a workspace clock", async () => {
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.map((request) => request.toolName), ["workspace_clock"]);
302
- assert.equal(
303
- harness.completionRequests[0].messages.some((message) => (
304
- message.role === "tool" && /Australia\/Perth/u.test(message.content)
305
- )),
306
- true
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 assistantToolMessages = harness.completionRequests
393
- .flatMap((request) => request.messages)
394
- .filter((message) => Array.isArray(message.tool_calls));
395
- assert.ok(assistantToolMessages.length > 0);
396
- assert.equal(assistantToolMessages.every((message) => message.content === ""), true);
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("tool-loop exhaustion gives the concise new-conversation instruction", async () => {
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
- ...mainRounds,
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
- const finalMessages = assistantMessages(harness.streamEvents);
426
- assert.equal(harness.executedTools.length, 16);
427
- assert.equal(harness.completionRequests.length, 19);
428
- assert.deepEqual(finalMessages, ["Limit reached. Start a new conversation."]);
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("tool-failure recovery retains the bounded latest successful result", async () => {
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(request, sequence) {
445
- if (request.toolName === "action_search") {
446
- return {
447
- ok: false,
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
- const [fallback] = assistantMessages(harness.streamEvents);
468
- assert.equal(harness.executedTools.length, 16);
469
- assert.ok(fallback.length <= 4000);
470
- assert.match(fallback, /Latest successful result from action_execute/u);
471
- assert.match(fallback, /"sequence": 16/u);
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("split internal tags and tool arguments never become answer deltas", async () => {
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.map(request => request.toolName), ["action_search"]);
513
- assert.deepEqual(harness.streamEvents.filter(event => event.method === "sendAssistantDelta").map(event => event.payload.delta), ["The search is complete."]);
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"], history: [
530
- { role: "user", content: "Previous file", attachmentIds: ["file-one"] }
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 { createActionProvider } from "@jskit-ai/kernel/server/actions";
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
- createActionProvider(),
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 { createActionProvider } = await import("@jskit-ai/kernel/server/actions");
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: [createActionProvider(),
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: [createActionProvider(), AssistantFeature, defineProvider({
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: [createActionProvider(), AssistantFeature], inputs: {
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", history: [] } },
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
- "assistant_turn_requests.cjs"
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
+ });