@jskit-ai/assistant-runtime 0.2.29 → 0.2.30

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 (37) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +6 -3
  3. package/package.json +16 -10
  4. package/patterns/assistant-surface/PATTERN.md +5 -2
  5. package/src/client/components/AssistantSettingsClientElement.vue +5 -3
  6. package/src/server/AssistantProvider.js +8 -13
  7. package/src/server/AssistantStoreFeature.js +59 -0
  8. package/src/server/actions.js +5 -5
  9. package/src/server/createAssistantRuntime.js +2 -1
  10. package/src/server/createAssistantSettingsActions.js +17 -9
  11. package/src/server/index.js +1 -0
  12. package/src/server/registerConversationSubscriptions.js +5 -2
  13. package/src/server/registerRoutes.js +11 -8
  14. package/src/server/registerSettingsRoutes.js +4 -4
  15. package/src/server/repositories/assistantConfigRepository.js +52 -67
  16. package/src/server/repositories/assistantResources.js +110 -0
  17. package/src/server/repositories/conversationsRepository.js +164 -147
  18. package/src/server/repositories/messagesRepository.js +136 -87
  19. package/src/server/repositories/repositoryPersistenceUtils.js +11 -22
  20. package/src/server/repositories/turnRequestsRepository.js +50 -16
  21. package/src/server/services/assistantConfigService.js +22 -0
  22. package/src/server/services/chatService.js +15 -7
  23. package/src/server/services/transcriptService.js +3 -3
  24. package/src/server/sqlConversationStorage.js +23 -19
  25. package/src/server/types.js +1 -0
  26. package/test/assistantIntegrationBoundary.test.js +2 -0
  27. package/test/assistantSettings.browser.test.js +23 -2
  28. package/test/assistantSettingsAccess.test.js +46 -1
  29. package/test/chatServiceLifecycle.test.js +32 -1
  30. package/test/featureRuntime.test.js +17 -3
  31. package/test/repositories.mysql.test.js +474 -0
  32. package/test/repositoryContracts.test.js +13 -13
  33. package/test/sqlConversationStorage.mysql.test.js +9 -6
  34. package/test/sqlConversationStorage.test.js +27 -20
  35. package/test/support/assistantStore.js +39 -0
  36. package/test/support/memoryAssistantRows.js +11 -4
  37. package/test/turnRequests.test.js +37 -14
@@ -11,15 +11,13 @@ import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/test
11
11
  import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
12
12
  import summaryMigration from "../migrations/assistant_transcripts_summary.cjs";
13
13
  import claimsMigration from "../migrations/assistant_turn_requests.cjs";
14
- import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
15
- import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
16
- import { createRepository as createTurnRequestsRepository } from "../src/server/repositories/turnRequestsRepository.js";
17
14
  import { createSqlConversationStorage } from "../src/server/sqlConversationStorage.js";
18
15
  import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
19
16
  import { createTranscriptService } from "../src/server/services/transcriptService.js";
20
17
  import { createChatService } from "../src/server/services/chatService.js";
21
18
  import { NO_PERSON_CONTEXT, createPersonContextPrompt } from "../src/server/support/personContext.js";
22
19
  import { createScriptedModel } from "./support/scriptedModel.js";
20
+ import { createTestAssistantStore } from "./support/assistantStore.js";
23
21
 
24
22
  // The chat service of an application that supplies no facts about the person.
25
23
  const NO_FACTS = createPersonContextPrompt({ personContext: NO_PERSON_CONTEXT, logger: silentLogger });
