@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.
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
@@ -8,6 +8,7 @@ import { AppError } from "@jskit-ai/kernel/server/runtime";
8
8
  import { createConversationTranscript } from "@jskit-ai/assistant-core/server/conversation";
9
9
  import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
10
10
  import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
11
+ import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
11
12
  import claimsMigration from "../migrations/assistant_turn_requests.cjs";
12
13
  import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
13
14
  import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
@@ -16,6 +17,7 @@ import { createSqlConversationStorage } from "../src/server/sqlConversationStora
16
17
  import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
17
18
  import { createTranscriptService } from "../src/server/services/transcriptService.js";
18
19
  import { createChatService } from "../src/server/services/chatService.js";
20
+ import { createScriptedModel } from "./support/scriptedModel.js";
19
21
 
20
22
  async function fixture(t) {
21
23
  const directory = await mkdtemp(path.join(tmpdir(), "assistant-sql-runtime-"));
@@ -35,6 +37,7 @@ async function fixture(t) {
35
37
  await first.db.schema.createTable("users", table => table.bigInteger("id").primary());
36
38
  await first.db("users").insert([{ id: 1 }, { id: 2 }]);
37
39
  await transcriptsMigration.up(first.db);
40
+ await summaryMigration.up(first.db);
38
41
  await claimsMigration.up(first.db);
39
42
  await first.db.raw("PRAGMA journal_mode = WAL");
40
43
  const conversation = actor => first.conversationsRepository.create({ createdByUserId: actor || "1", surfaceId: "assistant", metadata: {} });
@@ -139,7 +142,7 @@ test("historical SQL projection preserves exact rows and blocks ambiguous histor
139
142
  assert.deepEqual((await f.storage.preflight(interrupted.id)).issues, [{ code: "unconfirmed_historical_tool", rowIds: [call.id] }]);
140
143
  });
141
144
 
142
- test("the SQL facade uses common inference and durable tool rows while supplied history stays request context", async t => {
145
+ test("the SQL facade uses common inference and durable tool rows, and the model's history is the conversation's saved rows", async t => {
143
146
  const f = await fixture(t);
144
147
  const conversation = await f.conversation();
145
148
  await f.messagesRepository.create({ conversationId: conversation.id, role: "user", clientMessageSid: "previous", contentText: "Canonical history outside the selected context" });
@@ -149,14 +152,14 @@ test("the SQL facade uses common inference and durable tool rows while supplied
149
152
  let executions = 0;
150
153
  const aiClientFactory = { resolveClient(surface, input) {
151
154
  resolved.push({ surface, integrationId: input.integrationId, actor: input.context.actor.id });
152
- return { enabled: true, provider: "test", defaultModel: "fixture-model", async createChatCompletionStream(request) {
155
+ return { enabled: true, provider: "test", defaultModel: "fixture-model", model: createScriptedModel(request => {
153
156
  requests.push(structuredClone(request.messages));
154
157
  const tool = requests.length === 1;
155
158
  return (async function* () {
156
159
  yield { choices: [{ delta: tool ? { tool_calls: [{ index: 0, id: "lookup", function: { name: "inventory_lookup", arguments: "{}" } }] }
157
160
  : { content: "Stored once." }, finish_reason: tool ? "tool_calls" : "stop" }] };
158
161
  })();
159
- } };
162
+ }, { model: "fixture-model" }) };
160
163
  } };
161
164
  const catalog = { limits: { maxToolArgumentBytes: 4096 },
162
165
  resolveToolSet: () => ({ tools: [{ name: "inventory_lookup", parameters: { type: "object" } }] }),
@@ -179,7 +182,7 @@ test("the SQL facade uses common inference and durable tool rows while supplied
179
182
  assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
180
183
  const service = createChatService(dependencies);
181
184
  const input = { targetSurfaceId: "assistant", conversationId: conversation.id, messageId: "order/42:review", input: "Read the record",
182
- integrationId: "allowed-choice", history: [{ role: "user", content: "Selected request context only" }] };
185
+ integrationId: "allowed-choice" };
183
186
  const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
184
187
  ["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
185
188
  .map(name => [name, event => events.push({ name, event })])) };
@@ -187,26 +190,24 @@ test("the SQL facade uses common inference and durable tool rows while supplied
187
190
  assert.equal(executions, 1);
188
191
  assert.equal(requests.length, 2);
189
192
  assert.equal(resolved.every(value => value.surface === "assistant" && value.integrationId === "allowed-choice" && value.actor === "1"), true);
190
- assert.equal(requests[0].some(message => message.content === "Selected request context only"), true);
191
- assert.equal(requests[0].some(message => message.content === "Canonical history outside the selected context"), false);
193
+ assert.deepEqual(requests[0].slice(1, 3), [{ role: "user", content: "Canonical history outside the selected context" },
194
+ { role: "assistant", content: "Previous saved answer" }], "The model reads the conversation's saved history, not the browser's");
192
195
  assert.deepEqual(JSON.parse(requests[1].find(message => message.role === "tool").content), { found: 1 },
193
196
  "The model receives the original action result body, while its durable receipt keeps the full envelope");
194
197
  const saved = await rows(f.db);
195
198
  assert.equal(saved.messages.filter(row => row.kind === "tool_call").length, 1);
196
199
  assert.equal(saved.messages.filter(row => row.kind === "tool_result").length, 1);
197
200
  assert.equal(saved.messages.filter(row => row.client_message_sid === input.messageId).length, 1);
198
- assert.equal(saved.messages.some(row => row.content_text === "Selected request context only"), false);
199
- assert.doesNotMatch(saved.conversations[0].metadata_json, /Selected request context only/);
200
201
  assert.doesNotMatch(saved.messages.find(row => row.client_message_sid === input.messageId).metadata_json, /applicationTools/);
201
202
  const claimed = await f.db("assistant_turn_requests").first();
202
203
  assert.equal(claimed.status, "completed");
203
- assert.equal(JSON.parse(claimed.request_json).history[0].content, "Selected request context only");
204
+ assert.equal(Object.hasOwn(JSON.parse(claimed.request_json), "history"), false);
204
205
  assert.equal(events.at(-1).name, "sendDone");
205
206
  assert.equal((await service.streamChat(input, options)).status, "completed");
206
207
  assert.equal(requests.length, 2, "The original SQL claim replays without inference or duplicate effects");
207
- for (const change of [{ actor: { id: "2" } }, { surface: "other" }, { assistantRequest: { conversation, workspace: { id: "77" }, history: [] } }]) {
208
+ for (const change of [{ actor: { id: "2" } }, { surface: "other" }, { assistantRequest: { conversation, workspace: { id: "77" } } }]) {
208
209
  await assert.rejects(runtime.open({ id: conversation.id,
209
- context: { actor: { id: "1" }, surface: "assistant", assistantRequest: { conversation, workspace: null, history: [] }, ...change } }),
210
+ context: { actor: { id: "1" }, surface: "assistant", assistantRequest: { conversation, workspace: null }, ...change } }),
210
211
  { code: "conversation_forbidden" });
211
212
  }
212
213
  });
@@ -223,7 +224,8 @@ test("the SQL facade replays historical inspection failures with their exact pub
223
224
  let inference = 0;
224
225
  let effects = 0;
225
226
  const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
226
- createChatCompletionStream() { inference++; assert.fail("Historical inspection cannot invoke inference"); } }) };
227
+ model: createScriptedModel(() => { inference++; assert.fail("Historical inspection cannot invoke inference"); },
228
+ { once: () => { inference++; assert.fail("Historical inspection cannot invoke inference"); }, model: "test" }) }) };
227
229
  const catalog = { resolveToolSet: () => ({ tools: [] }),
228
230
  executeToolCall() { effects++; assert.fail("Historical inspection cannot invoke application effects"); } };
