@jskit-ai/assistant-runtime 0.2.15 → 0.2.17

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.2.15",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -14,19 +14,19 @@
14
14
  "./locales/en.json": "./locales/en.json"
15
15
  },
16
16
  "dependencies": {
17
- "@jskit-ai/assistant-core": "0.2.13",
17
+ "@jskit-ai/assistant-core": "0.2.15",
18
18
  "json-rest-schema": "^1.0.17"
19
19
  },
20
20
  "peerDependencies": {
21
- "@jskit-ai/http-runtime": "0.2.5",
22
- "@jskit-ai/http-web": "0.2.11",
23
- "@jskit-ai/kernel": "0.2.5",
24
- "@jskit-ai/realtime": "0.2.9",
25
- "@jskit-ai/shell-web": "0.2.6",
21
+ "@jskit-ai/http-runtime": "0.2.6",
22
+ "@jskit-ai/http-web": "0.2.12",
23
+ "@jskit-ai/kernel": "0.2.6",
24
+ "@jskit-ai/realtime": "0.2.10",
25
+ "@jskit-ai/shell-web": "0.2.7",
26
26
  "@tanstack/vue-query": "^5.90.5",
27
27
  "vue": "^3.5.13",
28
28
  "vuetify": "^4.0.0",
29
- "@jskit-ai/database-runtime": "0.1.254"
29
+ "@jskit-ai/database-runtime": "0.1.255"
30
30
  },