@@ -27,18 +25,25 @@ const NO_FACTS = createPersonContextPrompt({ personContext: NO_PERSON_CONTEXT, l
27
25
  async function fixture(t) {
28
26
  const directory = await mkdtemp(path.join(tmpdir(), "assistant-sql-runtime-"));
29
27
  const clients = [];
30
- function connect() {
28
+ const stores = [];
29
+ async function connect() {
31
30
  const db = knex({ client: "better-sqlite3", connection: { filename: path.join(directory, "assistant.sqlite") },
32
31
  useNullAsDefault: true, pool: { min: 1, max: 1, afterCreate(connection, done) {
33
32
  connection.pragma("busy_timeout = 0");
34
33
  done(null, connection);
35
34
  } } });
36
35
  clients.push(db);
37
- const repositories = { conversationsRepository: createConversationsRepository(db), messagesRepository: createMessagesRepository(db) };
38
- return { db, ...repositories, storage: createSqlConversationStorage(repositories) };
36
+ const store = await createTestAssistantStore(db);
37
+ stores.push(store);
38
+ const repositories = { conversationsRepository: store.conversations, messagesRepository: store.messages };
39
+ return { db, turnRequests: store.turnRequests, ...repositories, storage: createSqlConversationStorage(repositories) };
39
40
  }
40
- t.after(async () => { for (const client of clients) await client.destroy(); await rm(directory, { recursive: true, force: true }); });
41
- const first = connect();
41
+ t.after(async () => {
42
+ for (const store of stores) await store.close();
43
+ for (const client of clients) await client.destroy();
44
+ await rm(directory, { recursive: true, force: true });
45
+ });
46
+ const first = await connect();
42
47
  await first.db.schema.createTable("users", table => table.bigInteger("id").primary());
43
48
  await first.db("users").insert([{ id: 1 }, { id: 2 }]);
44
49
  await transcriptsMigration.up(first.db);
@@ -100,7 +105,7 @@ test("the existing runtime storage contract uses the original SQL rows and rolls
100
105
  test("independent SQL connections serialize authored receipt and sequence allocation", async t => {
101
106
  const f = await fixture(t);
102
107
  const conversation = await f.conversation();
103
- const second = f.connect();
108
+ const second = await f.connect();
104
109
  const transcripts = [f.storage, second.storage].map(storage => createConversationTranscript({ storage }));
105
110
  const input = { messageId: "invoice/42:follow-up", text: "Only once" };
106
111
  const results = await Promise.all(transcripts.map(transcript => transcript.writeConversationUserMessage(conversation.id, input)));
@@ -181,7 +186,7 @@ test("the SQL facade uses common inference and durable tool rows, and the model'
181
186
  const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
182
187
  t.after(() => runtime.close());
183
188
  const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
184
- transcriptService: createTranscriptService(f), turnRequests: createTurnRequestsRepository(f.db),
189
+ transcriptService: createTranscriptService(f), turnRequests: f.turnRequests,
185
190
  assistantConfigService: { resolveSystemPrompt: async () => "Retained SQL instructions" },
186
191
  personContextPrompt: NO_FACTS,
187
192
  appConfig: { surfaceDefinitions: { assistant: { enabled: true, requiresWorkspace: false } },
@@ -236,7 +241,7 @@ test("the SQL facade replays historical inspection failures with their exact pub
236
241
  executeToolCall() { effects++; assert.fail("Historical inspection cannot invoke application effects"); } };
237
242
  const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
238
243
  t.after(() => runtime.close());
239
- const turnRequests = createTurnRequestsRepository(f.db);
244
+ const turnRequests = f.turnRequests;
240
245
  const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
241
246
  transcriptService: createTranscriptService(f), turnRequests,
242
247
  assistantConfigService: { resolveSystemPrompt: async () => "Read the supplied context" },
@@ -260,7 +265,7 @@ test("the SQL facade replays historical inspection failures with their exact pub
260
265
  await assert.rejects(createChatService(dependencies).streamChat(input, options), requireInspection);
261
266
  const receipt = await f.db("assistant_turn_requests").first();
262
267
  assert.equal(receipt.status, "failed");
263
- const restarted = createChatService({ ...dependencies, turnRequests: createTurnRequestsRepository(f.db) });
268
+ const restarted = createChatService({ ...dependencies, turnRequests: f.turnRequests });
264
269
  await assert.rejects(restarted.streamChat(input, options), requireInspection);
265
270
  assert.deepEqual(JSON.parse(receipt.response_json).failure, {
266
271
  message: "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
@@ -347,7 +352,7 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
347
352
  executeToolCall() { effects++; assert.fail("Retained history cannot repeat an application effect"); } };
348
353
  const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
349
354
  t.after(() => runtime.close());
350
- const turnRequests = createTurnRequestsRepository(f.db);
355
+ const turnRequests = f.turnRequests;
351
356
  const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
352
357
  transcriptService: createTranscriptService(f), turnRequests,
353
358
  assistantConfigService: { resolveSystemPrompt: async () => "Read only the admitted request" },
@@ -376,7 +381,7 @@ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"])
376
381
  return true;
377
382
  });
378
383
  const receipt = await f.db("assistant_turn_requests").first();
379
- const restarted = createChatService({ ...dependencies, turnRequests: createTurnRequestsRepository(f.db) });
384
+ const restarted = createChatService({ ...dependencies, turnRequests: f.turnRequests });
380
385
  await assert.rejects(restarted.streamChat(input, options), error => {
381
386
  assert.equal(error.status, 409);
382
387
  assert.equal(error.code, "assistant_history_inspection_required");
@@ -464,7 +469,7 @@ test("a long conversation's rows are read once, then each write appends its rows
464
469
  const f = await fixture(t);
465
470
  const conversation = await f.conversation();
466
471
  await writeTurns(f.storage, conversation.id, 300);
467
- const cold = f.connect();
472
+ const cold = await f.connect();
468
473
  let recorded = recordQueries(cold.db);
469
474
  assert.equal((await cold.storage.read(conversation.id, transaction => transaction.listTurnIds())).length, 300);
470
475
  recorded.stop();
@@ -485,12 +490,14 @@ test("a long conversation's rows are read once, then each write appends its rows
485
490
  const write = await measure(() => appendTurn("after-300"));
486
491
  assert.equal(write.result, "000301");
487
492
  assert.deepEqual(write.queries.filter(readsWholeConversation), []);
488
- assert.ok(write.queries.length <= 12, `A write issues a constant number of queries, not one per stored row (${write.queries.length})`);
493
+ // BEGIN, the conversation's lock, its last seq, the message's insert and read-back, one update of the conversation, COMMIT;
494
+ // SQLite takes the lock with a no-op write and then reads the row.
495
+ assert.ok(write.queries.length <= 8, `A write issues a constant number of queries, not one per stored row (${write.queries.length})`);
489
496
  const read = await measure(() => cold.storage.read(conversation.id, transaction => transaction.readTurn("000301")));
490
497
  assert.equal(read.result.user.text, "One more");
491
498
  assert.deepEqual(read.result.metadata, { runtime: { status: "running" } });
492
499
  assert.deepEqual(read.queries.filter(readsWholeConversation), []);
493
- assert.ok(read.queries.length <= 5, `A read issues a constant number of queries (${read.queries.length})`);
500
+ assert.ok(read.queries.length <= 4, `A read issues a constant number of queries (${read.queries.length})`);
494
501
 
495
502
  await writeTurns(cold.storage, conversation.id, 50, "later");
496
503
  const longer = await measure(() => appendTurn("after-350"));
@@ -500,7 +507,7 @@ test("a long conversation's rows are read once, then each write appends its rows
500
507
  transaction.replaceAssistant("000301", { role: "assistant", messageId: "after-300:reply-1", text: "Answered", at: "2026-02-01T00:00:01.000Z" })));
501
508
  assert.deepEqual(answer.queries.filter(readsWholeConversation), []);
502
509
 
503
- const fresh = f.connect();
510
+ const fresh = await f.connect();
504
511
  const expected = await fresh.storage.read(conversation.id, async transaction =>
505
512
  Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id))));
506
513
  const cached = await cold.storage.read(conversation.id, async transaction =>
@@ -512,7 +519,7 @@ test("a long conversation's rows are read once, then each write appends its rows
512
519
  test("a server sees another server's saves, direct row inserts and conversation updates at once", async t => {
513
520
  const f = await fixture(t);
514
521
  const conversation = await f.conversation();
515
- const other = f.connect();
522
+ const other = await f.connect();
516
523
  await writeTurns(f.storage, conversation.id, 2);
517
524
  assert.deepEqual(await f.storage.read(conversation.id, transaction => transaction.listTurnIds()), ["000001", "000002"]);
518
525
  await writeTurns(other.storage, conversation.id, 1, "other");
@@ -569,7 +576,7 @@ test("the summary and the last turn it covers are saved on the conversation row,
569
576
  assert.equal(Object.hasOwn(await f.conversationsRepository.findById(conversation.id), "summary"), false,
570
577
  "Conversation records sent to the browser do not carry the summary");
571
578
 
572
- const restarted = f.connect();
579
+ const restarted = await f.connect();
573
580
  const later = await restarted.storage.read(conversation.id, async transaction => {
574
581
  const summary = await transaction.readSummary();
575
582
  return { summary, turns: await transaction.readTurnsAfter(summary.throughTurnId) };
@@ -0,0 +1,39 @@
1
+ import { ActionProvider } from "@jskit-ai/kernel/server/actions";
2
+ import { EventProvider } from "@jskit-ai/kernel/server/runtime";
3
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
4
+ import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
5
+ import { ResourceRuntimeProvider } from "@jskit-ai/resource-runtime/server";
6
+ import { AssistantStoreFeature } from "../../src/server/AssistantStoreFeature.js";
7
+
8
+ /**
9
+ * The assistant's repositories on a test's own Knex, as an application gets
10
+ * them: AssistantStoreFeature owning its tables through the resource runtime.
11
+ * `close()` shuts that runtime down; the Knex stays the test's.
12
+ * @param {import("knex").Knex} knex
13
+ */
14
+ export async function createTestAssistantStore(knex) {
15
+ let repositories = null;
16
+ const runtime = createCapabilityRuntime({
17
+ inputs: { "runtime.logger": silentLogger },
18
+ providers: [
19
+ EventProvider,
20
+ ActionProvider,
21
+ defineProvider({ id: "test.database", provides: { value: "runtime.database" }, setup: () => ({ value: { knex } }) }),
22
+ ResourceRuntimeProvider,
23
+ AssistantStoreFeature,
24
+ defineProvider({
25
+ id: "test.assistant-store",
26
+ requires: { store: "assistant.repositories" },
27
+ setup({ store }) {
28
+ repositories = store;
29
+ return {};
30
+ }
31
+ })
32
+ ]
33
+ });
34
+ await runtime.start();
35
+ return {
36
+ ...repositories,
37
+ close: () => runtime.shutdown()
38
+ };
39
+ }
@@ -18,14 +18,21 @@ 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);
21
+ async findWithTranscriptState(value, options) {
22
+ if (value !== id) return null;
23
+ const { conversation, transcript } = read(options);
24
+ return structuredClone({ conversation, ...transcript });
25
+ },
26
+ async writeTranscriptState(value, { changes = {}, ...transcript }, options) {
27
+ if (value !== id) return;
28
+ const target = read(options);
29
+ target.transcript = structuredClone(transcript);
30
+ Object.assign(target.conversation, structuredClone(changes));
24
31
  },
25
32
  transaction(operation) {
26
33
  const next = pending.then(async () => {
27
34
  const trx = structuredClone(state);
28
- const value = await operation(trx);
35
+ const value = await operation({ trx });
29
36
  state = trx;
30
37
  messages.splice(0, messages.length, ...structuredClone(state.messages));
31
38
  return value;
@@ -5,37 +5,60 @@ import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import knex from "knex";
7
7
  import migration from "../migrations/assistant_turn_requests.cjs";
8
- import { createRepository } from "../src/server/repositories/turnRequestsRepository.js";
9
8
  import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
10
9
  import { createChatService } from "../src/server/services/chatService.js";
11
10
  import { NO_PERSON_CONTEXT, createPersonContextPrompt } from "../src/server/support/personContext.js";
12
11
  import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
13
12
  import { createMemoryAssistantRows } from "./support/memoryAssistantRows.js";
14
13
  import { createScriptedModel } from "./support/scriptedModel.js";
14
+ import { createTestAssistantStore } from "./support/assistantStore.js";
15
15
 
16
16
  async function database(t) {
17
17
  const dir = await mkdtemp(path.join(tmpdir(), "assistant-requests-"));
18
18
  const connections = [];
19
+ const stores = [];
20
+ // Two connections in one process: a connection that waited for SQLite's lock would block the event loop the lock's
21
+ // holder needs to commit, so each finds the database busy at once, and its write's transaction runs again.
19
22
  const connect = () => {
20
- const db = knex({ client: "better-sqlite3", connection: { filename: path.join(dir, "requests.db") }, useNullAsDefault: true });
23
+ const db = knex({
24
+ client: "better-sqlite3",
25
+ connection: { filename: path.join(dir, "requests.db") },
26
+ useNullAsDefault: true,
27
+ pool: {
28
+ afterCreate(connection, done) {
29
+ connection.pragma("busy_timeout = 0");
30
+ done(null, connection);
31
+ }
32
+ }
33
+ });
21
34
  connections.push(db);
22
35
  return db;
23
36
  };
24
- t.after(async () => { for (const db of connections) await db.destroy(); await rm(dir, { recursive: true, force: true }); });
37
+ t.after(async () => {
38
+ for (const store of stores) await store.close();
39
+ for (const db of connections) await db.destroy();
40
+ await rm(dir, { recursive: true, force: true });
41
+ });
42
+ // The turn requests repository on a connection, as AssistantStoreFeature builds it.
43
+ const createRepository = async (connection) => {
44
+ const store = await createTestAssistantStore(connection);
45
+ stores.push(store);
46
+ return store.turnRequests;
47
+ };
25
48
  const db = connect();
26
49
  await db.schema.createTable("users", table => table.bigInteger("id").primary());
27
50
  await db("users").insert([{ id: 1 }, { id: 2 }]);
28
51
  await migration.up(db);
29
- return { db, connect };
52
+ return { db, connect, createRepository };
30
53
  }
31
54
 
32
55
  const scope = { actorUserId: "1", surfaceId: "assistant", workspaceId: null };
33
56
  const request = { messageId: "message-1", input: "Do this once." };
34
57
 
35
58
  test("migration preserves existing claims and database uniqueness arbitrates independent connections", async t => {
36
- const { db, connect } = await database(t);
37
- const first = createRepository(db);
38
- const second = createRepository(connect());
59
+ const { db, connect, createRepository } = await database(t);
60
+ const first = await createRepository(db);
61
+ const second = await createRepository(connect());
39
62
  const claims = await Promise.all([first.claim(scope, request), second.claim(scope, request)]);
40
63
  assert.equal(claims.filter(claim => claim.acquired).length, 1);
41
64
  assert.equal((await db("assistant_turn_requests")).length, 1);
@@ -84,7 +107,7 @@ function run(chat, input = request, { events = [], disconnectAfterCompletion = f
84
107
  }
85
108
 
86
109
  test("concurrent submissions, lost completion responses and a fresh service replay one provider execution", async t => {
87
- const { db, connect } = await database(t);
110
+ const { db, connect, createRepository } = await database(t);
88
111
  const waiting = Promise.withResolvers();
89
112
  const started = Promise.withResolvers();
90
113
  let calls = 0;
@@ -96,11 +119,11 @@ test("concurrent submissions, lost completion responses and a fresh service repl
96
119
  yield { choices: [{ delta: { content: "Completed once." } }] };
97
120
  yield { choices: [{ delta: {}, finish_reason: "stop" }] };
98
121
  };
99
- const first = service(createRepository(db), { provider, transcript });
122
+ const first = service(await createRepository(db), { provider, transcript });
100
123
  const pending = run(first, request, { disconnectAfterCompletion: true });
101
124
  const failedResponse = assert.rejects(pending, /Response lost/);
102
125
  await started.promise;
103
- const second = service(createRepository(connect()), { provider, transcript });
126
+ const second = service(await createRepository(connect()), { provider, transcript });
104
127
  const inFlight = [];
105
128
  assert.equal((await run(second, request, { events: inFlight })).status, "unconfirmed");
106
129
  assert.equal(inFlight.find(item => item.method === "sendMeta").event.conversationId, "100");
@@ -108,7 +131,7 @@ test("concurrent submissions, lost completion responses and a fresh service repl
108
131
  waiting.resolve();
109
132
  await failedResponse;
110
133
  const replay = [];
111
- const restarted = service(createRepository(connect()), { provider: () => { throw new Error("Replay must not invoke the provider."); } });
134
+ const restarted = service(await createRepository(connect()), { provider: () => { throw new Error("Replay must not invoke the provider."); } });
112
135
  assert.equal((await run(restarted, request, { events: replay })).status, "completed");
113
136
  assert.equal(replay.find(item => item.method === "sendAssistantMessage").event.text, "Completed once.");
114
137
  assert.equal(calls, 1);
@@ -117,11 +140,11 @@ test("concurrent submissions, lost completion responses and a fresh service repl
117
140
  });
118
141
 
119
142
  test("an interrupted claim is never stolen after restart, and provider failures replay without executing again", async t => {
120
- const { db, connect } = await database(t);
121
- const repository = createRepository(db);
143
+ const { db, connect, createRepository } = await database(t);
144
+ const repository = await createRepository(db);
122
145
  await repository.claim(scope, { ...request, conversationId: null, integrationId: "" });
123
146
  let calls = 0;
124
- const chat = service(createRepository(connect()), { provider: () => { calls++; throw new Error("Provider failed."); } });
147
+ const chat = service(await createRepository(connect()), { provider: () => { calls++; throw new Error("Provider failed."); } });
125
148
  const unconfirmed = [];
126
149
  assert.equal((await run(chat, request, { events: unconfirmed })).status, "unconfirmed");
127
150
  assert.equal(unconfirmed.find(item => item.method === "sendError").event.messageCode, "assistant.runtime.request.unconfirmed");