@jskit-ai/assistant-runtime 0.1.197 → 0.1.198
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.
|
|
3
|
+
"version": "0.1.198",
|
|
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.
|
|
15
|
+
"@jskit-ai/assistant-core": "0.1.205",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/realtime": "0.1.
|
|
23
|
-
"@jskit-ai/shell-web": "0.1.
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.225",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.72",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.227",
|
|
22
|
+
"@jskit-ai/realtime": "0.1.224",
|
|
23
|
+
"@jskit-ai/shell-web": "0.1.231",
|
|
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.
|
|
27
|
+
"@jskit-ai/database-runtime": "0.1.227"
|
|
28
28
|
},
|
|
29
29
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
30
30
|
"jskit": {
|
|
@@ -31,7 +31,7 @@ function configureAssistantConversations(app, { actorKey, api = null, request, c
|
|
|
31
31
|
// occur after dispatch and must retain an uncertain receipt.
|
|
32
32
|
const SEND_REJECTIONS = new Set([
|
|
33
33
|
"ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
|
|
34
|
-
"conversation_invalid_message", "conversation_busy", "conversation_invalid_goal", "conversation_goal_changed"
|
|
34
|
+
"conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed"
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
37
|
function isQuestionConfiguration(value) {
|
|
@@ -46,7 +46,7 @@ function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
|
|
|
46
46
|
return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, draftStorage, application, goalReadEnabled }) {
|
|
49
|
+
function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled }) {
|
|
50
50
|
const disposed = ref(false);
|
|
51
51
|
const placementRevision = ref(0);
|
|
52
52
|
const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
|
|
@@ -90,8 +90,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
90
90
|
const editable = computed(() => active.value && !accessDenied.value);
|
|
91
91
|
const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
|
|
92
92
|
const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
|
|
93
|
-
const queueing = computed(() => queueWhileSending !== false && steerable.value);
|
|
94
|
-
const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value) &&
|
|
93
|
+
const queueing = computed(() => queueWhileSending !== false && (steerable.value || deferWhileWorking));
|
|
94
|
+
const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || deferWhileWorking && snapshot.value.status === "working") &&
|
|
95
95
|
(queueWhileSending !== false || !delivery.state.sending) &&
|
|
96
96
|
!delivery.state.messages.some(message => message.status === "uncertain"));
|
|
97
97
|
const savedDraft = toValue(draftStorage);
|
|
@@ -109,9 +109,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
109
109
|
const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
|
|
110
110
|
typeof message?.id === "string" && typeof message.text === "string" &&
|
|
111
111
|
typeof message.payload?.message === "string") : [];
|
|
112
|
-
const restoredMessages = messages.filter(message => !delivery.find(message.id))
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
|
|
113
|
+
const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
|
|
114
|
+
return { ...message, status, checking: false, error: message.error || (status === "accepted" ? "" :
|
|
115
|
+
"Delivery was not confirmed before this page closed. Check the conversation before retrying.") };
|
|
116
|
+
});
|
|
115
117
|
delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
|
|
116
118
|
} catch {
|
|
117
119
|
draft.value = "";
|
|
@@ -232,6 +234,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
232
234
|
}, { immediate: true, flush: "sync" });
|
|
233
235
|
watch(current, value => {
|
|
234
236
|
if (value) return;
|
|
237
|
+
for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
|
|
235
238
|
delivery.reset();
|
|
236
239
|
snapshot.value = null;
|
|
237
240
|
olderPages.value = [];
|
|
@@ -251,7 +254,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
251
254
|
disposed.value = true;
|
|
252
255
|
subscription?.();
|
|
253
256
|
stopPlacement();
|
|
254
|
-
for (const
|
|
257
|
+
for (const pending of pendingMessages.values()) pending.controller.abort();
|
|
255
258
|
pendingMessages.clear();
|
|
256
259
|
goalReadController?.abort();
|
|
257
260
|
globalThis.removeEventListener?.("focus", refreshVisibleGoal);
|
|
@@ -268,7 +271,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
268
271
|
const requestedSteering = request?.steer === true;
|
|
269
272
|
if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
|
|
270
273
|
const steering = requestedSteering || steerable.value;
|
|
271
|
-
|
|
274
|
+
const deferred = deferWhileWorking && !retry && !steering;
|
|
275
|
+
if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred) return false;
|
|
272
276
|
if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
|
|
273
277
|
const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request || {
|
|
274
278
|
text: String(payload.message || ""),
|
|
@@ -276,7 +280,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
276
280
|
...(payload.displayAttachments?.length ? { attachmentIds: payload.displayAttachments.map(file => file.attachmentId) } : {}),
|
|
277
281
|
...(steerable.value ? { steer: true } : {})
|
|
278
282
|
} };
|
|
279
|
-
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && steering });
|
|
283
|
+
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred), deferred });
|
|
280
284
|
}
|
|
281
285
|
function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
|
|
282
286
|
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
@@ -315,16 +319,50 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
315
319
|
}
|
|
316
320
|
return result;
|
|
317
321
|
}
|
|
318
|
-
|
|
322
|
+
function waitUntilReady({ controller }) {
|
|
323
|
+
return new Promise(resolve => {
|
|
324
|
+
let stop;
|
|
325
|
+
const finish = ready => {
|
|
326
|
+
stop?.();
|
|
327
|
+
controller.signal.removeEventListener("abort", check);
|
|
328
|
+
resolve(ready);
|
|
329
|
+
};
|
|
330
|
+
const check = () => {
|
|
331
|
+
if (controller.signal.aborted || !available.value) finish(false);
|
|
332
|
+
else if (snapshot.value.status === "ready") finish(true);
|
|
333
|
+
else if (snapshot.value.status !== "working") finish(false);
|
|
334
|
+
};
|
|
335
|
+
stop = watch([available, snapshot], check, { flush: "sync" });
|
|
336
|
+
controller.signal.addEventListener("abort", check, { once: true });
|
|
337
|
+
check();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false }) {
|
|
342
|
+
if (pendingMessages.has(messageId)) return false;
|
|
343
|
+
// Register before the original serial tail so Stop/retirement also reaches
|
|
344
|
+
// a local follower whose deliver callback has not started yet.
|
|
345
|
+
const pending = { controller: new AbortController(), started: false, dispatched: false, deferred };
|
|
346
|
+
const { controller } = pending;
|
|
347
|
+
pendingMessages.set(messageId, pending);
|
|
319
348
|
try {
|
|
320
349
|
const result = await delivery.send(payload, {
|
|
321
350
|
messageId, queue, isCurrent: () => current.value, onAccepted,
|
|
322
351
|
receiptTurns: turns, uncertainOnError: true,
|
|
323
352
|
async deliver(submission) {
|
|
324
|
-
|
|
325
|
-
pendingMessages.set(messageId, controller);
|
|
326
|
-
let dispatched = false;
|
|
353
|
+
pending.started = true;
|
|
327
354
|
try {
|
|
355
|
+
if (controller.signal.aborted) return false;
|
|
356
|
+
if (deferred) {
|
|
357
|
+
// Refresh through the same subscription after a queued predecessor;
|
|
358
|
+
// its receipt may have arrived before the working snapshot.
|
|
359
|
+
await subscription?.reload();
|
|
360
|
+
if (error.value || !await waitUntilReady(pending)) return false;
|
|
361
|
+
if (controller.signal.aborted || !available.value || snapshot.value.status !== "ready") return false;
|
|
362
|
+
if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
|
|
363
|
+
return { ok: false, error: "This conversation no longer accepts attachments." };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
328
366
|
const request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
|
|
329
367
|
if (prepare) {
|
|
330
368
|
if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
|
|
@@ -332,20 +370,20 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
332
370
|
throw new TypeError("Application preparation must return its conversation request or false.");
|
|
333
371
|
}
|
|
334
372
|
}
|
|
335
|
-
dispatched = true;
|
|
373
|
+
pending.dispatched = true;
|
|
336
374
|
return await (submission.goalRequest
|
|
337
375
|
? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
|
|
338
376
|
: api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
|
|
339
377
|
}
|
|
340
378
|
catch (failure) {
|
|
341
379
|
if (controller.signal.aborted) return false;
|
|
342
|
-
if (prepare && !dispatched) return { ok: false, error: failure.message,
|
|
380
|
+
if (prepare && !pending.dispatched) return { ok: false, error: failure.message,
|
|
343
381
|
...(failure.code ? { code: failure.code } : {}) };
|
|
344
382
|
if (SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
|
|
345
383
|
...(prepare && failure.code ? { code: failure.code } : {}) };
|
|
346
384
|
throw failure;
|
|
347
385
|
} finally {
|
|
348
|
-
if (pendingMessages.get(messageId) ===
|
|
386
|
+
if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
349
387
|
}
|
|
350
388
|
}
|
|
351
389
|
});
|
|
@@ -355,13 +393,15 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
355
393
|
if (prepare) return { ok: false, status: "uncertain", error: failure.message,
|
|
356
394
|
...(failure.code ? { code: failure.code } : {}) };
|
|
357
395
|
return false;
|
|
358
|
-
} finally {
|
|
396
|
+
} finally {
|
|
397
|
+
if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
398
|
+
if (current.value) subscription?.reload();
|
|
399
|
+
}
|
|
359
400
|
}
|
|
360
401
|
function cancelMessage(messageId) {
|
|
361
|
-
const
|
|
362
|
-
if (!
|
|
363
|
-
controller.abort();
|
|
364
|
-
pendingMessages.delete(messageId);
|
|
402
|
+
const pending = pendingMessages.get(messageId);
|
|
403
|
+
if (!pending) return false;
|
|
404
|
+
pending.controller.abort();
|
|
365
405
|
return true;
|
|
366
406
|
}
|
|
367
407
|
|
|
@@ -515,6 +555,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
515
555
|
async function cancel() {
|
|
516
556
|
if (!editable.value || stopping.value) return false;
|
|
517
557
|
stopping.value = true;
|
|
558
|
+
for (const pending of pendingMessages.values()) {
|
|
559
|
+
if (pending.deferred && !pending.dispatched) pending.controller.abort();
|
|
560
|
+
}
|
|
518
561
|
try { return await api.cancelConversation(identity.conversationId); }
|
|
519
562
|
catch (failure) { receiveError(failure); return false; }
|
|
520
563
|
finally { stopping.value = false; if (current.value) subscription?.reload(); }
|
|
@@ -610,7 +653,7 @@ function conversationBindingSetup({ socket, boundedTask = null } = {}) {
|
|
|
610
653
|
|
|
611
654
|
function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
|
|
612
655
|
actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
|
|
613
|
-
clearDraftOn: suppliedClearDraftOn, queueWhileSending, draftWhileLoading = false,
|
|
656
|
+
clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, draftWhileLoading = false,
|
|
614
657
|
draftStorage = null, application = null, boundedTask = null,
|
|
615
658
|
data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
|
|
616
659
|
const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
|
|
@@ -622,6 +665,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
622
665
|
});
|
|
623
666
|
if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
|
|
624
667
|
if (queueWhileSending !== undefined && typeof queueWhileSending !== "boolean") throw new TypeError("queueWhileSending must be a boolean.");
|
|
668
|
+
if (typeof deferWhileWorking !== "boolean") throw new TypeError("deferWhileWorking must be a boolean.");
|
|
625
669
|
if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
|
|
626
670
|
if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
|
|
627
671
|
if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
|
|
@@ -679,7 +723,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
679
723
|
const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
|
|
680
724
|
target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
|
|
681
725
|
retained = retainAssistantConversation(app, key, readers => createConversation(target, {
|
|
682
|
-
readers, socket, actorKey, placement, queueWhileSending, draftStorage, application, goalReadEnabled: toValue(goal) === true,
|
|
726
|
+
readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
|
|
683
727
|
api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
|
|
684
728
|
resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
|
|
685
729
|
}), { active, questions, goal, onEvent });
|
|
@@ -601,12 +601,140 @@ test("canonical receipts settle a held response and uncertain delivery stays ins
|
|
|
601
601
|
await expect(f.primary.getByText("Keep this exact uncertain request", { exact: true })).toHaveCount(0);
|
|
602
602
|
});
|
|
603
603
|
|
|
604
|
-
|
|
604
|
+
for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognised"]) {
|
|
605
|
+
test(`saved delivery preserves its actual admission fact through page reload: ${status}`, options, async t => {
|
|
606
|
+
const f = await fixture(t);
|
|
607
|
+
const messageId = `reload-${status}`;
|
|
608
|
+
const attach = async () => {
|
|
609
|
+
await f.page.evaluate(() => {
|
|
610
|
+
window.savedBinding = window.conversationFixture.acquireConversation({
|
|
611
|
+
conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "original-delivery-reload" }
|
|
612
|
+
});
|
|
613
|
+
window.conversationFixture.target("chat:2");
|
|
614
|
+
});
|
|
615
|
+
await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
|
|
616
|
+
};
|
|
617
|
+
await attach();
|
|
618
|
+
await f.command("mode", { mode: status === "accepted" ? "product-native-duplicate"
|
|
619
|
+
: status === "failed" ? "rejected" : status === "pending" ? "hold" : "uncertain" });
|
|
620
|
+
await f.page.evaluate(messageId => {
|
|
621
|
+
const runtime = window.savedBinding.runtime.value;
|
|
622
|
+
runtime.draft.value = "Keep my separate newer draft";
|
|
623
|
+
window.savedSubmission = runtime.send({ message: "Exact original saved words",
|
|
624
|
+
data: { clientId: "original-client", focus: { project: "original-project" } } }, { messageId });
|
|
625
|
+
}, messageId);
|
|
626
|
+
await expect.poll(async () => (await f.sent()).length).toBe(1);
|
|
627
|
+
if (status !== "pending") await f.page.evaluate(() => window.savedSubmission);
|
|
628
|
+
const unconfirmed = ["uncertain", "pending", "unrecognised"].includes(status);
|
|
629
|
+
await f.command("question", { id: "chat:2", text: "An unrelated completed answer.", pending: false });
|
|
630
|
+
if (unconfirmed) {
|
|
631
|
+
await f.command("history", { id: "chat:2", limit: 20, turns: [{ turnId: messageId,
|
|
632
|
+
user: { messageId, role: "user", text: "Exact original saved words", receipt: false }
|
|
633
|
+
}] });
|
|
634
|
+
}
|
|
635
|
+
await f.notify("chat:2");
|
|
636
|
+
await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value.snapshot.value.status)).toBe("ready");
|
|
637
|
+
if (status === "unrecognised") {
|
|
638
|
+
await f.page.evaluate(() => {
|
|
639
|
+
const saved = JSON.parse(sessionStorage.getItem("original-delivery-reload"));
|
|
640
|
+
saved.messages[0].status = "unrecognised";
|
|
641
|
+
sessionStorage.setItem("original-delivery-reload", JSON.stringify(saved));
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
let releaseInspection;
|
|
645
|
+
if (status === "uncertain") {
|
|
646
|
+
const gate = Promise.withResolvers();
|
|
647
|
+
t.after(() => gate.resolve());
|
|
648
|
+
releaseInspection = gate.resolve;
|
|
649
|
+
await f.page.route("**/deliveries/**/inspect", async route => {
|
|
650
|
+
await gate.promise;
|
|
651
|
+
await route.fulfill({ json: { status: "unknown", messageId } }).catch(() => {});
|
|
652
|
+
});
|
|
653
|
+
await f.page.evaluate(messageId => { void window.savedBinding.runtime.value.inspectDelivery(messageId); }, messageId);
|
|
654
|
+
await expect.poll(() => f.page.evaluate(() => JSON.parse(sessionStorage.getItem("original-delivery-reload")).messages[0].checking)).toBe(true);
|
|
655
|
+
}
|
|
656
|
+
const saved = await f.page.evaluate(() => JSON.parse(sessionStorage.getItem("original-delivery-reload")));
|
|
657
|
+
assert.equal(saved.messages[0].status, status);
|
|
658
|
+
await f.page.reload();
|
|
659
|
+
releaseInspection?.();
|
|
660
|
+
if (releaseInspection) await f.page.unroute("**/deliveries/**/inspect");
|
|
661
|
+
await expect(f.input).toBeEnabled();
|
|
662
|
+
await attach();
|
|
663
|
+
await expect(f.input).toHaveValue(saved.draft);
|
|
664
|
+
const restored = await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId), messageId);
|
|
665
|
+
assert.equal(restored.status, unconfirmed ? "uncertain" : status);
|
|
666
|
+
assert.equal(restored.checking, false, "an inspector from the old page cannot disable recovery on the new page");
|
|
667
|
+
assert.equal(restored.id, saved.messages[0].id);
|
|
668
|
+
assert.deepEqual(restored.payload, saved.messages[0].payload);
|
|
669
|
+
assert.equal((await f.sent()).length, 1, "restoration never dispatches a saved request");
|
|
670
|
+
assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), !unconfirmed);
|
|
671
|
+
if (unconfirmed) {
|
|
672
|
+
const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
|
|
673
|
+
try { await expect(check).toBeEnabled(); }
|
|
674
|
+
catch (failure) {
|
|
675
|
+
t.diagnostic(JSON.stringify(await f.page.evaluate(() => {
|
|
676
|
+
const saved = window.savedBinding.runtime.value;
|
|
677
|
+
const primary = window.conversationFixture.current();
|
|
678
|
+
return { shared: saved === primary, savedIdentity: saved.identity, primaryIdentity: primary.identity,
|
|
679
|
+
canonical: saved.turns.value, messages: saved.delivery.state.messages,
|
|
680
|
+
projected: saved.delivery.turns(saved.turns.value) };
|
|
681
|
+
})));
|
|
682
|
+
await f.page.screenshot({ path: `/tmp/jskit-saved-delivery-reload-${status}-20261006.png` });
|
|
683
|
+
throw failure;
|
|
684
|
+
}
|
|
685
|
+
for (const action of ["Retry", "Cancel", "Edit"]) {
|
|
686
|
+
await expect(f.primary.getByRole("button", { name: action, exact: true })).toHaveCount(0);
|
|
687
|
+
}
|
|
688
|
+
// The fixture's original inspector matches any same-ID row. Keep the
|
|
689
|
+
// nonreceipt projection proof above, then inspect genuine absence.
|
|
690
|
+
await f.command("history", { id: "chat:2", turns: [], limit: 20 });
|
|
691
|
+
await f.notify("chat:2");
|
|
692
|
+
await expect(check).toBeEnabled();
|
|
693
|
+
assert.equal(await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId).status, messageId), "uncertain",
|
|
694
|
+
"removing a nonreceipt row is not evidence of rejection or admission");
|
|
695
|
+
await check.click();
|
|
696
|
+
await expect(check).toBeEnabled();
|
|
697
|
+
assert.equal(await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId).status, messageId), "uncertain");
|
|
698
|
+
assert.equal((await f.sent()).length, 1, "unknown inspection cannot resend or acknowledge");
|
|
699
|
+
if (status === "pending") await f.command("accept");
|
|
700
|
+
else await f.command("confirm", { id: "chat:2" });
|
|
701
|
+
await f.notify("chat:2");
|
|
702
|
+
await expect(check).toHaveCount(0);
|
|
703
|
+
assert.equal((await f.sent()).length, 1, "only the actual canonical receipt settles the original identity");
|
|
704
|
+
} else {
|
|
705
|
+
await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
|
|
706
|
+
await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(status === "failed" ? 1 : 0);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
|
|
712
|
+
test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
|
|
605
713
|
const f = await fixture(t);
|
|
606
714
|
await f.command("mode", { mode: "rejected" });
|
|
715
|
+
if (code === "conversation_not_steerable") {
|
|
716
|
+
await f.command("steering", { id: "chat:1", enabled: true });
|
|
717
|
+
await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
|
|
718
|
+
await f.notify("chat:1");
|
|
719
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
720
|
+
await f.page.route("**/messages", async route => {
|
|
721
|
+
const response = await route.fetch();
|
|
722
|
+
await route.fulfill(response.status() === 400
|
|
723
|
+
? { response, status: 409, json: { error: "The message was rejected before admission.", code } }
|
|
724
|
+
: { response });
|
|
725
|
+
});
|
|
726
|
+
}
|
|
607
727
|
await f.input.fill("Retry only this original message");
|
|
608
728
|
await f.input.press("Enter");
|
|
609
729
|
await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
|
|
730
|
+
const [rejected] = await f.sent();
|
|
731
|
+
assert.equal((await f.sent()).length, 1, "a definite rejection never silently resubmits steering as new work");
|
|
732
|
+
assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, rejected.input.messageId), "failed");
|
|
733
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value), true);
|
|
734
|
+
await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
|
|
735
|
+
if (code === "conversation_not_steerable") {
|
|
736
|
+
assert.equal(rejected.input.steer, true);
|
|
737
|
+
}
|
|
610
738
|
await f.input.fill("My newer draft");
|
|
611
739
|
await f.command("mode", { mode: "accepted" });
|
|
612
740
|
await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
|
|
@@ -619,8 +747,119 @@ test("an explicit pre-admission rejection retains original retry identity and th
|
|
|
619
747
|
await f.primary.getByRole("button", { name: "Stop", exact: true }).click();
|
|
620
748
|
await expect(f.primary.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
|
|
621
749
|
assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, 1);
|
|
750
|
+
if (code === "conversation_not_steerable") {
|
|
751
|
+
await f.command("mode", { mode: "rejected" });
|
|
752
|
+
for (const action of ["Cancel", "Edit"]) {
|
|
753
|
+
await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
|
|
754
|
+
await f.notify("chat:1");
|
|
755
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
756
|
+
const text = `Known rejected steer for ${action}`;
|
|
757
|
+
await f.input.fill(text);
|
|
758
|
+
await f.input.press("Enter");
|
|
759
|
+
await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
|
|
760
|
+
const request = (await f.sent()).at(-1);
|
|
761
|
+
assert.equal(request.input.steer, true);
|
|
762
|
+
await f.input.fill("Keep my newer draft");
|
|
763
|
+
await f.primary.getByRole("button", { name: action, exact: true }).click();
|
|
764
|
+
await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toHaveCount(0);
|
|
765
|
+
assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId), null);
|
|
766
|
+
await expect(f.input).toHaveValue(action === "Edit" ? `${text}\n\nKeep my newer draft` : "Keep my newer draft");
|
|
767
|
+
}
|
|
768
|
+
assert.equal((await f.sent()).length, 4, "Cancel and Edit do not dispatch another prompt");
|
|
769
|
+
assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, 1, "failed-message recovery does not stop agent work");
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
test("opted-in nonsteerable follow-ups use the original serial queue and dispatch only after ready", options, async t => {
|
|
775
|
+
const f = await fixture(t);
|
|
776
|
+
await f.page.evaluate(() => {
|
|
777
|
+
window.deferredBinding = window.conversationFixture.acquireConversation({
|
|
778
|
+
conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
|
|
779
|
+
});
|
|
780
|
+
window.conversationFixture.target("chat:2");
|
|
781
|
+
});
|
|
782
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
|
|
783
|
+
await expect(f.input).toBeEnabled();
|
|
784
|
+
await f.input.fill("Original nonsteerable request");
|
|
785
|
+
await f.input.press("Enter");
|
|
786
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
|
|
787
|
+
await f.page.evaluate(() => window.conversationFixture.data({ clientId: "typed", focus: { project: "captured" } }));
|
|
788
|
+
await f.input.fill("Typed follow-up while answering");
|
|
789
|
+
await f.input.press("Enter");
|
|
790
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(1);
|
|
791
|
+
const [typed] = await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages);
|
|
792
|
+
await f.page.evaluate(() => {
|
|
793
|
+
window.conversationFixture.retainVoice();
|
|
794
|
+
window.queuedSpeech = window.conversationFixture.sendVoice("Spoken follow-up while answering", {
|
|
795
|
+
clientId: "voice", focus: { project: "voice-captured" }
|
|
796
|
+
});
|
|
797
|
+
window.conversationFixture.changeFocus({ project: "later" });
|
|
798
|
+
});
|
|
799
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(2);
|
|
800
|
+
const [, spoken] = await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages);
|
|
801
|
+
await expect(f.primary.getByText(typed.text, { exact: true })).toHaveCount(1);
|
|
802
|
+
await expect(f.primary.getByText(spoken.text, { exact: true })).toHaveCount(1);
|
|
803
|
+
await expect(f.input).toBeEnabled();
|
|
804
|
+
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value), true);
|
|
805
|
+
assert.equal((await f.sent()).length, 1, "both authored follow-ups wait locally without HTTP");
|
|
806
|
+
await f.command("finish", { id: "chat:2" });
|
|
807
|
+
await f.notify("chat:2");
|
|
808
|
+
await expect.poll(async () => (await f.sent()).length).toBe(2);
|
|
809
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
|
|
810
|
+
assert.deepEqual((await f.sent())[1].input, {
|
|
811
|
+
messageId: typed.id, text: typed.text, data: { clientId: "typed", focus: { project: "captured" } }
|
|
812
|
+
});
|
|
813
|
+
assert.equal((await f.sent()).length, 2, "the next follower waits for the first follow-up's real turn");
|
|
814
|
+
await f.command("finish", { id: "chat:2" });
|
|
815
|
+
await f.notify("chat:2");
|
|
816
|
+
await expect.poll(async () => (await f.sent()).length).toBe(3);
|
|
817
|
+
assert.deepEqual((await f.sent())[2].input, {
|
|
818
|
+
messageId: spoken.id, text: spoken.text, data: { clientId: "voice", focus: { project: "voice-captured" } }
|
|
819
|
+
});
|
|
820
|
+
assert.equal((await f.page.evaluate(() => window.queuedSpeech)).status, "accepted");
|
|
821
|
+
assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false,
|
|
822
|
+
"ordinary follow-ups never stop the original turn or output owner");
|
|
823
|
+
await f.command("steering", { id: "chat:2", enabled: true });
|
|
824
|
+
await f.notify("chat:2");
|
|
825
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
826
|
+
assert.equal((await f.page.evaluate(() => window.conversationFixture.sendVoice("Native steering remains immediate"))).status, "accepted");
|
|
827
|
+
assert.equal((await f.sent()).at(-1).input.steer, true);
|
|
622
828
|
});
|
|
623
829
|
|
|
830
|
+
for (const action of ["stop", "actor", "denied"]) {
|
|
831
|
+
test(`opted-in local followers cannot dispatch after ${action}`, options, async t => {
|
|
832
|
+
const f = await fixture(t);
|
|
833
|
+
await f.page.evaluate(() => {
|
|
834
|
+
window.deferredBinding = window.conversationFixture.acquireConversation({
|
|
835
|
+
conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
|
|
836
|
+
});
|
|
837
|
+
window.conversationFixture.target("chat:2");
|
|
838
|
+
});
|
|
839
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
|
|
840
|
+
await f.command("mode", { mode: "hold" });
|
|
841
|
+
await f.page.evaluate(() => {
|
|
842
|
+
window.firstDeferred = window.deferredBinding.runtime.value.send({ message: "First in-flight request" }, { messageId: "first-held" });
|
|
843
|
+
});
|
|
844
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
845
|
+
await f.page.evaluate(() => {
|
|
846
|
+
window.followerDeferred = window.deferredBinding.runtime.value.send({ message: "Local follower only" }, { messageId: "local-follower" });
|
|
847
|
+
});
|
|
848
|
+
assert.equal((await f.sent()).length, 1);
|
|
849
|
+
if (action === "stop") await f.page.evaluate(() => window.deferredBinding.runtime.value.cancel());
|
|
850
|
+
else if (action === "actor") await f.page.evaluate(() => window.conversationFixture.actor("different-actor"));
|
|
851
|
+
else {
|
|
852
|
+
await f.command("deny", { denied: true });
|
|
853
|
+
await f.notify("chat:2");
|
|
854
|
+
await expect.poll(() => f.page.evaluate(() => window.deferredBinding.runtime.value.available.value)).toBe(false);
|
|
855
|
+
}
|
|
856
|
+
await f.command("accept");
|
|
857
|
+
assert.equal(await f.page.evaluate(() => window.followerDeferred), false);
|
|
858
|
+
assert.equal((await f.sent()).length, 1, "a canceled local follower never reaches HTTP behind the original serial predecessor");
|
|
859
|
+
assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, action === "stop" ? 1 : 0);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
624
863
|
test("authored application data stays captured across retry, a lost receipt and changed focus", options, async t => {
|
|
625
864
|
const f = await fixture(t);
|
|
626
865
|
const originalData = { clientId: "typed-client", focus: { project: "first", session: "original" } };
|