229
231
  const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
@@ -235,7 +237,7 @@ test("the SQL facade replays historical inspection failures with their exact pub
235
237
  appConfig: { surfaceDefinitions: { assistant: { enabled: true, requiresWorkspace: false } },
236
238
  assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
237
239
  const input = { targetSurfaceId: "assistant", conversationId: conversation.id,
238
- messageId: "inspect-legacy-history", input: "Continue", history: [] };
240
+ messageId: "inspect-legacy-history", input: "Continue" };
239
241
  const events = [];
240
242
  const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
241
243
  ["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
@@ -266,7 +268,7 @@ test("the SQL facade replays historical inspection failures with their exact pub
266
268
  const oldFailure = { message: "Previously recorded failure", status: 409 };
267
269
  const oldInput = { ...input, messageId: "old-failure-receipt" };
268
270
  const oldClaim = await turnRequests.claim({ actorUserId: "1", surfaceId: "assistant", workspaceId: null },
269
- { conversationId: conversation.id, messageId: oldInput.messageId, input: oldInput.input, history: [], integrationId: "" });
271
+ { conversationId: conversation.id, messageId: oldInput.messageId, input: oldInput.input, integrationId: "" });
270
272
  await turnRequests.update(oldClaim, { failure: oldFailure }, "failed");
271
273
  await assert.rejects(restarted.streamChat(oldInput, options), error => {
272
274
  assert.equal(error.status, oldFailure.status);
@@ -326,13 +328,13 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
326
328
  const requests = [];
327
329
  let effects = 0;
328
330
  const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "fixture-model",
329
- async createChatCompletionStream(request) {
331
+ model: createScriptedModel(request => {
330
332
  assert.equal(allowFreshInference, true, "Historical refusal and foreign admission must not start inference");
331
333
  requests.push(structuredClone(request.messages));
332
334
  return (async function* () {
333
335
  yield { choices: [{ delta: { content: "Fresh answer." }, finish_reason: "stop" }] };
334
336
  })();
335
- } }) };
337
+ }, { model: "fixture-model" }) }) };
336
338
  const catalog = { limits: { maxToolArgumentBytes: 4096 }, resolveToolSet: () => ({ tools: [] }),
337
339
  toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
338
340
  executeToolCall() { effects++; assert.fail("Retained history cannot repeat an application effect"); } };
