@jskit-ai/assistant-runtime 0.2.29 → 0.2.31

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 (38) hide show
  1. package/CHANGELOG.md +9 -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 +119 -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
  38. package/test/usersForeignKeysMigration.test.js +24 -0
@@ -126,25 +126,25 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
126
126
  }
127
127
  }
128
128
 
129
- async function readRows(id, conversation, trx) {
129
+ async function readRows(id, conversation, txOptions) {
130
130
  const rows = [];
131
131
  for (let page = 1; ; page += 1) {
132
- const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 }, { trx });
132
+ const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 }, txOptions);
133
133
  rows.push(...batch);
134
134
  if (batch.length < 500) break;
135
135
  }
136
136
  return rows;
137
137
  }
138
138
 
139
- async function load(id, trx, write = false) {
140
- const conversation = await conversationsRepository.findById(id, { trx, forUpdate: write });
141
- if (!conversation) throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
142
- const { revision, summary } = await conversationsRepository.readTranscriptState(id, { trx });
143
- const lastSeq = await messagesRepository.lastSequence(id, { trx });
139
+ async function load(id, txOptions, write = false) {
140
+ const found = await conversationsRepository.findWithTranscriptState(id, { ...txOptions, forUpdate: write });
141
+ if (!found) throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
142
+ const { conversation, revision, summary } = found;
143
+ const lastSeq = await messagesRepository.lastSequence(id, txOptions);
144
144
  let saved = cache.get(id);
145
145
  if (saved && revision && saved.revision === revision && saved.lastSeq === lastSeq) remember(id, saved);
146
146
  else {
147
- const rows = await readRows(id, conversation, trx);
147
+ const rows = await readRows(id, conversation, txOptions);
148
148
  saved = { revision, lastSeq, rows, projection: inspectSqlConversationRows(conversation, rows) };
149
149
  remember(id, saved);
150
150
  }
@@ -153,7 +153,8 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
153
153
  return { conversation, summary, lastSeq, rows: saved.rows, projection: { ...projection, record }, next: null };
154
154
  }
155
155
 
156
- async function save(id, loaded, record, trx) {
156
+ // Writes under the conversation's row lock, so each new message takes the next seq after the loaded last one.
157
+ async function save(id, loaded, record, txOptions) {
157
158
  const { conversation, projection } = loaded;
158
159
  let added = 0;
159
160
  let title = conversation.title;
@@ -162,11 +163,11 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
162
163
  const persist = async (previous, fields) => {
163
164
  if (previous) {
164
165
  if (previous.contentText !== fields.contentText || !isDeepStrictEqual(previous.metadata, fields.metadata)) {
165
- updated.set(previous.id, await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata }, { trx }));
166
+ updated.set(previous.id, await messagesRepository.updateById(previous.id, { contentText: fields.contentText, metadata: fields.metadata }, txOptions));
166
167
  }
167
168
  } else {
168
169
  inserted.push(await messagesRepository.create({ conversationId: id, workspaceId: conversation.workspaceId,
169
- actorUserId: conversation.createdByUserId, ...fields }, { trx }));
170
+ actorUserId: conversation.createdByUserId, seq: loaded.lastSeq + added + 1, ...fields }, txOptions));
170
171
  added += 1;
171
172
  }
172
173
  };
@@ -203,11 +204,14 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
203
204
  const patch = { ...(added ? { messageCount: conversation.messageCount + added } : {}),
204
205
  ...(title !== conversation.title ? { title } : {}),
205
206
  ...(!isDeepStrictEqual(record.metadata, conversation.metadata) ? { metadata: record.metadata } : {}) };
206
- if (Object.keys(patch).length) await conversationsRepository.updateById(id, patch, { trx });
207
207
  const summary = record.summary ? { text: record.summary.text, throughTurnId: record.summary.throughTurnId } : null;
208
- if (!updated.size && !inserted.length && isDeepStrictEqual(summary, loaded.summary)) return;
208
+ if (!updated.size && !inserted.length && isDeepStrictEqual(summary, loaded.summary)) {
209
+ if (Object.keys(patch).length) await conversationsRepository.updateById(id, patch, txOptions);
210
+ return;
211
+ }
209
212
  const revision = randomUUID();
210
- await conversationsRepository.writeTranscriptState(id, { revision, summary }, { trx });
213
+ // One statement writes the transcript state and the record's changes.
214
+ await conversationsRepository.writeTranscriptState(id, { revision, summary, changes: patch }, txOptions);
211
215
  const rows = loaded.rows.map(row => updated.get(row.id) || row).concat(inserted);
