@jskit-ai/assistant-runtime 0.2.27 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `@jskit-ai/assistant-runtime/client/providers/AssistantClientProvider` exports the client provider by itself, so the client bootstrap loads it without the assistant's elements and what they import.
6
+ - `subscribeAssistantConversation()` takes `noticeMs`, how long an unanswered subscription waits before the loading notice. It stays ten seconds; tests pass a shorter one, so the real-socket proof of a slow subscription no longer waits ten seconds.
5
7
  - The assistant settings are a settings pane: the app bar shows their title and Save (a primary bar action that submits the form, with the card's loading and disabled states), and the card no longer has a Save row at its bottom. The button reads **Save** (`jskit.assistant.runtime.settings.save`, was "Save assistant settings"), since the bar's title names the settings. Visual only: what saving does is unchanged.
6
8
  - A browser test (`test/assistantSettings.browser.test.js`, fixture `fixtures/settings/`, run in CI) renders the settings pane in the shell's `ShellLayout` at 390 and 1280 px: the bar's title is the page's one `h1`, its **Save** is a submit button for `assistant-settings-form` that sends the edited prompt and shows its loading state until the save answers, a refused save shows the prompt's field message and reports the failure, and workspace settings show their note and no Save until the address names a workspace.
7
9
  - The assistant surface pattern's icon is `$smartToy`, and the fixtures render with `@jskit-ai/material-web`.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
7
7
  },
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
10
+ "./client/providers/AssistantClientProvider": "./src/client/providers/AssistantClientProvider.js",
10
11
  "./shared": "./src/shared/index.js",
11
12
  "./server/actionIds": "./src/server/actionIds.js",
12
13
  "./server": "./src/server/index.js",
@@ -14,19 +15,19 @@
14
15
  "./locales/en.json": "./locales/en.json"
15
16
  },
16
17
  "dependencies": {
17
- "@jskit-ai/assistant-core": "0.2.25",
18
+ "@jskit-ai/assistant-core": "0.2.26",
18
19
  "json-rest-schema": "^1.0.17"
19
20
  },
20
21
  "peerDependencies": {
21
- "@jskit-ai/http-runtime": "0.2.11",
22
- "@jskit-ai/http-web": "0.2.19",
23
- "@jskit-ai/kernel": "0.2.11",
24
- "@jskit-ai/realtime": "0.2.17",
25
- "@jskit-ai/shell-web": "0.2.14",
22
+ "@jskit-ai/http-runtime": "0.2.12",
23
+ "@jskit-ai/http-web": "0.2.20",
24
+ "@jskit-ai/kernel": "0.2.12",
25
+ "@jskit-ai/realtime": "0.2.18",
26
+ "@jskit-ai/shell-web": "0.2.15",
26
27
  "@tanstack/vue-query": "^5.90.5",
27
28
  "vue": "^3.5.13",
28
29
  "vuetify": "^4.0.0",
29
- "@jskit-ai/database-runtime": "0.1.260"
30
+ "@jskit-ai/database-runtime": "0.1.261"
30
31
  },
31
32
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
32
33
  "jskit": {
@@ -88,6 +89,10 @@
88
89
  "subpath": "./client",
89
90
  "summary": "Exports assistant runtime client elements and composables."
90
91
  },
92
+ {
93
+ "subpath": "./client/providers/AssistantClientProvider",
94
+ "summary": "Exports the assistant client provider, which the client bootstrap loads without the elements."
95
+ },
91
96
  {
92
97
  "subpath": "./shared",
93
98
  "summary": "Exports assistant runtime shared config helpers."
@@ -45,12 +45,14 @@ function ignoreSubscriptionError() {}
45
45
  * read: (read: { current: () => boolean }) => Promise<any>,
46
46
  * onState: (state: any, options: { initial: boolean, canonical: boolean }) => void,
47
47
  * onEvent?: (event: any) => void,
48
- * onError?: (failure: Error) => void
49
- * }} subscription
48
+ * onError?: (failure: Error) => void,
49
+ * noticeMs?: number
50
+ * }} subscription `noticeMs` is how long an unanswered subscription waits before the loading notice: ten seconds,
51
+ * shorter only in tests.
50
52
  */
51
53
  function subscribeAssistantConversation({
52
54
  socket, conversationId, targetSurfaceId, hostSurfaceId, workspaceSlug,
53
- read, onState, onEvent = ignoreConversationEvent, onError = ignoreSubscriptionError
55
+ read, onState, onEvent = ignoreConversationEvent, onError = ignoreSubscriptionError, noticeMs = SUBSCRIPTION_NOTICE_MS
54
56
  }) {
55
57
  // readable-check: allow method-probe - input validation: an application may hand the conversation binding its own socket
56
58
  if (typeof socket?.on !== "function" || typeof socket?.off !== "function" || typeof socket?.timeout !== "function") {
@@ -239,7 +241,7 @@ function subscribeAssistantConversation({
239
241
  reloadQueued = false;
240
242
  waitingTimer = setTimeout(() => onError(Object.assign(
241
243
  new Error("Chat is still loading. Waiting for the server; Reload chat to try now."),
242
- { code: "assistant_subscription_waiting" })), SUBSCRIPTION_NOTICE_MS);
244
+ { code: "assistant_subscription_waiting" })), noticeMs);
243
245
  socket.timeout(SUBSCRIPTION_LOST_MS).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
244
246
  if (disposed || attempt !== generation) return;
245
247
  clearTimers();
@@ -191,9 +191,9 @@ async function fixture(t, { diagnostics = false, onSetup } = {}) {
191
191
  });