@@ -428,3 +430,146 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
428
430
  assert.deepEqual((await restarted.getConversationMessages(conversation.id, {}, options)).entries, historical);
429
431
  });
430
432
  }
433
+
434
+ function recordQueries(db) {
435
+ const queries = [];
436
+ const listener = query => queries.push(query.sql);
437
+ db.on("query", listener);
438
+ return { queries, stop: () => db.removeListener("query", listener) };
439
+ }
440
+
441
+ const readsWholeConversation = sql => /from [`"]assistant_messages[`"]/u.test(sql) && /order by/u.test(sql);
442
+
443
+ async function writeTurns(storage, conversationId, count, prefix = "turn") {
444
+ await storage.write(conversationId, async transaction => {
445
+ for (let index = 0; index < count; index += 1) {
446
+ const turnId = await transaction.nextTurnId();
447
+ const at = new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString();
448
+ await transaction.appendMessage(turnId, { role: "user", messageId: `${prefix}-${index}`, text: `Question ${index}`, at,
449
+ turnMetadata: { runtime: { status: "complete" } } });
450
+ await transaction.appendMessage(turnId, { role: "assistant", messageId: `${prefix}-${index}:reply-1`, text: `Answer ${index}`, at });
451
+ }
452
+ });
453
+ }
454
+
455
+ test("a long conversation's rows are read once, then each write appends its rows without reading the conversation again", async t => {
456
+ const f = await fixture(t);
457
+ const conversation = await f.conversation();
458
+ await writeTurns(f.storage, conversation.id, 300);
459
+ const cold = f.connect();
460
+ let recorded = recordQueries(cold.db);
461
+ assert.equal((await cold.storage.read(conversation.id, transaction => transaction.listTurnIds())).length, 300);
462
+ recorded.stop();
463
+ assert.equal(recorded.queries.filter(readsWholeConversation).length, 2, "A cold server reads the 600 rows once, in pages of 500");
464
+
465
+ const measure = async operation => {
466
+ recorded = recordQueries(cold.db);
467
+ const result = await operation();
468
+ recorded.stop();
469
+ return { result, queries: recorded.queries };
470
+ };
471
+ const appendTurn = messageId => cold.storage.write(conversation.id, async transaction => {
472
+ const turnId = await transaction.nextTurnId();
473
+ await transaction.appendMessage(turnId, { role: "user", messageId, text: "One more", at: "2026-02-01T00:00:00.000Z" });
474
+ await transaction.updateTurnMetadata(turnId, { runtime: { status: "running" } });
475
+ return turnId;
476
+ });
477
+ const write = await measure(() => appendTurn("after-300"));
478
+ assert.equal(write.result, "000301");
479
+ assert.deepEqual(write.queries.filter(readsWholeConversation), []);
480
+ assert.ok(write.queries.length <= 12, `A write issues a constant number of queries, not one per stored row (${write.queries.length})`);
481
+ const read = await measure(() => cold.storage.read(conversation.id, transaction => transaction.readTurn("000301")));
482
+ assert.equal(read.result.user.text, "One more");
483
+ assert.deepEqual(read.result.metadata, { runtime: { status: "running" } });
484
+ assert.deepEqual(read.queries.filter(readsWholeConversation), []);
485
+ assert.ok(read.queries.length <= 5, `A read issues a constant number of queries (${read.queries.length})`);
486
+
487
+ await writeTurns(cold.storage, conversation.id, 50, "later");
488
+ const longer = await measure(() => appendTurn("after-350"));
489
+ assert.equal(longer.result, "000352");
490
+ assert.deepEqual(longer.queries, write.queries, "The same write issues the same queries however long the conversation is");
491
+ const answer = await measure(() => cold.storage.write(conversation.id, transaction =>
492
+ transaction.replaceAssistant("000301", { role: "assistant", messageId: "after-300:reply-1", text: "Answered", at: "2026-02-01T00:00:01.000Z" })));
493
+ assert.deepEqual(answer.queries.filter(readsWholeConversation), []);
494
+
495
+ const fresh = f.connect();
496
+ const expected = await fresh.storage.read(conversation.id, async transaction =>
497
+ Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id))));
498
+ const cached = await cold.storage.read(conversation.id, async transaction =>
499
+ Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id))));
500
+ assert.equal(expected.length, 352);
501
+ assert.deepEqual(cached, expected, "Cached rows project exactly as a fresh read of the database");
502
+ });
503
+
504
+ test("a server sees another server's saves, direct row inserts and conversation updates at once", async t => {
505
+ const f = await fixture(t);
506
+ const conversation = await f.conversation();
507
+ const other = f.connect();
508
+ await writeTurns(f.storage, conversation.id, 2);
509
+ assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002"]);
510
+ await writeTurns(other.storage, conversation.id, 1, "other");
511
+ assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002", "000003"]);
512
+ await other.storage.write(conversation.id, transaction => transaction.updateTurnMetadata("000001", { product: { edited: true } }));
513
+ assert.deepEqual((await f.storage.read(conversation.id, transaction => transaction.readTurn("000001"))).metadata,
514
+ { runtime: { status: "complete" }, product: { edited: true } });
515
+ assert.equal(await f.storage.write(conversation.id, transaction => transaction.nextTurnId()), "000004");
516
+
517
+ await f.messagesRepository.create({ conversationId: conversation.id, actorUserId: "1", role: "user", kind: "chat",
518
+ clientMessageSid: "imported", contentText: "Imported question",
519
+ metadata: { surfaceId: "assistant", conversationRuntime: { turnId: "000009" } } });
520
+ assert.equal((await f.storage.read(conversation.id, transaction => transaction.readTurn("000009"))).user.text, "Imported question");
521
+
522
+ await f.conversationsRepository.updateById(conversation.id, { metadata: { runtime: { external: true } } });
523
+ assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.readMetadata()), { runtime: { external: true } });
524
+ });
525
+
526
+ test("a save that fails to commit leaves the cached rows on what the database holds", async t => {
527
+ const f = await fixture(t);
528
+ const conversation = await f.conversation();
529
+ let fail = false;
530
+ const storage = createSqlConversationStorage({ conversationsRepository: f.conversationsRepository,
531
+ messagesRepository: { ...f.messagesRepository, async create(input, options) {
532
+ const result = await f.messagesRepository.create(input, options);
533
+ if (fail) throw new Error("forced-row-commit-failure");
534
+ return result;
535
+ } } });
536
+ await writeTurns(storage, conversation.id, 2);
537
+ assert.equal((await storage.read(conversation.id, transaction => transaction.listTurnIds())).length, 2);
538
+ fail = true;
539
+ await assert.rejects(writeTurns(storage, conversation.id, 1, "failed"), /forced-row-commit-failure/);
540
+ fail = false;
541
+ assert.deepEqual(await storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002"]);
542
+ await writeTurns(storage, conversation.id, 1, "next");
543
+ const saved = await f.messagesRepository.listByConversationScope(conversation.id);
544
+ assert.deepEqual(saved.map(row => row.seq), [1, 2, 3, 4, 5, 6]);
545
+ assert.equal(saved.at(-2).clientMessageSid, "next-0");
546
+ assert.equal((await f.conversationsRepository.findById(conversation.id)).messageCount, 6);
547
+ });
548
+
549
+ test("the summary and the last turn it covers are saved on the conversation row, and a new server reads only the turns after it", async t => {
550
+ const f = await fixture(t);
551
+ const conversation = await f.conversation();
552
+ await writeTurns(f.storage, conversation.id, 4);
553
+ const before = await rows(f.db);
554
+ await f.storage.write(conversation.id, transaction => transaction.writeSummary({ text: "Turns one to three.", throughTurnId: "000003" }));
555
+ const saved = await f.db("assistant_conversations").where({ id: conversation.id }).first();
556
+ assert.equal(saved.summary_text, "Turns one to three.");
557
+ assert.equal(saved.summary_through_turn_id, "000003");
558
+ assert.notEqual(saved.transcript_revision, before.conversations[0].transcript_revision);
559
+ assert.deepEqual((await rows(f.db)).messages, before.messages, "Compaction never deletes or changes messages");
560
+ assert.doesNotMatch(saved.metadata_json || "", /Turns one to three/u);
561
+ assert.equal(Object.hasOwn(await f.conversationsRepository.findById(conversation.id), "summary"), false,
562
+ "Conversation records sent to the browser do not carry the summary");
563
+
564
+ const restarted = f.connect();
565
+ const later = await restarted.storage.read(conversation.id, async transaction => {
566
+ const summary = await transaction.readSummary();
567
+ return { summary, turns: await transaction.readTurnsAfter(summary.throughTurnId) };
568
+ });
569
+ assert.deepEqual(later.summary, { text: "Turns one to three.", throughTurnId: "000003" });
570
+ assert.deepEqual(later.turns.map(turn => [turn.turnId, turn.user.text, turn.assistant.text]), [["000004", "Question 3", "Answer 3"]]);
571
+
572
+ await restarted.storage.write(conversation.id, transaction => transaction.writeSummary({ text: "Turns one to four.", throughTurnId: "000004" }));
573
+ assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.readSummary()),
574
+ { text: "Turns one to four.", throughTurnId: "000004" }, "Another server's summary is read at once");
575
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
  export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "assistant", messages = [] } = {}) {
5
5
  let state = { conversation: { id, workspaceId: null, createdByUserId: "1", surfaceId,
6
- title: "New conversation", status: "active", messageCount: 0, metadata: {} }, messages: [] };
6
+ title: "New conversation", status: "active", messageCount: 0, metadata: {} }, transcript: { revision: "", summary: null }, messages: [] };
7
7
  let pending = Promise.resolve();