31
31
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
32
32
  "jskit": {
@@ -1,6 +1,6 @@
1
1
  import { computed, onScopeDispose, ref, shallowRef, toValue, watch } from "vue";
2
2
  import { toText } from "@jskit-ai/kernel/shared/support/normalize";
3
- import { createAssistantMessageDelivery, unmatchedOptimisticMessages } from "@jskit-ai/assistant-core/client/conversation-delivery";
3
+ import { createAssistantMessageDelivery, draftWithRestoredMessage, unmatchedOptimisticMessages } from "@jskit-ai/assistant-core/client/conversation-delivery";
4
4
  import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversation-questions";
5
5
  import { latestAssistantMessageAwaitingUserReply } from "@jskit-ai/assistant-core/shared/conversation";
6
6
  import { failureMessage, isAccessDenied, resultError } from "../support/conversationFailures.js";
@@ -124,6 +124,8 @@ function createConversationRuntime({ identity, api, socket, actorKey, placement,
124
124
  return Boolean(retry && submitted && (current === submitted || current.startsWith(submitted)));
125
125
  });
126
126
  let draftSubmissionSequence = 0;
127
+ // The draft each submitted draft message came from, until its send settles.
128
+ const submittedDrafts = new Map();
127
129
  const error = ref("");
128
130
  const loading = ref(true);
129
131
  const stopping = ref(false);
@@ -290,6 +292,18 @@ function createConversationRuntime({ identity, api, socket, actorKey, placement,
290
292
  questions.reset();
291
293
  }, { flush: "sync" });
292
294
  onScopeDispose(() => {
295
+ // A draft message not yet dispatched can no longer be sent: its draft
296
+ // returns to the saved draft, ahead of any text typed after it. A
297
+ // dispatched one stays saved and is restored as unconfirmed.
298
+ const unsent = [...pendingMessages].filter(([messageId, pending]) => !pending.dispatched && submittedDrafts.has(messageId));
299
+ if (unsent.length && current.value && !accessDenied.value) {
300
+ for (const [messageId] of unsent.reverse()) {
301
+ draft.value = draftWithRestoredMessage(submittedDrafts.get(messageId), draft.value);
302
+ delivery.remove(messageId);
303
+ }
304
+ // The scope's watchers have stopped, so the draft is saved here.
305
+ persistDraft();
306
+ }
293
307
  disposed.value = true;
294
308
  subscription();
295
309
  stopPlacement();
@@ -349,10 +363,17 @@ function createConversationRuntime({ identity, api, socket, actorKey, placement,
349
363
  const sequence = ++draftSubmissionSequence;
350
364
  draftRetry.value = null;
351
365
  draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
352
- const result = await send(captured, { messageId: id, onAccepted });
366
+ submittedDrafts.set(id, draftSnapshot);
367
+ let result;
368
+ try { result = await send(captured, { messageId: id, onAccepted }); }
369
+ finally { submittedDrafts.delete(id); }
353
370
  if (!current.value) return false;
354
371
  const accepted = result !== false && result?.ok !== false;
355
- if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
372
+ const undelivered = !accepted && sequence === draftSubmissionSequence && !draft.value;
373
+ // No undelivered draft is lost: a failed or unconfirmed message keeps its
374
+ // words in the chat with Retry, and one never recorded returns to the draft.
375
+ if (undelivered && kind !== "steer" && !delivery.find(id) && !accessDenied.value) draft.value = draftSnapshot;
376
+ if (undelivered && kind === "steer") {
356
377
  draft.value = draftSnapshot;
357
378
  const optimistic = delivery.find(id) || {
358
379
  createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
@@ -251,9 +251,13 @@ function subscribeAssistantConversation({
251
251
  retryTimer = setTimeout(() => subscribe({ preserveHistory }), retryDelay);
252
252
  retryDelay = Math.min(retryDelay * 2, 30_000);
253
253
  }
254
- onError(error ? Object.assign(new Error("Chat updates could not reconnect. Retrying automatically; Reload chat to try now.", { cause: error }), {
255
- code: "assistant_subscription_transport_failed"
256
- }) : Object.assign(new Error(response?.error || "Conversation subscription failed."), {
254
+ if (error) {
255
+ onError(Object.assign(new Error("Chat updates could not reconnect. Retrying automatically; Reload chat to try now.", { cause: error }), {
256
+ code: "assistant_subscription_transport_failed"
257
+ }));
258
+ return;
259
+ }
260
+ onError(Object.assign(new Error(response?.error || "Conversation subscription failed."), {
257
261
  code: response?.code, statusCode: response?.status, params: response?.params,
258
262
  ...(response?.error ? { messageCode: response.messageCode, messageParams: response.messageParams }
259
263
  : { messageCode: "assistant.runtime.subscription.failed" })
@@ -0,0 +1,109 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { effectScope } from "vue";
5
+ import { createConversationRuntime } from "../src/client/composables/conversationRuntime.js";
6
+
7
+ function memoryStorage() {
8
+ const values = new Map();
9
+ return {
10
+ getItem: key => values.get(key) ?? null,
11
+ setItem: (key, value) => values.set(key, String(value)),
12
+ removeItem: key => values.delete(key)
13
+ };
14
+ }
15
+
16
+ // A socket that answers each subscription with the conversation's current state.
17
+ function answeringSocket(read) {
18
+ const socket = {
19
+ connected: true,
20
+ on() {},
21
+ off() {},
22
+ timeout() { return socket; },
23
+ emit(name, _input, acknowledge) {
24
+ if (name === "assistant.conversation.subscribe") acknowledge(null, { ok: true, streamEpoch: "epoch", state: read() });
25
+ }
26
+ };
27
+ return socket;
28
+ }
29
+
30
+ // One retained conversation over its draft storage. Its sends never answer,
31
+ // so a dispatched message stays in flight.
32
+ async function openConversation(t, { status = "ready", storage = memoryStorage(), deferWhileWorking = false } = {}) {
33
+ const state = () => ({ id: "chat:1", status, capabilities: { steering: false, attachments: true }, conversationLog: [] });
34
+ const sent = [];
35
+ const scope = effectScope();
36
+ t.after(() => scope.stop());
37
+ const runtime = scope.run(() => createConversationRuntime({
38
+ identity: { conversationId: "chat:1", targetSurfaceId: "home", hostSurfaceId: "home", workspaceSlug: "", actorKey: "42" },
39
+ api: {
40
+ async readConversation() { return state(); },
41
+ sendConversationMessage(_id, input) { sent.push(input); return new Promise(() => {}); }
42
+ },
43
+ socket: answeringSocket(state),
44
+ actorKey: "42",
45
+ placement: { subscribe: () => () => {}, getContext: () => ({}) },
46
+ readers: new Map([[Symbol("view"), { active: true }]]),
47
+ retain: () => ({}),
48
+ queueWhileSending: undefined,
49
+ deferWhileWorking,
50
+ admitWhileWorking: false,
51
+ draftStorage: { storage, key: "chat:1:draft" },
52
+ application: null,
53
+ goalReadEnabled: false
54
+ }));
55
+ for (let attempt = 0; attempt < 50 && !runtime.available.value; attempt += 1) await delay(1);
56
+ assert.equal(runtime.available.value, true, "the conversation is ready for a draft");
57
+ return { runtime, scope, sent, storage };
58
+ }
59
+
60
+ const saved = storage => JSON.parse(storage.getItem("chat:1:draft") || "{}");
61
+
62
+ test("a draft whose send ends before any delivery record goes back to the draft", async t => {
63
+ const { runtime, sent } = await openConversation(t);
64
+ runtime.draft.value = "First words";
65
+ void runtime.submitDraft({ message: "First words" });
66
+ await delay(1);
67
+ assert.equal(sent.length, 1, "the first message is in flight");
68
+ // A binding that neither queues nor steers refuses a second send while the first is in flight.
69
+ runtime.draft.value = "Second words";
70
+ assert.equal(await runtime.submitDraft({ message: "Second words" }), false);
71
+ assert.equal(runtime.draft.value, "Second words", "the refused draft is given back");
72
+ assert.deepEqual(runtime.delivery.state.messages.map(message => message.text), ["First words"]);
73
+ assert.equal(sent.length, 1);
74
+ });
75
+
76
+ test("a deferred draft message not yet sent returns to the saved draft when its conversation is released", async t => {
77
+ const storage = memoryStorage();
78
+ const first = await openConversation(t, { status: "working", deferWhileWorking: true, storage });
79
+ first.runtime.draft.value = "Follow-up words";
80
+ void first.runtime.submitDraft({ message: "Follow-up words" });
81
+ await delay(1);
82
+ assert.equal(first.runtime.delivery.state.messages[0]?.status, "pending", "the follow-up waits for the working turn");
83
+ first.runtime.draft.value = "Typed later";
84
+ first.scope.stop();
85
+ assert.equal(first.sent.length, 0, "the waiting follow-up was never dispatched");
86
+ assert.equal(saved(storage).draft, "Follow-up words\n\nTyped later");
87
+ assert.deepEqual(saved(storage).messages, []);
88
+
89
+ const reopened = await openConversation(t, { storage });
90
+ assert.equal(reopened.runtime.draft.value, "Follow-up words\n\nTyped later");
91
+ assert.deepEqual(reopened.runtime.delivery.state.messages, [], "it is not restored as an unconfirmed send");
92
+ assert.equal(reopened.sent.length, 0);
93
+ });
94
+
95
+ test("a dispatched draft message stays saved as unconfirmed when its conversation is released", async t => {
96
+ const storage = memoryStorage();
97
+ const first = await openConversation(t, { storage });
98
+ first.runtime.draft.value = "Sent words";
99
+ void first.runtime.submitDraft({ message: "Sent words" });
100
+ await delay(1);
101
+ assert.equal(first.sent.length, 1);
102
+ first.scope.stop();
103
+ assert.equal(saved(storage).draft, "");
104
+
105
+ const reopened = await openConversation(t, { storage });
106
+ assert.equal(reopened.runtime.draft.value, "", "a possibly received message never returns to the draft");
107
+ assert.deepEqual(reopened.runtime.delivery.state.messages.map(message => [message.text, message.status]),
108
+ [["Sent words", "uncertain"]]);
109
+ });