212
216
  loaded.next = { revision, lastSeq: Math.max(loaded.lastSeq, ...inserted.map(row => row.seq)), rows,
213
217
  projection: inspectSqlConversationRows(conversation, rows) };
@@ -218,12 +222,12 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
218
222
  let entered = false;
219
223
  let loaded = null;
220
224
  try {
221
- const result = await conversationsRepository.transaction(async trx => {
222
- loaded = await load(id, trx, write);
225
+ const result = await conversationsRepository.transaction(async txOptions => {
226
+ loaded = await load(id, txOptions, write);
223
227
  requireUnambiguous(loaded.projection);
224
228
  const storage = createConversationStorage({
225
229
  readRecord: () => loaded.projection.record,
226
- writeRecord: (_scope, record) => save(id, loaded, record, trx)
230
+ writeRecord: (_scope, record) => save(id, loaded, record, txOptions)
227
231
  });
228
232
  entered = true;
229
233
  return storage[write ? "write" : "read"](id, operation);
@@ -249,8 +253,8 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
249
253
  }
250
254
 
251
255
  function preflight(id) {
252
- return conversationsRepository.transaction(async trx => {
253
- const { rows, projection } = await load(id, trx);
256
+ return conversationsRepository.transaction(async txOptions => {
257
+ const { rows, projection } = await load(id, txOptions);
254
258
  return { conversationId: id, rowCount: rows.length, issues: projection.issues };
255
259
  });
256
260
  }
@@ -16,6 +16,7 @@
16
16
  /** @typedef {ReturnType<typeof import("./repositories/conversationsRepository.js").createRepository>} ConversationsRepository */
17
17
  /** @typedef {ReturnType<typeof import("./repositories/messagesRepository.js").createRepository>} MessagesRepository */
18
18
  /** @typedef {ReturnType<typeof import("./repositories/turnRequestsRepository.js").createRepository>} TurnRequestsRepository */
19
+ /** @typedef {{ config: AssistantConfigRepository, conversations: ConversationsRepository, messages: MessagesRepository, turnRequests: TurnRequestsRepository }} AssistantRepositories The `assistant.repositories` capability: the database-backed assistant's repositories, which AssistantStoreFeature provides. */
19
20
 
20
21
  /** @typedef {import("@jskit-ai/workspaces-core/server/types").WorkspacesCore["scope"]} WorkspaceScopeSupport The workspace route support of `workspaces.core`: its route params, the action input they build and the workspace an action runs in. */
21
22
 
@@ -71,6 +71,7 @@ test("settings routes and actions compose independently of the chat service", as
71
71
  settingsSurfaces: ["console"],
72
72
  assistantConfigService: {
73
73
  getSettings(input, options) { calls.push({ input, options }); return {}; },
74
+ canUpdateSettings(input, options) { calls.push({ canUpdate: input, options }); return true; },
74
75
  updateSettings(input, patch, options) { calls.push({ input, patch, options }); return {}; }
75
76
  }
76
77
  });
@@ -98,6 +99,7 @@ test("settings routes and actions compose independently of the chat service", as
98
99
  }
99
100
  assert.deepEqual(calls, [
100
101
  { input: { targetSurfaceId: "home" }, options: { context } },
102
+ { canUpdate: { targetSurfaceId: "home" }, options: { context } },
101
103
  { input: { targetSurfaceId: "home", patch }, patch, options: { context } }
102
104
  ]);
103
105
  });
@@ -11,14 +11,15 @@ const RUN = process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION === "1";
11
11
  const FIXTURE_ROOT = fileURLToPath(new URL("../fixtures/settings/", import.meta.url));
12
12
  const options = { skip: RUN ? false : "set JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION=1 to run it", timeout: 120_000 };
13
13
 
14
- function settingsDocument(targetSurfaceId, systemPrompt, workspaceId = null) {
14
+ // canUpdate is the server's answer to whether the signed-in person may change the settings.
15
+ function settingsDocument(targetSurfaceId, systemPrompt, workspaceId = null, { canUpdate = true } = {}) {
15
16
  return {
16
17
  contentType: "application/vnd.api+json",
17
18
  json: {
18
19
  data: {
19
20
  type: "assistant-settings",
20
21
  id: targetSurfaceId,
21
- attributes: { targetSurfaceId, scopeKey: targetSurfaceId, workspaceId, settings: { systemPrompt } }
22
+ attributes: { targetSurfaceId, scopeKey: targetSurfaceId, workspaceId, settings: { systemPrompt }, canUpdate }
22
23
  }
23
24
  }
24
25
  };
@@ -119,6 +120,26 @@ test("the app bar's Save submits the assistant settings, on phones and wide wind
119
120
  }
120
121
  });
121
122
 
123
+ test("someone the server does not let change the settings sees them read-only, without Save", options, async (t) => {
124
+ const fixture = await startFixture(t);
125
+ for (const viewport of [{ width: 390, height: 844 }, { width: 1280, height: 900 }]) {
126
+ const api = await openPage(fixture, {
127
+ viewport,
128
+ routes: { "GET /api/assistant/console/settings": settingsDocument("console", "Answer briefly.", null, { canUpdate: false }) }
129
+ });
130
+ const { page } = api;
131
+ await page.goto("/settings/console");
132
+ await expect(prompt(page)).toHaveValue("Answer briefly.");
133
+ await expect(prompt(page)).toHaveAttribute("readonly", "");
134
+ await expect(page.getByText("Read-only", { exact: true })).toBeVisible();
135
+ await expect(page.getByRole("button", { name: "Save", exact: true })).toHaveCount(0);
136
+ // Enter in the read-only form sends nothing.
137
+ await prompt(page).press("Enter");
138
+ assert.deepEqual(api.requests.map(({ key }) => key), ["GET /api/assistant/console/settings"]);
139
+ await finish(api);
140
+ }
141
+ });
142
+
122
143
  test("a refused save shows the prompt's message and reports the failure", options, async (t) => {
123
144
  const fixture = await startFixture(t);
124
145
  const api = await openPage(fixture, {
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
3
4
  import { createService } from "../src/server/services/assistantConfigService.js";
5
+ import { createAssistantSettingsActions } from "../src/server/createAssistantSettingsActions.js";
6
+ import { actionIds } from "../src/server/actionIds.js";
4
7
 
5
8
  function createSettingsRows() {
6
9
  const rows = new Map();
@@ -43,7 +46,8 @@ function createSettings(assistantServer, { consoleOwnerIds = [] } = {}) {
43
46
  consoleService: {
44
47
  async requireConsoleOwner(context) {
45
48
  if (!consoleOwnerIds.includes(context.actor.id)) {
46
- throw Object.assign(new Error("Console owner required."), { code: "console.ownerRequired" });
49
+ // As console-core's consoleService refuses: a 403.
50
+ throw new AppError(403, "Console owner required.", { code: "console.ownerRequired" });
47
51
  }
48
52
  }
49
53
  },
@@ -95,6 +99,47 @@ test("a workspace assistant still needs workspace.settings.update, and a console
95
99
  assert.equal((await update(settings, "ops", { actor: { id: "1" }, permissions: [] })).settings.systemPrompt, "Set by 1");
96
100
  });
97
101
 
102
+ test("the settings say whether the reader may change them, by the check that refuses the change", async () => {
103
+ const can = (settings, targetSurfaceId, context) => settings.canUpdateSettings({ targetSurfaceId }, { context });
104
+ const admin = { actor: { id: "1" }, permissions: ["assistant.settings.update"] };
105
+ const settings = createSettings({}, { consoleOwnerIds: ["1"] });
106
+ assert.equal(await can(settings, "home", employee), false);
107
+ assert.equal(await can(settings, "home", admin), true);
108
+ assert.equal(await can(settings, "admin", { actor: { id: "7" }, permissions: ["workspace.settings.view"] }), false);
109
+ assert.equal(await can(settings, "admin", { actor: { id: "1" }, permissions: ["workspace.settings.update"] }), true);
110
+ assert.equal(await can(settings, "ops", employee), false);
111
+ assert.equal(await can(settings, "ops", { actor: { id: "1" }, permissions: [] }), true);
112
+
113
+ // An app's settingsUpdatePermission decides, as it does for the change itself.
114
+ const configured = createSettings({ home: { settingsUpdatePermission: "app.admin" } });
115
+ assert.equal(await can(configured, "home", admin), false);
116
+ assert.equal(await can(configured, "home", { actor: { id: "1" }, permissions: ["app.admin"] }), true);
117
+ assert.equal(await can(createSettings({ home: { settingsUpdatePermission: { require: "authenticated" } } }), "home", employee), true);
118
+
119
+ // A failure that is not a refusal still fails.
120
+ const noConsole = createService({
121
+ assistantConfigRepository: createSettingsRows(),
122
+ consoleService: null,
123
+ appConfig: createAppConfig({}),
124
+ workspaceScopeSupport: null
125
+ });
126
+ await assert.rejects(can(noConsole, "ops", admin), /requires consoleService\.requireConsoleOwner\(\)/u);
127
+
128
+ // The settings read carries the answer, and a successful change says the person may change them.
129
+ const definitions = createAssistantSettingsActions({ assistantConfigService: configured, settingsSurfaces: ["home"] });
130
+ const execute = (id, input, context) => definitions.find((definition) => definition.id === id).execute(input, context);
131
+ const read = async (context) => (await execute(actionIds.settingsRead, { targetSurfaceId: "home" }, context)).value;
132
+ assert.deepEqual(await read(admin), {
133
+ targetSurfaceId: "home", workspaceId: null, scopeKey: "home:", settings: { systemPrompt: "" }, canUpdate: false
134
+ });
135
+ const appAdmin = { actor: { id: "1" }, permissions: ["app.admin"] };
136
+ assert.equal((await read(appAdmin)).canUpdate, true);
137
+ const changed = await execute(actionIds.settingsUpdate, { targetSurfaceId: "home", patch: { systemPrompt: "Be brief." } }, appAdmin);
138
+ assert.deepEqual(changed.value, {
139
+ targetSurfaceId: "home", workspaceId: null, scopeKey: "home:", settings: { systemPrompt: "Be brief." }, canUpdate: true
140
+ });
141
+ });
142
+
98
143
  test("a malformed settingsUpdatePermission stops the settings service from starting", () => {
99
144
  for (const settingsUpdatePermission of [false, "", { require: "all" }, { require: "none" }, ["app.admin"]]) {
100
145
  assert.throws(() => createSettings({ home: { settingsUpdatePermission } }), {
@@ -91,7 +91,7 @@ function createRecordingLogger() {
91
91
  }
92
92
 
93
93
  function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime,
94
- once, personContext = NO_PERSON_CONTEXT, savedSystemPrompt = "", workspace = null } = {}) {
94
+ once, personContext = NO_PERSON_CONTEXT, savedSystemPrompt = "", workspace = null, transcriptFailures = {} } = {}) {
95
95
  const pendingCompletions = [...completions];
96
96
  const completionRequests = [];
97
97
  const transcriptMessages = [];
@@ -135,6 +135,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
135
135
  },
136
136
  transcriptService: {
137
137
  async createConversationForTurn() {
138
+ if (transcriptFailures.create) throw transcriptFailures.create;
138
139
  return {
139
140
  conversation: {
140
141
  id: "conversation_1"
@@ -142,6 +143,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
142
143
  };
143
144
  },
144
145
  async completeConversation(_surface, _conversationId, completion) {
146
+ if (transcriptFailures.complete && completion.status === "completed") throw transcriptFailures.complete;
145
147
  completedConversations.push(structuredClone(completion));
146
148
  }
147
149
  },
@@ -180,6 +182,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
180
182
  },
181
183
  personContextPrompt: createPersonContextPrompt({ personContext, logger: logged.logger }),
182
184
  appConfig: workspace ? WORKSPACE_APP_CONFIG : APP_CONFIG,
185
+ logger: logged.logger,
183
186
  ...(workspace ? { workspaceScopeSupport: { resolveWorkspace: () => workspace } } : {})
184
187
  };
185
188
  const conversationRuntime = createAssistantConversationRuntime({
@@ -400,6 +403,34 @@ test("a failed action's error goes back to the model, and the action stays avail
400
403
  assert.deepEqual(assistantMessages(harness.streamEvents), ["The second search found it."]);
401
404
  });
402
405
 
406
+ // MariaDB's errno 1020 as the mysql2 driver reports it, its message starting with the statement and its values.
407
+ function databaseError() {
408
+ return Object.assign(new Error("UPDATE `conversations` SET `title` = 'secret-value' - Record has changed since last read"), {
409
+ code: "ER_CHECKREAD",
410
+ errno: 1020,
411
+ sqlMessage: "Record has changed since last read in table 'conversations'",
412
+ sql: "UPDATE `conversations` SET `title` = 'secret-value'"
413
+ });
414
+ }
415
+
416
+ test("a database error streams, saves and replays only as the kernel's generic answer, and its detail goes to the log", async () => {
417
+ const streamedFailure = databaseError();
418
+ const streamed = createHarness([textCompletion("Done.")], { transcriptFailures: { complete: streamedFailure } });
419
+ const result = await streamed.run();
420
+ assert.equal(result.status, "failed");
421
+ const sent = streamed.streamEvents.find(event => event.method === "sendError").payload;
422
+ assert.deepEqual({ code: sent.code, message: sent.message, status: sent.status }, { code: "kernel.database.concurrentWrite", message: "Another request changed the same data at the same time. Try again.", status: 409 });
423
+ assert.equal(JSON.stringify(streamed.streamEvents).includes("secret-value"), false);
424
+ assert.deepEqual(streamed.logged.filter(entry => entry.level === "error"),
425
+ [{ level: "error", fields: { err: streamedFailure }, message: "Assistant turn failed on a database error." }]);
426
+
427
+ // Before the stream starts, the error reaches the route as it was thrown; the saved failure a replay answers is the stand-in.
428
+ const earlyFailure = databaseError();
429
+ const early = createHarness([], { transcriptFailures: { create: earlyFailure } });
430
+ await assert.rejects(early.run(), error => error === earlyFailure);
431
+ await assert.rejects(early.run(), error => error instanceof AppError && error.status === 409 &&
432
+ error.code === "kernel.database.concurrentWrite");
433
+ });
403
434
 
404
435
  test("answer text reaches the client before the provider finishes, then persists once", async () => {
405
436
  const firstChunk = Promise.withResolvers();
@@ -42,7 +42,7 @@ test("AssistantFeature composes chat, transcripts, settings, routes, and action
42
42
  providers: [
43
43
  EventProvider, ActionProvider,
44
44
  valueProvider("test.config", "runtime.config", config),
45
- valueProvider("test.database", "runtime.database", { knex: Object.assign(() => {}, {}) }),
45
+ valueProvider("test.repositories", "assistant.repositories", { config: {}, conversations: {}, messages: {}, turnRequests: {} }),
46
46
  valueProvider("test.env", "runtime.env", {}),
47
47
  valueProvider("test.http", "runtime.http", {
48
48
  router: {
@@ -92,13 +92,13 @@ test("AssistantFeature uses a supplied file runtime without loading database or
92
92
  import { tmpdir } from "node:os";
93
93
  import { join } from "node:path";
94
94
  registerHooks({ resolve(specifier, context, nextResolve) {
95
- if (["@jskit-ai/database-runtime", "knex"].some(name => specifier === name || specifier.startsWith(name + "/")) ||
95
+ if (["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime", "knex"].some(name => specifier === name || specifier.startsWith(name + "/")) ||
96
96
  specifier.endsWith("/chatService.js") || specifier.endsWith("/createAssistantRuntime.js")) {
97
97
  throw new Error("Unexpected database/legacy import: " + specifier);
98
98
  }
99
99
  return nextResolve(specifier, context);
100
100
  } });
101
- for (const name of ["@jskit-ai/database-runtime", "knex"]) {
101
+ for (const name of ["@jskit-ai/database-runtime", "@jskit-ai/resource-runtime", "knex"]) {
102
102
  await assert.rejects(() => import(name), /Unexpected database/);
103
103
  }
104
104
  const { AssistantFeature } = await import("@jskit-ai/assistant-runtime/server");
@@ -296,6 +296,20 @@ test("one AssistantFeature composes host access and declared data while settings
296
296
  assert.equal((await conversation.read()).id, "persisted-local");
297
297
  });
298
298
 
299
+ test("AssistantFeature without conversations or AssistantStoreFeature fails at startup, naming the feature to register", async () => {
300
+ let registrations = 0;
301
+ const host = createCapabilityRuntime({ providers: [EventProvider, ActionProvider, AssistantFeature], inputs: {
302
+ "runtime.logger": silentLogger,
303
+ "runtime.config": {}, "runtime.env": {},
304
+ "runtime.http": { router: { register() { registrations++; } } }
305
+ } });
306
+ await assert.rejects(host.start(), {
307
+ message: "AssistantFeature keeps conversations in assistant.repositories, which no feature provides: register AssistantStoreFeature " +
308
+ "from @jskit-ai/assistant-runtime/server in your server providers, or provide assistant.conversations (createConversationRuntime())."
309
+ });
310
+ assert.equal(registrations, 0);
311
+ });
312
+
299
313
  test("AssistantFeature rejects incomplete host access before registering routes or actions", async () => {
300
314
  const validAccess = { router: { register() {} }, wrapAction: definition => definition,
301
315
  subscribeActionId: "test.host.conversation.subscribe", requestPolicy: "host" };