@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.
|
|
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.
|
|
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.
|
|
22
|
-
"@jskit-ai/http-web": "0.2.
|
|
23
|
-
"@jskit-ai/kernel": "0.2.
|
|
24
|
-
"@jskit-ai/realtime": "0.2.
|
|
25
|
-
"@jskit-ai/shell-web": "0.2.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
+
});
|