@jskit-ai/assistant-runtime 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -34,7 +34,8 @@ modes) and `packages/agent-docs/site/guide/app-extras/assistant-conversation.md`
34
34
  With the existing realtime diagnostic option (`JSKIT_REALTIME_DEBUG=1`, or
35
35
  `config.realtime.debug=true`), conversation subscriptions emit one `pending`
36
36
  warning if admission/snapshot reading has not returned within the client's
37
- 10-second acknowledgement budget. The warning names the current stage and the
37
+ 10-second budget, after which the client shows a loading notice and keeps waiting
38
+ (it gives up only after 120 seconds). The warning names the current stage and the
38
39
  server-generated epoch; a conversation identity is included only after its
39
40
  successful authorized open. Observer release also records the current stage and
40
41
  reason. While diagnostics are enabled, every stage transition is recorded at
package/locales/en.json CHANGED
@@ -130,7 +130,8 @@
130
130
  },
131
131
  "errors": {
132
132
  "assistant_history_inspection_required": "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
133
- "assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now."
133
+ "assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.",
134
+ "assistant_subscription_waiting": "Chat is still loading. Waiting for the server; Reload chat to try now."
134
135
  }
135
136
  }
136
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -13,12 +13,12 @@
13
13
  "./locales/en.json": "./locales/en.json"
14
14
  },
15
15
  "dependencies": {
16
- "@jskit-ai/assistant-core": "0.2.1",
16
+ "@jskit-ai/assistant-core": "0.2.2",
17
17
  "json-rest-schema": "^1.0.17"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "@jskit-ai/http-runtime": "0.2.0",
21
- "@jskit-ai/http-web": "0.2.0",
21
+ "@jskit-ai/http-web": "0.2.1",
22
22
  "@jskit-ai/kernel": "0.2.0",
23
23
  "@jskit-ai/realtime": "0.2.0",
24
24
  "@jskit-ai/shell-web": "0.2.0",
@@ -1009,10 +1009,15 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1009
1009
  } else current.draft.value = "";
1010
1010
  }
1011
1011
  onSubmit?.();
1012
+ const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
1013
+ : current.send(captured, { messageId, onAccepted: acknowledge });
1012
1014
  // A restored uncertain receipt may have no retained acceptance callback.
1013
- // Inspection can acknowledge it, but must never run preparation or Send.
1015
+ // Inspection can acknowledge it. Only the server's proof that the message
1016
+ // never reached the AI (which marks it failed) lets Retry send it again.
1014
1017
  if (retry?.status === "uncertain") return current.inspectDelivery(messageId).then(receipt => {
1015
1018
  if (!current.current.value || !isCurrent()) return false;
1019
+ if (receipt?.status === "not-sent" && receipt.messageId === messageId &&
1020
+ current.delivery.find(messageId)?.status === "failed") return dispatch();
1016
1021
  if (receipt?.status !== "accepted") {
1017
1022
  const error = current.error.value || t("jskit.assistant.runtime.delivery.notConfirmed");
1018
1023
  const pending = current.delivery.find(messageId);
@@ -1022,8 +1027,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
1022
1027
  acknowledge();
1023
1028
  return { ...receipt, ok: true };
1024
1029
  });
1025
- return prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
1026
- : current.send(captured, { messageId, onAccepted: acknowledge });
1030
+ return dispatch();
1027
1031
  }
