@jskit-ai/assistant-runtime 0.1.237 → 0.1.238

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.237",
3
+ "version": "0.1.238",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -12,19 +12,19 @@
12
12
  "./server": "./src/server/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/assistant-core": "0.1.243",
15
+ "@jskit-ai/assistant-core": "0.1.244",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.245",
20
- "@jskit-ai/http-web": "0.1.95",
21
- "@jskit-ai/kernel": "0.1.247",
22
- "@jskit-ai/realtime": "0.1.247",
23
- "@jskit-ai/shell-web": "0.1.254",
19
+ "@jskit-ai/http-runtime": "0.1.246",
20
+ "@jskit-ai/http-web": "0.1.96",
21
+ "@jskit-ai/kernel": "0.1.248",
22
+ "@jskit-ai/realtime": "0.1.248",
23
+ "@jskit-ai/shell-web": "0.1.255",
24
24
  "@tanstack/vue-query": "^5.90.5",
25
25
  "vue": "^3.5.13",
26
26
  "vuetify": "^4.0.0",
27
- "@jskit-ai/database-runtime": "0.1.247"
27
+ "@jskit-ai/database-runtime": "0.1.248"
28
28
  },
29
29
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
30
30
  "jskit": {
@@ -40,6 +40,10 @@ function subscribeAssistantConversation({
40
40
  let streaming = null;
41
41
  let reloadInFlight = null;
42
42
  let reloadQueued = false;
43
+ // A transient subscription failure retries while the socket stays connected;
44
+ // otherwise only the next connect would recover the chat.
45
+ let retryTimer = null;
46
+ let retryDelay = 1_000;
43
47
  const completed = new Map();
44
48
  const settledTurns = new Set();
45
49
  const pendingReads = new Set();
@@ -183,7 +187,12 @@ function subscribeAssistantConversation({
183
187
  if (epoch === null) pending.push(payload);
184
188
  else deliver(payload);
185
189
  }
190
+ function clearRetry() {
191
+ clearTimeout(retryTimer);
192
+ retryTimer = null;
193
+ }
186
194
  function subscribe({ preserveHistory = false } = {}) {
195
+ clearRetry();
187
196
  if (disposed || !socket.connected) return;
188
197
  const attempt = ++generation;
189
198
  epoch = null;
@@ -202,13 +211,18 @@ function subscribeAssistantConversation({
202
211
  listening = false;
203
212
  pending = [];
204
213
  if (socket.connected) socket.emit(ASSISTANT_CONVERSATION_UNSUBSCRIBE, { subscriptionId });
205
- onError(error ? Object.assign(new Error("Chat updates could not reconnect. Reload chat to try again.", { cause: error }), {
214
+ if (error || response?.status >= 500) {
215
+ retryTimer = setTimeout(() => subscribe({ preserveHistory }), retryDelay);
216
+ retryDelay = Math.min(retryDelay * 2, 30_000);
217
+ }
218
+ onError(error ? Object.assign(new Error("Chat updates could not reconnect. Retrying automatically; Reload chat to try now.", { cause: error }), {
206
219
  code: "assistant_subscription_transport_failed"
207
220
  }) : Object.assign(new Error(response?.error || "Conversation subscription failed."), {
208
221
  code: response?.code, statusCode: response?.status
209
222
  }));
210
223
  return;
211
224
  }
225
+ retryDelay = 1_000;
212
226
  epoch = response.streamEpoch;
213
227
  receiveState(response.state, { initial: !preserveHistory, canonical: !preserveHistory });
214
228
  for (const payload of pending.sort((left, right) => left.streamRevision - right.streamRevision)) deliver(payload);
@@ -216,6 +230,7 @@ function subscribeAssistantConversation({
216
230
  });
217
231
  }
218
232
  function disconnected() {
233
+ clearRetry();
219
234
  generation += 1;
220
235
  epoch = null;
221
236
  pending = [];
@@ -231,6 +246,7 @@ function subscribeAssistantConversation({
231
246
  function release() {
232
247
  if (disposed) return;
233
248
  disposed = true;
249
+ clearRetry();
234
250
  generation += 1;
235
251
  pending = [];
236
252
  completed.clear();
@@ -343,7 +343,7 @@ test("the real ten-second subscription deadline releases its late observer and R
343
343
  while (!view.errors.length || f.active() !== 0) {
344
344
  await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
345
345
  }
346
- assert.equal(view.errors[0].message, "Chat updates could not reconnect. Reload chat to try again.");
346
+ assert.equal(view.errors[0].message, "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.");
347
347
  assert.equal(view.errors[0].cause.message, "operation has timed out");
348
348
  assert.equal(socket.id, socketId);
349
349
  assert.equal(socket.connected, true);
@@ -1012,6 +1012,70 @@ test("host subscription policy retains authenticated actor metadata and per-even
1012
1012
  assert.ok(cookies.every(value => value === "host-session"));
1013
1013
  });
1014
1014
 
1015
+ test("a transient subscription failure retries by itself while connected; denials never retry", t => {
1016
+ // Production: after a laptop woke, the resubscribe acknowledgement missed its
1017
+ // deadline while the socket stayed connected, and the chat stayed stuck.
1018
+ t.mock.timers.enable({ apis: ["setTimeout"] });
1019
+ const socket = new EventEmitter();
1020
+ socket.connected = true;
1021
+ const requests = [], states = [], errors = [];
1022
+ socket.timeout = () => ({ emit(name, input, acknowledge) { requests.push({ input, acknowledge }); } });
1023
+ const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
1024
+ read() {}, onState: state => states.push(state), onError: error => errors.push(error) });
1025
+ const state = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
1026
+ requests[0].acknowledge(new Error("operation has timed out"));
1027
+ assert.match(errors[0].message, /Retrying automatically/u);
1028
+ t.mock.timers.tick(999);
1029
+ assert.equal(requests.length, 1);
1030
+ t.mock.timers.tick(1);
1031
+ assert.equal(requests.length, 2);
1032
+ assert.deepEqual(requests[1].input, requests[0].input);
1033
+ requests[1].acknowledge(null, { ok: false, error: "Retry shortly.", status: 503 });
1034
+ t.mock.timers.tick(1_999);
1035
+ assert.equal(requests.length, 2, "Each retry waits twice as long");
1036
+ t.mock.timers.tick(1);
1037
+ assert.equal(requests.length, 3);
1038
+ requests[2].acknowledge(null, { ok: true, streamEpoch: "recovered", state });
1039
+ assert.equal(states.length, 1);
1040
+ // A reconnect replaces a pending retry, and success restarts the backoff.
1041
+ socket.emit("disconnect");
1042
+ socket.emit("connect");
1043
+ requests[3].acknowledge(new Error("operation has timed out"));
1044
+ socket.connected = false;
1045
+ socket.emit("disconnect");
1046
+ t.mock.timers.tick(60_000);
1047
+ assert.equal(requests.length, 4, "A disconnected socket waits for its own reconnect");
1048
+ socket.connected = true;
1049
+ socket.emit("connect");
1050
+ requests[4].acknowledge(null, { ok: false, error: "Access denied.", status: 403 });
1051
+ t.mock.timers.tick(60_000);
1052
+ assert.equal(requests.length, 5, "A denial never retries");
1053
+ dispose.reload();
1054
+ requests[5].acknowledge(new Error("operation has timed out"));
1055
+ t.mock.timers.tick(999);
1056
+ dispose();
1057
+ t.mock.timers.tick(60_000);
1058
+ assert.equal(requests.length, 6, "Disposal cancels the pending retry");
1059
+ });
1060
+
1061
+ test("a retried resubscribe keeps the loaded history its caller preserved", t => {
1062
+ t.mock.timers.enable({ apis: ["setTimeout"] });
1063
+ const socket = new EventEmitter();
1064
+ socket.connected = true;
1065
+ const requests = [], publications = [];
1066
+ socket.timeout = () => ({ emit(name, input, acknowledge) { requests.push(acknowledge); } });
1067
+ const subscription = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
1068
+ read() {}, onState: (state, options) => publications.push(options) });
1069
+ const state = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
1070
+ requests[0](null, { ok: true, streamEpoch: "first", state });
1071
+ subscription.reload({ resubscribe: true });
1072
+ requests[1](new Error("operation has timed out"));
1073
+ t.mock.timers.tick(1_000);
1074
+ requests[2](null, { ok: true, streamEpoch: "second", state });
1075
+ assert.deepEqual(publications, [{ initial: true, canonical: true }, { initial: false, canonical: false }]);
1076
+ subscription();
1077
+ });
1078
+
1015
1079
  for (const status of [401, 403, 503]) {
1016
1080
  test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
1017
1081
  const changes = new EventEmitter();