192
192
  return socket;
193
193
  }
194
- function view(socket, id) {
194
+ function view(socket, id, options = {}) {
195
195
  const states = [], received = [], errors = [];
196
- const dispose = subscribeAssistantConversation({ socket, conversationId: id, targetSurfaceId: "home", hostSurfaceId: "home",
196
+ const dispose = subscribeAssistantConversation({ ...options, socket, conversationId: id, targetSurfaceId: "home", hostSurfaceId: "home",
197
197
  read() {
198
198
  return actions.execute({ actionId: actionIds.conversationRead,
199
199
  input: { targetSurfaceId: "home", conversationId: id },
@@ -332,7 +332,10 @@ test("slow successful subscriptions warn once with the blocked stage and no conv
332
332
  assert.equal(f.fetches.length, 0);
333
333
  });
334
334
 
335
- test("a real subscription slower than ten seconds keeps its observer, says it is still loading and delivers the late snapshot", { timeout: 25_000 }, async t => {
335
+ // The client's ten-second notice is proven on a mocked clock below ("a connected subscription keeps waiting for a slow
336
+ // server ..."); here a real socket and server run the same path with a shorter notice, and the server's clock is moved
337
+ // past ten seconds, as in the test above.
338
+ test("a real subscription slower than its notice keeps its observer, says it is still loading and delivers the late snapshot", async t => {
336
339
  const f = await fixture(t);
337
340
  const socket = await f.connect();
338
341
  const socketId = socket.id;
@@ -340,22 +343,23 @@ test("a real subscription slower than ten seconds keeps its observer, says it is
340
343
  await f.conversations.get(id).send({ messageId: "survives-wait", text: "Keep working through a slow snapshot" });
341
344
  await until(f.changes, () => f.fetches.length === 1);
342
345
  const barrier = f.holdNextRead(id);
343
- const view = f.view(socket, id);
346
+ const noticeMs = 300;
347
+ const view = f.view(socket, id, { noticeMs });
344
348
  await until(f.changes, () => barrier.started);
345
- const readStartedAt = performance.now();
346
349
  const subscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
347
- while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
350
+ while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(5_000) });
348
351
  assert.equal(view.errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
349
352
  assert.equal(view.errors[0].code, "assistant_subscription_waiting");
350
353
  assert.equal(f.active(), 1, "The slow observer stays attached while its snapshot is read");
351
354
  assert.equal(socket.id, socketId);
352
355
  assert.equal(socket.connected, true);
353
356
  assert.equal(view.states.length, 0);
354
- // The client's ten-second wait starts before the server's clock does, so the
355
- // read is held until the server has also waited ten seconds.
356
- await new Promise(resolve => setTimeout(resolve, Math.max(0, 10_001 - (performance.now() - readStartedAt))));
357
- barrier.resolve();
358
- await until(f.changes, () => view.states.length === 1);
357
+ const now = performance.now.bind(performance);
358
+ const clock = t.mock.method(performance, "now", () => now() + 10_001);
359
+ try {
360
+ barrier.resolve();
361
+ await until(f.changes, () => view.states.length === 1);
362
+ } finally { clock.mock.restore(); }
359
363
  assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-wait");
360
364
  const records = f.subscriptionTimings.filter(record => record.subscriptionEpoch === subscriptionEpoch);
361
365
  assert.equal(records.some(record => record.stage === "abandoned"), false, "Slow work is never abandoned");
@@ -434,8 +438,19 @@ test("Reload recovers a failed initial subscription on the same connected socket
434
438
  const barrier = f.holdNextRead(f.ids[0]);
435
439
  const view = f.view(socket, f.ids[0]);
436
440
  await until(f.changes, () => barrier.started);
441
+ // A failed read is a server failure, so the client also retries by itself a second later. Here the person reloads
442
+ // within that second; on a loaded host the steps below can take longer, and the retry would then subscribe before
443
+ // the conversation is revoked. The test holds that retry so only Reload recovers.
444
+ const setTimer = globalThis.setTimeout;
445
+ let retry = null;
446
+ t.mock.method(globalThis, "setTimeout", (callback, delay, ...args) => {
447
+ if (retry || delay !== 1_000) return setTimer(callback, delay, ...args);
448
+ retry = callback;
449
+ return setTimer(() => {}, 0);
450
+ });
437
451
  barrier.reject(new Error("The initial history read failed."));
438
452
  await until(f.changes, () => view.errors.length === 1 && f.active() === 0);
453
+ assert.equal(typeof retry, "function", "The failed subscription scheduled its automatic retry");
439
454
  assert.equal(socket.connected, true);
440
455
  assert.equal(view.states.length, 0);
441
456
 
@@ -833,10 +848,14 @@ test("subscription identity and surface checks reject forged bodies and honor co
833
848
  await until(f.changes, () => view.received.some(event => event.type === "message"));
834
849
  assert.equal(otherEvents.length, 0);
835
850
  f.revoked.add(f.ids[0]);
851
+ // A streamed delta may reuse the subscriber's authorization while it is under a second
852
+ // old; once a second has passed, the next delta authorizes again and is refused.
853
+ t.mock.timers.enable({ apis: ["Date"], now: Date.now() + 1000 });
836
854
  const before = view.received.length;
837
855
  f.fetches[0].text(" Secret output after access was revoked.");
838
856
  f.fetches[0].finish();
839
857
  await f.conversations.get(f.ids[0]).wait();
858
+ t.mock.timers.reset();
840
859
  assert.equal(view.received.length, before);
841
860
  assert.equal(f.published.some(event => event.realtime?.payload?.event?.text?.includes("Secret output")), false);
842
861
  const second = f.view(owner, f.ids[1]);
@@ -178,7 +178,7 @@ test("registerRoutes forwards the resolved host surface through every direct act
178
178
  params: {
179
179
  conversationId: "conversation_1",
180
180
  surfaceId: "admin",
181
- workspaceSlug: "dogandgroom"
181
+ workspaceSlug: "acme"
182
182
  },
183
183
  query: {
184
184
  limit: 20
@@ -233,7 +233,7 @@ test("registerRoutes builds workspace action input from explicit config and scop
233
233
  },
234
234
  input: {
235
235
  params: {
236
- workspaceSlug: "dogandgroom",
236
+ workspaceSlug: "acme",
237
237
  surfaceId: "admin"
238
238
  },
239
239
  query: {
@@ -259,7 +259,7 @@ test("registerRoutes builds workspace action input from explicit config and scop
259
259
  assert.equal(reply.statusCode, 200);
260
260
  assert.equal(capturedAction?.surface, "admin");
261
261
  assert.equal(capturedAction?.input?.targetSurfaceId, "admin");
262
- assert.equal(capturedAction?.input?.workspaceSlug, "dogandgroom");
262
+ assert.equal(capturedAction?.input?.workspaceSlug, "acme");
263
263
  });
264
264
 
265
265
  test("registerRoutes returns clear AppError payload for pre-stream assistant failures", async () => {
@@ -306,7 +306,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
306
306
  },
307
307
  input: {
308
308
  params: {
309
- workspaceSlug: "dogandgroom",
309
+ workspaceSlug: "acme",
310
310
  surfaceId: "admin"
311
311
  },
312
312
  body: {
@@ -330,7 +330,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
330
330
  });
331
331
  assert.deepEqual(capturedInput, {
332
332
  targetSurfaceId: "admin",
333
- workspaceSlug: "dogandgroom",
333
+ workspaceSlug: "acme",
334
334
  messageId: "msg_1",
335
335
  input: "hello"
336
336
  });
@@ -349,7 +349,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
349
349
  await route.handler({
350
350
  raw: { on() {}, off() {} },
351
351
  headers: { "x-jskit-surface": "admin" },
352
- input: { params: { workspaceSlug: "dogandgroom", surfaceId: "admin" }, body: { messageId: "msg_2", input: "hello" } },
352
+ input: { params: { workspaceSlug: "acme", surfaceId: "admin" }, body: { messageId: "msg_2", input: "hello" } },
353
353
  executeAction: async () => { throw failure; }
354
354
  }, reply);
355
355
  assert.equal(reply.statusCode, status);
@@ -389,7 +389,7 @@ test("chat service uses explicit app config when conversations are listed", asyn
389
389
  {
390
390
  input: {
391
391
  targetSurfaceId: "admin",
392
- workspaceSlug: "dogandgroom"
392
+ workspaceSlug: "acme"
393
393
  },
394
394
  context: {
395
395
  actor: {
@@ -398,14 +398,14 @@ test("chat service uses explicit app config when conversations are listed", asyn
398
398
  },
399
399
  workspace: {
400
400
  id: 7,
401
- slug: "dogandgroom"
401
+ slug: "acme"
402
402
  }
403
403
  }
404
404
  }
405
405
  );
406
406
 
407
407
  assert.equal(response.assistantSurface.targetSurfaceId, "admin");
408
- assert.equal(response.workspace.slug, "dogandgroom");
408
+ assert.equal(response.workspace.slug, "acme");
409
409
  });
410
410
 
411
411
  test("chat service rejects workspace-scoped assistant surfaces when workspace support is unavailable", async () => {
@@ -435,7 +435,7 @@ test("chat service rejects workspace-scoped assistant surfaces when workspace su
435
435
  {
436
436
  input: {
437
437
  targetSurfaceId: "admin",
438
- workspaceSlug: "dogandgroom"
438
+ workspaceSlug: "acme"
439
439
  },
440
440
  context: {
441
441
  actor: {