8
8
  const read = options => options?.trx || state;
9
9
  const conversationsRepository = {
@@ -18,6 +18,10 @@ export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "
18
18
  Object.assign(target.conversation, structuredClone(patch));
19
19
  return structuredClone(target.conversation);
20
20
  },
21
+ async readTranscriptState(value, options) { return value === id ? structuredClone(read(options).transcript) : null; },
22
+ async writeTranscriptState(value, transcript, options) {
23
+ if (value === id) read(options).transcript = structuredClone(transcript);
24
+ },
21
25
  transaction(operation) {
22
26
  const next = pending.then(async () => {
23
27
  const trx = structuredClone(state);
@@ -34,6 +38,7 @@ export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "
34
38
  async listByConversationScope(_id, _scope, { page, pageSize }, options) {
35
39
  return structuredClone(read(options).messages.slice((page - 1) * pageSize, page * pageSize));
36
40
  },
41
+ async lastSequence(_id, options) { return read(options).messages.length; },
37
42
  async create(payload, options) {
38
43
  const rows = read(options).messages;
39
44
  const row = { ...structuredClone(payload), id: String(rows.length + 1), seq: rows.length + 1,
@@ -0,0 +1,32 @@
1
+ import { createAiConnectionClient } from "@jskit-ai/assistant-core/server";
2
+
3
+ const encoder = new TextEncoder();
4
+
5
+ /** An AI SDK model on an OpenAI-compatible endpoint that answers from a script. A streamed
6
+ * request (a reply step) gets `stream(body)`: OpenAI stream chunks (an async iterable), passed on
7
+ * as they come. The engine's other calls (checks, accounts, summaries) get `once(body)` as one
8
+ * message; by default an empty one, which a check reads as nothing to report.
9
+ */
10
+ export function createScriptedModel(stream, { once = () => "", model = "test-model" } = {}) {
11
+ const fetch = async (_url, request) => {
12
+ const body = JSON.parse(request.body);
13
+ if (!body.stream) {
14
+ return Response.json({ id: "scripted", object: "chat.completion", created: 0, model,
15
+ choices: [{ index: 0, message: { role: "assistant", content: await once(body) }, finish_reason: "stop" }],
16
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } });
17
+ }
18
+ const chunks = (await stream(body))[Symbol.asyncIterator]();
19
+ const frame = chunk => ({ id: "scripted", object: "chat.completion.chunk", created: 0, model, ...chunk });
20
+ return new Response(new ReadableStream({
21
+ async pull(controller) {
22
+ const { value, done } = await chunks.next();
23
+ if (done) {
24
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
25
+ controller.close();
26
+ } else controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame(value))}\n\n`));
27
+ }
28
+ }), { headers: { "content-type": "text/event-stream" } });
29
+ };
30
+ return createAiConnectionClient({ providerId: "test", model, sdkPackage: "@ai-sdk/openai-compatible",
31
+ apiKey: "test", baseURL: "http://test.invalid/v1" }, { fetch }).model;
32
+ }
@@ -9,6 +9,7 @@ import { createRepository } from "../src/server/repositories/turnRequestsReposit
9
9
  import { createChatService } from "../src/server/services/chatService.js";
10
10
  import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
11
11
  import { createMemoryAssistantRows } from "./support/memoryAssistantRows.js";
12
+ import { createScriptedModel } from "./support/scriptedModel.js";
12
13
 
13
14
  async function database(t) {
14
15
  const dir = await mkdtemp(path.join(tmpdir(), "assistant-requests-"));
@@ -27,7 +28,7 @@ async function database(t) {
27
28
  }
28
29
 
29
30
  const scope = { actorUserId: "1", surfaceId: "assistant", workspaceId: null };
30
- const request = { messageId: "message-1", input: "Do this once.", history: [] };
31
+ const request = { messageId: "message-1", input: "Do this once." };
31
32
 
32
33
  test("migration preserves existing claims and database uniqueness arbitrates independent connections", async t => {
33
34
  const { db, connect } = await database(t);
@@ -49,7 +50,8 @@ test("migration preserves existing claims and database uniqueness arbitrates ind
49
50
  function service(repository, { provider, transcript = [] } = {}) {
50
51
  const dependencies = {
51
52
  turnRequests: repository,
52
- aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test", createChatCompletionStream: provider }) },
53
+ aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
54
+ model: provider && createScriptedModel(body => provider({ messages: body.messages }), { model: "test" }) }) },
53
55
  transcriptService: {
54
56
  async createConversationForTurn() { return { conversation: { id: "100" } }; },
55
57
  async completeConversation() {}
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knex from "knex";
4
+ import configMigration from "../migrations/assistant_config_initial.cjs";
5
+ import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
6
+ import turnRequestsMigration from "../migrations/assistant_turn_requests.cjs";
7
+ import usersForeignKeysMigration from "../migrations/constraints/assistant_users_foreign_keys.cjs";
8
+
9
+ const USER_KEYS = [
10
+ { table: "assistant_conversations", from: "created_by_user_id", on_delete: "SET NULL" },
11
+ { table: "assistant_messages", from: "actor_user_id", on_delete: "SET NULL" },
12
+ { table: "assistant_turn_requests", from: "actor_user_id", on_delete: "CASCADE" }
13
+ ];
14
+
15
+ async function usersKeys(db) {
16
+ const keys = [];
17
+ for (const { table } of USER_KEYS) {
18
+ for (const key of await db.raw("PRAGMA foreign_key_list(??)", [table])) {
19
+ if (key.table === "users") keys.push({ table, from: key.from, on_delete: key.on_delete });
20
+ }
21
+ }
22
+ return keys;
23
+ }
24
+
25
+ test("the assistant tables are created before users exists and get their users keys in the constraints phase", async (t) => {
26
+ const db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
27
+ t.after(() => db.destroy());
28
+
29
+ // Package table migrations run before the application's migrations, which may create users.
30
+ await configMigration.up(db);
31
+ await transcriptsMigration.up(db);
32
+ await turnRequestsMigration.up(db);
33
+ assert.deepEqual(await usersKeys(db), []);
34
+
35
+ await assert.rejects(usersForeignKeysMigration.up(db), /the users table does not exist/u);
36
+ await db.schema.createTable("users", (table) => table.bigInteger("id").primary());
37
+ await usersForeignKeysMigration.up(db);
38
+ assert.deepEqual(await usersKeys(db), USER_KEYS);
39
+
40
+ // Tables that already have their users keys, as databases migrated before this phase do, gain no duplicate.
41
+ await usersForeignKeysMigration.up(db);
42
+ assert.deepEqual(await usersKeys(db), USER_KEYS);
43
+
44
+ await usersForeignKeysMigration.down(db);
45
+ assert.deepEqual(await usersKeys(db), []);
46
+ });
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import knex from "knex";
4
+ import configMigration from "../migrations/assistant_config_initial.cjs";
5
+ import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
6
+ import workspacesForeignKeysMigration from "../migrations/constraints/assistant_workspaces_foreign_keys.cjs";
7
+
8
+ const TABLES = ["assistant_config", "assistant_conversations", "assistant_messages"];
9
+
10
+ async function workspaceKeys(db) {
11
+ const keys = [];
12
+ for (const table of TABLES) {
13
+ for (const key of await db.raw("PRAGMA foreign_key_list(??)", [table])) {
14
+ if (key.table === "workspaces") keys.push({ table, from: key.from, on_delete: key.on_delete });
15
+ }
16
+ }
17
+ return keys;
18
+ }
19
+
20
+ async function freshTables(t) {
21
+ const db = knex({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
22
+ t.after(() => db.destroy());
23
+ // Package table migrations run before workspaces-core's, so no workspaces table exists yet.
24
+ await configMigration.up(db);
25
+ await transcriptsMigration.up(db);
26
+ assert.deepEqual(await workspaceKeys(db), []);
27
+ return db;
28
+ }
29
+
30
+ test("the assistant tables get their workspaces keys in the constraints phase, once", async (t) => {
31
+ const db = await freshTables(t);
32
+ await db.schema.createTable("workspaces", (table) => table.bigInteger("id").primary());
33
+ await workspacesForeignKeysMigration.up(db);
34
+ const keys = TABLES.map((table) => ({ table, from: "workspace_id", on_delete: "CASCADE" }));
35
+ assert.deepEqual(await workspaceKeys(db), keys);
36
+
37
+ // Tables that already have their workspaces keys, as databases migrated before this phase do, are unchanged.
38
+ await workspacesForeignKeysMigration.up(db);
39
+ assert.deepEqual(await workspaceKeys(db), keys);
40
+
41
+ await workspacesForeignKeysMigration.down(db);
42
+ assert.deepEqual(await workspaceKeys(db), []);
43
+ });
44
+
45
+ test("an application without a workspaces table gets no workspaces keys", async (t) => {
46
+ const db = await freshTables(t);
47
+ await workspacesForeignKeysMigration.up(db);
48
+ assert.deepEqual(await workspaceKeys(db), []);
49
+ });
50
+
51
+ test("rows whose workspace is gone stop the migration with their table, column and count, and stay", async (t) => {
52
+ const db = await freshTables(t);
53
+ await db.schema.createTable("workspaces", (table) => table.bigInteger("id").primary());
54
+ await db("workspaces").insert({ id: 1 });
55
+ await db("assistant_config").insert([
56
+ { target_surface_id: "app", scope_key: "workspace:1", workspace_id: 1 },
57
+ { target_surface_id: "app", scope_key: "workspace:7", workspace_id: 7 },
58
+ { target_surface_id: "app", scope_key: "workspace:8", workspace_id: 8 }
59
+ ]);
60
+ await assert.rejects(
61
+ workspacesForeignKeysMigration.up(db),
62
+ /assistant_config\.workspace_id foreign key to workspaces\.id: 2 assistant_config row\(s\) have a workspace_id with no workspaces row/u
63
+ );
64
+ assert.equal((await db("assistant_config")).length, 3);
65
+ assert.deepEqual(await workspaceKeys(db), []);
66
+ });
@@ -1,6 +0,0 @@
1
- /** Whether a conversation failure means the viewer lost access to it. */
2
- function isAccessDenied(error) {
3
- return [401, 403].includes(Number(error.status || error.statusCode));
4
- }
5
-
6
- export { isAccessDenied };