1028
1032
  const adapter = computed(() => {
1029
1033
  const current = runtime.value;
@@ -16,6 +16,12 @@ function patchLoadedHistoryPages(pages = [], patch) {
16
16
  }
17
17
 
18
18
  const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
19
+ // A connected server may need longer than the normal budget to answer (an
20
+ // overloaded host took 15 s). Abandoning it then discards the server's work and
21
+ // repeats it, so a slow answer only shows a loading notice; it is treated as
22
+ // lost, unsubscribed and retried only after the much longer cap.
23
+ const SUBSCRIPTION_NOTICE_MS = 10_000;
24
+ const SUBSCRIPTION_LOST_MS = 120_000;
19
25
 
20
26
  function subscribeAssistantConversation({
21
27
  socket, conversationId, targetSurfaceId, hostSurfaceId, workspaceSlug,
@@ -44,6 +50,7 @@ function subscribeAssistantConversation({
44
50
  // otherwise only the next connect would recover the chat.
45
51
  let retryTimer = null;
46
52
  let retryDelay = 1_000;
53
+ let waitingTimer = null;
47
54
  const completed = new Map();
48
55
  const settledTurns = new Set();
49
56
  const pendingReads = new Set();
@@ -187,12 +194,14 @@ function subscribeAssistantConversation({
187
194
  if (epoch === null) pending.push(payload);
188
195
  else deliver(payload);
189
196
  }
190
- function clearRetry() {
197
+ function clearTimers() {
191
198
  clearTimeout(retryTimer);
192
199
  retryTimer = null;
200
+ clearTimeout(waitingTimer);
201
+ waitingTimer = null;
193
202
  }
194
203
  function subscribe({ preserveHistory = false } = {}) {
195
- clearRetry();
204
+ clearTimers();
196
205
  if (disposed || !socket.connected) return;
197
206
  const attempt = ++generation;
198
207
  epoch = null;
@@ -205,8 +214,12 @@ function subscribeAssistantConversation({
205
214
  settledTurns.clear();
206
215
  reloadInFlight = null;
207
216
  reloadQueued = false;
208
- socket.timeout(10_000).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
217
+ waitingTimer = setTimeout(() => onError(Object.assign(
218
+ new Error("Chat is still loading. Waiting for the server; Reload chat to try now."),
219
+ { code: "assistant_subscription_waiting" })), SUBSCRIPTION_NOTICE_MS);
220
+ socket.timeout(SUBSCRIPTION_LOST_MS).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
209
221
  if (disposed || attempt !== generation) return;
222
+ clearTimers();
210
223
  if (error || response?.ok !== true) {
211
224
  listening = false;
212
225
  pending = [];
@@ -232,7 +245,7 @@ function subscribeAssistantConversation({
232
245
  });
233
246
  }
234
247
  function disconnected() {
235
- clearRetry();
248
+ clearTimers();
236
249
  generation += 1;
237
250
  epoch = null;
238
251
  pending = [];
@@ -248,7 +261,7 @@ function subscribeAssistantConversation({
248
261
  function release() {
249
262
  if (disposed) return;
250
263
  disposed = true;
251
- clearRetry();
264
+ clearTimers();
252
265
  generation += 1;
253
266
  pending = [];
254
267
  completed.clear();
@@ -2311,6 +2311,34 @@ test("prepared binding preserves retry identity, newer composer input and inspec
2311
2311
  assert.deepEqual(await f.page.evaluate(() => window.taskClearedFiles), [["original-file"]]);
2312
2312
  });
2313
2313
 
2314
+ test("prepared Retry sends an uncertain message again only after the server proves it was never sent", options, async t => {
2315
+ const f = await fixture(t);
2316
+ await f.page.evaluate(() => {
2317
+ window.provenBinding = window.conversationFixture.acquireConversation({ conversationId: "chat:3", active: false });
2318
+ window.provenReader = window.conversationFixture.acquireConversation({ conversationId: "chat:3" });
2319
+ });
2320
+ await expect.poll(() => f.page.evaluate(() => window.provenReader.runtime.value.available.value)).toBe(true);
2321
+ await f.command("mode", { mode: "uncertain" });
2322
+ const uncertain = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Send me once." },
2323
+ { messageId: "proven-retry", prepare: captured => captured.request }));
2324
+ assert.equal(uncertain.status, "uncertain");
2325
+ assert.equal((await f.sent()).length, 1);
2326
+ // An unknown receipt still never resends.
2327
+ const unknown = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
2328
+ { retryMessageId: "proven-retry", prepare: captured => captured.request }));
2329
+ assert.equal(unknown.status, "uncertain");
2330
+ assert.equal((await f.sent()).length, 1);
2331
+ await f.command("mode", { mode: "not-sent" });
2332
+ const retried = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
2333
+ { retryMessageId: "proven-retry", prepare: captured => captured.request }));
2334
+ assert.equal(retried.ok, true);
2335
+ const sent = await f.sent();
2336
+ assert.equal(sent.length, 2);
2337
+ assert.equal(sent[1].input.messageId, "proven-retry");
2338
+ assert.equal(sent[1].input.text, "Send me once.");
2339
+ assert.equal(await f.page.evaluate(() => window.provenBinding.runtime.value.delivery.find("proven-retry")?.status ?? "accepted"), "accepted");
2340
+ });
2341
+
2314
2342
  test("acceptance settles a voice-retained conversation after its text view retargets without clearing the next composer", options, async t => {
2315
2343
  const f = await fixture(t, 1280, { acceptedDraft: true });
2316
2344
  await f.page.evaluate(() => window.conversationFixture.attachments(true));
@@ -329,45 +329,42 @@ test("slow successful subscriptions warn once with the blocked stage and no conv
329
329
  assert.equal(f.fetches.length, 0);
330
330
  });
331
331
 
332
- test("the real ten-second subscription deadline releases its late observer and Reload never repeats work", { timeout: 25_000 }, async t => {
332
+ 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 => {
333
333
  const f = await fixture(t);
334
334
  const socket = await f.connect();
335
335
  const socketId = socket.id;
336
336
  const id = f.ids[0];
337
- await f.conversations.get(id).send({ messageId: "survives-timeout", text: "Keep working through a slow snapshot" });
337
+ await f.conversations.get(id).send({ messageId: "survives-wait", text: "Keep working through a slow snapshot" });
338
338
  await until(f.changes, () => f.fetches.length === 1);
339
339
  const barrier = f.holdNextRead(id);
340
340
  const view = f.view(socket, id);
341
341
  await until(f.changes, () => barrier.started);
342
- const firstSubscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
343
- while (!view.errors.length || f.active() !== 0) {
344
- await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
345
- }
346
- assert.equal(view.errors[0].message, "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.");
347
- assert.equal(view.errors[0].cause.message, "operation has timed out");
342
+ const subscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
343
+ while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
344
+ assert.equal(view.errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
345
+ assert.equal(view.errors[0].code, "assistant_subscription_waiting");
346
+ assert.equal(f.active(), 1, "The slow observer stays attached while its snapshot is read");
348
347
  assert.equal(socket.id, socketId);
349
348
  assert.equal(socket.connected, true);
350
349
  assert.equal(view.states.length, 0);
351
- assert.equal(f.fetches[0].signal.aborted, false);
352
- view.dispose.reload();
353
- await until(f.changes, () => view.states.length === 1 && f.active() === 1);
354
- assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-timeout");
355
- const statesBeforeLateRead = view.states.length;
356
350
  barrier.resolve();
357
- await until(f.changes, () => f.subscriptionTimings.some(record =>
358
- record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned"));
359
- const abandoned = f.subscriptionTimings.find(record =>
360
- record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned");
361
- assert.equal(abandoned.level, "warn", "A timed-out snapshot remains visible at the production warning level");
362
- assert.equal(abandoned.previousStage, "snapshot-read");
363
- assert.ok(abandoned.durationMs >= 5_000);
364
- assert.equal(view.states.length, statesBeforeLateRead, "The obsolete server read never acknowledges into the recovered view");
365
- assert.equal(f.active(), 1, "The late read cannot release the recovered observer");
351
+ await until(f.changes, () => view.states.length === 1);
352
+ assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-wait");
353
+ const records = f.subscriptionTimings.filter(record => record.subscriptionEpoch === subscriptionEpoch);
354
+ assert.equal(records.some(record => record.stage === "abandoned"), false, "Slow work is never abandoned");
355
+ const acknowledged = records.find(record => record.stage === "acknowledgement");
356
+ assert.equal(acknowledged.level, "warn", "A slow snapshot remains visible at the production warning level");
357
+ assert.equal(acknowledged.previousStage, "snapshot-read");
358
+ assert.ok(acknowledged.elapsedMs >= 10_000);
359
+ assert.equal(f.subscriptionTimings.filter(record => record.stage === "authentication").length, 1,
360
+ "Waiting never sends another subscription");
361
+ assert.equal(view.errors.length, 1);
362
+ assert.equal(f.active(), 1);
366
363
  assert.equal(f.fetches.length, 1);
367
364
  assert.equal(f.fetches[0].signal.aborted, false);
368
365
  assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
369
- f.fetches[0].text("Still running after the timed-out observation.");
370
- await until(f.changes, () => view.received.some(event => event.text?.includes("timed-out observation")));
366
+ f.fetches[0].text("Still running after the slow snapshot.");
367
+ await until(f.changes, () => view.received.some(event => event.text?.includes("slow snapshot")));
371
368
  view.dispose();
372
369
  await until(f.changes, () => f.active() === 0);
373
370
  });
@@ -474,7 +471,7 @@ for (const failure of ["rejection", "timeout"]) {
474
471
  const requests = [], states = [], errors = [];
475
472
  let reads = 0;
476
473
  socket.timeout = timeout => ({ emit(name, input, acknowledge) {
477
- assert.equal(timeout, 10_000);
474
+ assert.equal(timeout, 120_000, "Only a much longer silence counts as a lost answer");
478
475
  assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
479
476
  requests.push({ input, acknowledge });
480
477
  } });
@@ -1076,6 +1073,134 @@ test("a retried resubscribe keeps the loaded history its caller preserved", t =>
1076
1073
  subscription();
1077
1074
  });
1078
1075
 
1076
+ // Socket.IO's acknowledgement deadline and disconnect handling, on mocked timers.
1077
+ function slowServerSocket(t) {
1078
+ t.mock.timers.enable({ apis: ["setTimeout"] });
1079
+ const socket = new EventEmitter();
1080
+ socket.connected = true;
1081
+ const subscribes = [], unsubscribes = [];
1082
+ socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribes.push(input));
1083
+ socket.timeout = milliseconds => ({ emit(name, input, acknowledge) {
1084
+ let settled = false;
1085
+ const settle = (...args) => {
1086
+ if (settled) return;
1087
+ settled = true;
1088
+ clearTimeout(timer);
1089
+ acknowledge(...args);
1090
+ };
1091
+ const timer = setTimeout(() => settle(new Error("operation has timed out")), milliseconds);
1092
+ subscribes.push({ name, input, answer: response => settle(null, response),
1093
+ drop: () => settle(new Error("socket has been disconnected")) });
1094
+ } });
1095
+ function disconnect() {
1096
+ socket.connected = false;
1097
+ socket.emit("disconnect");
1098
+ for (const request of subscribes) request.drop();
1099
+ }
1100
+ return { socket, subscribes, unsubscribes, disconnect };
1101
+ }
1102
+ const slowState = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
1103
+ function slowView(socket) {
1104
+ const states = [], errors = [];
1105
+ const subscription = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
1106
+ read: () => slowState, onState: state => states.push(state), onError: error => errors.push(error) });
1107
+ return { states, errors, subscription };
1108
+ }
1109
+
1110
+ test("a connected subscription keeps waiting for a slow server instead of abandoning its work at ten seconds", t => {
1111
+ // Production: an overloaded daemon answered after 14-15 s; the client gave
1112
+ // up at 10 s, unsubscribed and retried with 10 s again, so the chat never loaded.
1113
+ const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1114
+ const { states, errors, subscription } = slowView(socket);
1115
+ t.mock.timers.tick(9_999);
1116
+ assert.equal(errors.length, 0);
1117
+ t.mock.timers.tick(1);
1118
+ assert.equal(errors.length, 1, "Past the normal budget the user is told the chat is still loading");
1119
+ assert.equal(errors[0].code, "assistant_subscription_waiting");
1120
+ assert.equal(errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
1121
+ t.mock.timers.tick(5_000);
1122
+ subscribes[0].answer({ ok: true, streamEpoch: "slow", state: slowState });
1123
+ assert.equal(states.length, 1, "The fifteen-second answer is delivered");
1124
+ assert.equal(subscribes.length, 1, "Waiting never starts another subscription");
1125
+ assert.deepEqual(unsubscribes, [], "Waiting never abandons the server's work");
1126
+ t.mock.timers.tick(300_000);
1127
+ assert.equal(subscribes.length, 1);
1128
+ assert.equal(errors.length, 1);
1129
+ subscription();
1130
+ assert.equal(unsubscribes.length, 1, "Disposal still releases the observer");
1131
+ });
1132
+
1133
+ test("a subscription answer lost past the two-minute cap still unsubscribes and retries with backoff", t => {
1134
+ const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1135
+ const { states, errors, subscription } = slowView(socket);
1136
+ t.mock.timers.tick(119_999);
1137
+ assert.deepEqual(errors.map(error => error.code), ["assistant_subscription_waiting"]);
1138
+ assert.deepEqual(unsubscribes, []);
1139
+ t.mock.timers.tick(1);
1140
+ assert.deepEqual(unsubscribes, [{ subscriptionId: subscribes[0].input.subscriptionId }]);
1141
+ assert.equal(errors[1].code, "assistant_subscription_transport_failed");
1142
+ assert.equal(errors[1].cause.message, "operation has timed out");
1143
+ t.mock.timers.tick(999);
1144
+ assert.equal(subscribes.length, 1);
1145
+ t.mock.timers.tick(1);
1146
+ assert.equal(subscribes.length, 2, "The lost answer retries after the existing backoff");
1147
+ assert.deepEqual(subscribes[1].input, subscribes[0].input);
1148
+ t.mock.timers.tick(10_000);
1149
+ assert.equal(errors[2].code, "assistant_subscription_waiting", "Each slow attempt says it is still loading");
1150
+ subscribes[1].answer({ ok: true, streamEpoch: "recovered", state: slowState });
1151
+ assert.equal(states.length, 1);
1152
+ assert.equal(unsubscribes.length, 1);
1153
+ subscription();
1154
+ });
1155
+
1156
+ test("a disconnect while a slow subscription waits resets it and the next connect subscribes again", t => {
1157
+ const { socket, subscribes, unsubscribes, disconnect } = slowServerSocket(t);
1158
+ const { states, errors, subscription } = slowView(socket);
1159
+ t.mock.timers.tick(10_000);
1160
+ assert.equal(errors.length, 1);
1161
+ disconnect();
1162
+ t.mock.timers.tick(300_000);
1163
+ assert.equal(subscribes.length, 1, "A disconnected socket waits for its own reconnect");
1164
+ assert.deepEqual(unsubscribes, [], "There is no connection to unsubscribe on");
1165
+ assert.equal(errors.length, 1, "A disconnect reports no lost answer and no further notice");
1166
+ socket.connected = true;
1167
+ socket.emit("connect");
1168
+ assert.equal(subscribes.length, 2);
1169
+ assert.deepEqual(subscribes[1].input, subscribes[0].input);
1170
+ subscribes[1].answer({ ok: true, streamEpoch: "reconnected", state: slowState });
1171
+ assert.equal(states.length, 1);
1172
+ t.mock.timers.tick(300_000);
1173
+ assert.equal(errors.length, 1);
1174
+ subscription();
1175
+ });
1176
+
1177
+ test("Reload chat while a slow subscription waits reads history or resubscribes, ignoring the earlier late answer", async t => {
1178
+ const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1179
+ const { states, errors, subscription } = slowView(socket);
1180
+ t.mock.timers.tick(10_000);
1181
+ assert.equal(errors.length, 1);
1182
+ await subscription.reload();
1183
+ assert.equal(states.length, 1, "Reload reads canonical history while the subscription keeps waiting");
1184
+ assert.equal(subscribes.length, 1);
1185
+ subscription.reload({ resubscribe: true });
1186
+ assert.equal(subscribes.length, 2, "A manual resubscribe replaces the waiting attempt by its identity");
1187
+ assert.deepEqual(subscribes[1].input, subscribes[0].input);
1188
+ assert.deepEqual(unsubscribes, []);
1189
+ t.mock.timers.tick(5_000);
1190
+ subscribes[0].answer({ ok: true, streamEpoch: "replaced", state: slowState });
1191
+ assert.equal(states.length, 1, "The replaced attempt's late answer is ignored");
1192
+ assert.equal(errors.length, 1);
1193
+ t.mock.timers.tick(5_000);
1194
+ assert.equal(errors.length, 2, "The replacement attempt has its own loading notice");
1195
+ subscribes[1].answer({ ok: true, streamEpoch: "current", state: slowState });
1196
+ assert.equal(states.length, 2);
1197
+ t.mock.timers.tick(300_000);
1198
+ assert.equal(errors.length, 2);
1199
+ assert.equal(subscribes.length, 2);
1200
+ assert.deepEqual(unsubscribes, []);
1201
+ subscription();
1202
+ });
1203
+
1079
1204
  for (const status of [401, 403, 503]) {
1080
1205
  test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
1081
1206
  const changes = new EventEmitter();