@jskit-ai/assistant-runtime 0.1.197 → 0.1.199
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 +8 -8
- package/src/client/components/AssistantConversationClientElement.vue +0 -2
- package/src/client/composables/useAssistantConversation.js +73 -28
- package/src/client/support/subscribeAssistantConversation.js +26 -2
- package/src/server/AssistantProvider.js +3 -2
- package/src/server/actions.js +3 -0
- package/src/server/registerConversationSubscriptions.js +32 -3
- package/test/assistantConversationBinding.browser.test.js +307 -1
- package/test/assistantConversationSubscriptions.test.js +302 -2
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.199",
|
|
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.206",
|
|
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.226",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.73",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.228",
|
|
22
|
+
"@jskit-ai/realtime": "0.1.225",
|
|
23
|
+
"@jskit-ai/shell-web": "0.1.232",
|
|
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.228"
|
|
28
28
|
},
|
|
29
29
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
30
30
|
"jskit": {
|
|
@@ -50,7 +50,6 @@ defineExpose({ focus: () => conversation.value?.focus(), runtime: binding.runtim
|
|
|
50
50
|
<slot :name="name" v-bind="scope" :runtime="binding.runtime.value" />
|
|
51
51
|
</template>
|
|
52
52
|
<template #composer-feedback>
|
|
53
|
-
<p v-if="binding.error.value" role="status" class="assistant-client-conversation__error">{{ binding.error.value }}</p>
|
|
54
53
|
<slot name="composer-feedback" :runtime="binding.runtime.value" />
|
|
55
54
|
</template>
|
|
56
55
|
</AssistantConversationElement>
|
|
@@ -58,5 +57,4 @@ defineExpose({ focus: () => conversation.value?.focus(), runtime: binding.runtim
|
|
|
58
57
|
|
|
59
58
|
<style scoped>
|
|
60
59
|
.assistant-client-conversation { flex: 1 1 auto; height: 100%; min-height: 0; min-width: 0; }
|
|
61
|
-
.assistant-client-conversation__error { color: rgb(var(--v-theme-error)); font-size: .85rem; margin: .25rem 0; overflow-wrap: anywhere; }
|
|
62
60
|
</style>
|
|
@@ -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(); }
|
|
@@ -536,7 +579,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
536
579
|
|
|
537
580
|
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
538
581
|
snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
|
|
539
|
-
error, loading, stopping, delivery, turns,
|
|
582
|
+
error, accessDenied, loading, stopping, delivery, turns,
|
|
540
583
|
hasMoreBefore, loadingMore, loadMoreError, loadMore,
|
|
541
584
|
send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
|
|
542
585
|
reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
|
|
@@ -581,7 +624,7 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
|
|
|
581
624
|
conversation: {
|
|
582
625
|
turns: conversationTurnsFromMessages(messages.value.map(message => ({ ...message, text: message.content }))),
|
|
583
626
|
assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage,
|
|
584
|
-
visible: display.visible, variant: display.variant, scrollKey: toValue(scope)
|
|
627
|
+
visible: display.visible, retainWhenHidden: display.retainWhenHidden, variant: display.variant, scrollKey: toValue(scope)
|
|
585
628
|
},
|
|
586
629
|
composer: {
|
|
587
630
|
draft: draft.value, disabled: busy.value, pending: busy.value,
|
|
@@ -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 });
|
|
@@ -818,12 +862,13 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
818
862
|
models: current?.available.value ? modelOwner.value : null, questions: prompts,
|
|
819
863
|
goal: (toValue(goal) === true ? null : toValue(goal)) || current?.goalState.value || null,
|
|
820
864
|
conversation: { turns: current?.turns.value || [], interimReply: state?.interimReply || null,
|
|
865
|
+
error: current?.error.value || state?.error || "", errorReloadable: Boolean(current) && !current.accessDenied.value,
|
|
821
866
|
scrollKey: JSON.stringify(identity.value),
|
|
822
867
|
hasMoreBefore: current?.hasMoreBefore.value || false,
|
|
823
868
|
loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
|
|
824
|
-
loading: current?.loading.value || false, working: state?.status === "working" && !current.delivery.state.sending,
|
|
869
|
+
loading: current?.loading.value || false, working: !current?.error.value && state?.status === "working" && !current.delivery.state.sending,
|
|
825
870
|
assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage ?? "What would you like to do?",
|
|
826
|
-
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible,
|
|
871
|
+
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
|
|
827
872
|
userMessageFormat: display.userMessageFormat, progressPreviewLimit: display.progressPreviewLimit,
|
|
828
873
|
previewMessage: current?.available.value ? display.previewMessage : null },
|
|
829
874
|
composer: { draft, disabled: !editable ||
|
|
@@ -59,6 +59,7 @@ function subscribeAssistantConversation({
|
|
|
59
59
|
publishState({ ...options, canonical: true });
|
|
60
60
|
}
|
|
61
61
|
function reload() {
|
|
62
|
+
if (!listening) return subscribe();
|
|
62
63
|
if (reloadInFlight) {
|
|
63
64
|
reloadQueued = true;
|
|
64
65
|
return reloadInFlight;
|
|
@@ -82,7 +83,24 @@ function subscribeAssistantConversation({
|
|
|
82
83
|
for (const [id, entry] of completed) if (settled.has(entry.turnId)) completed.delete(id);
|
|
83
84
|
receiveState(snapshot);
|
|
84
85
|
}).catch(error => {
|
|
85
|
-
if (current())
|
|
86
|
+
if (current()) {
|
|
87
|
+
if ([401, 403].includes(Number(error.status || error.statusCode))) {
|
|
88
|
+
if (epoch === null) {
|
|
89
|
+
disconnected();
|
|
90
|
+
if (socket.connected) socket.emit(ASSISTANT_CONVERSATION_UNSUBSCRIBE, { subscriptionId });
|
|
91
|
+
} else {
|
|
92
|
+
// Retain invalidation notifications for a fresh authorized read, not cached content.
|
|
93
|
+
generation += 1;
|
|
94
|
+
reloadInFlight = null;
|
|
95
|
+
reloadQueued = false;
|
|
96
|
+
}
|
|
97
|
+
state = null;
|
|
98
|
+
streaming = null;
|
|
99
|
+
completed.clear();
|
|
100
|
+
settledTurns.clear();
|
|
101
|
+
}
|
|
102
|
+
onError(error);
|
|
103
|
+
}
|
|
86
104
|
}).finally(() => {
|
|
87
105
|
pendingReads.delete(pendingRead);
|
|
88
106
|
if (reloadInFlight !== job) return;
|
|
@@ -100,6 +118,10 @@ function subscribeAssistantConversation({
|
|
|
100
118
|
if (payload.streamEpoch !== epoch || !Number.isSafeInteger(payload.streamRevision) || payload.streamRevision <= revision) return;
|
|
101
119
|
revision = payload.streamRevision;
|
|
102
120
|
const event = payload.event;
|
|
121
|
+
if (!state) {
|
|
122
|
+
if (SNAPSHOT_EVENTS.has(event.type)) reload();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
103
125
|
let changedPresentation = Object.hasOwn(event, "interimReply");
|
|
104
126
|
if (changedPresentation && state) state = { ...state, interimReply: event.interimReply };
|
|
105
127
|
if (event.type === "transcript") {
|
|
@@ -164,7 +186,9 @@ function subscribeAssistantConversation({
|
|
|
164
186
|
listening = false;
|
|
165
187
|
pending = [];
|
|
166
188
|
if (socket.connected) socket.emit(ASSISTANT_CONVERSATION_UNSUBSCRIBE, { subscriptionId });
|
|
167
|
-
onError(error
|
|
189
|
+
onError(error ? Object.assign(new Error("Chat updates could not reconnect. Reload chat to try again.", { cause: error }), {
|
|
190
|
+
code: "assistant_subscription_transport_failed"
|
|
191
|
+
}) : Object.assign(new Error(response?.error || "Conversation subscription failed."), {
|
|
168
192
|
code: response?.code, statusCode: response?.status
|
|
169
193
|
}));
|
|
170
194
|
return;
|
|
@@ -15,6 +15,7 @@ const AssistantFeature = defineFeature({
|
|
|
15
15
|
http: "runtime.http"
|
|
16
16
|
},
|
|
17
17
|
optional: {
|
|
18
|
+
logger: "runtime.logger",
|
|
18
19
|
conversationRuntime: "assistant.conversations",
|
|
19
20
|
conversationAccess: "assistant.conversation.access",
|
|
20
21
|
assistantConfigService: "assistant.settings",
|
|
@@ -97,11 +98,11 @@ const AssistantFeature = defineFeature({
|
|
|
97
98
|
config
|
|
98
99
|
});
|
|
99
100
|
},
|
|
100
|
-
boot({ conversationRuntime, conversationAccess, realtime, events, config, workspaces }, { outputs }) {
|
|
101
|
+
boot({ conversationRuntime, conversationAccess, realtime, events, config, workspaces, logger }, { outputs }) {
|
|
101
102
|
if (!conversationRuntime || !realtime) return;
|
|
102
103
|
const state = subscriptionLifecycle.get(outputs.assistant);
|
|
103
104
|
state.release = registerConversationSubscriptions({
|
|
104
|
-
realtime, events, actions: state.actions, config, workspaceScopeSupport: workspaces?.scope || null,
|
|
105
|
+
realtime, events, actions: state.actions, config, logger, workspaceScopeSupport: workspaces?.scope || null,
|
|
105
106
|
...(conversationAccess ? {
|
|
106
107
|
subscribeActionId: conversationAccess.subscribeActionId,
|
|
107
108
|
requestPolicy: conversationAccess.requestPolicy
|
package/src/server/actions.js
CHANGED
|
@@ -201,10 +201,13 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
|
|
|
201
201
|
if (typeof deps?.onEvent !== "function" || typeof deps?.onRelease !== "function") {
|
|
202
202
|
throw new TypeError("Conversation subscriptions require their server-owned event and cleanup callbacks.");
|
|
203
203
|
}
|
|
204
|
+
deps.onStage?.("conversation-open");
|
|
204
205
|
const conversation = await openConversation(input, context);
|
|
206
|
+
deps.onStage?.("observer-attach");
|
|
205
207
|
const release = await conversation.subscribe(deps.onEvent);
|
|
206
208
|
try {
|
|
207
209
|
deps.onRelease(release);
|
|
210
|
+
deps.onStage?.("snapshot-read");
|
|
208
211
|
return await conversation.read();
|
|
209
212
|
} catch (error) {
|
|
210
213
|
release();
|
|
@@ -30,7 +30,7 @@ function subscriptionError(error) {
|
|
|
30
30
|
// Register one conversation subscription owner per shared realtime runtime.
|
|
31
31
|
// A host may select its fixed action/request policy at composition; clients
|
|
32
32
|
// never choose either. The selected action remains the authorization boundary.
|
|
33
|
-
function registerConversationSubscriptions({ realtime, events, actions, config = {}, workspaceScopeSupport = null,
|
|
33
|
+
function registerConversationSubscriptions({ realtime, events, actions, config = {}, logger = null, workspaceScopeSupport = null,
|
|
34
34
|
subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated" } = {}) {
|
|
35
35
|
if (typeof realtime?.onConnection !== "function" || typeof events?.publish !== "function") {
|
|
36
36
|
throw new TypeError("Conversation subscriptions require the shared realtime connection and event runtime.");
|
|
@@ -63,14 +63,34 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
63
63
|
if (typeof acknowledge !== "function" || closed) return;
|
|
64
64
|
let subscription;
|
|
65
65
|
let subscriptionId;
|
|
66
|
+
const startedAt = performance.now();
|
|
67
|
+
let stageStartedAt = startedAt;
|
|
68
|
+
let stage = "request-admission";
|
|
69
|
+
let observedConversationId;
|
|
70
|
+
function onStage(next, fields = {}) {
|
|
71
|
+
const now = performance.now();
|
|
72
|
+
// Only a successfully opened, authorized conversation may enter diagnostics.
|
|
73
|
+
if (next === "observer-attach") observedConversationId = input.conversationId;
|
|
74
|
+
try {
|
|
75
|
+
logger?.info?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
|
|
76
|
+
...(typeof observedConversationId === "string" ? { conversationId: observedConversationId } : {}),
|
|
77
|
+
stage: next, previousStage: stage,
|
|
78
|
+
durationMs: now - stageStartedAt, elapsedMs: now - startedAt, ...fields },
|
|
79
|
+
"Assistant conversation subscription stage");
|
|
80
|
+
} catch { /* Diagnostics must not change conversation admission or cleanup. */ }
|
|
81
|
+
stage = next;
|
|
82
|
+
stageStartedAt = now;
|
|
83
|
+
}
|
|
66
84
|
try {
|
|
67
85
|
subscriptionId = requireSubscriptionId(input);
|
|
68
86
|
release(subscriptionId);
|
|
69
87
|
subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve() };
|
|
70
88
|
subscriptions.set(subscriptionId, subscription);
|
|
71
89
|
const current = () => !closed && socket.connected && subscriptions.get(subscriptionId) === subscription;
|
|
90
|
+
onStage("authentication");
|
|
72
91
|
const request = await (requestPolicy === "host" ? readRequest() : authenticate());
|
|
73
|
-
if (!current()) return;
|
|
92
|
+
if (!current()) { onStage("abandoned"); return; }
|
|
93
|
+
onStage("action-admission");
|
|
74
94
|
const assistantSurface = resolveAssistantSurfaceConfig(config, input.targetSurfaceId);
|
|
75
95
|
if (!assistantSurface) throw new AppError(404, "Assistant not found.");
|
|
76
96
|
const requiresWorkspace = assistantSurface.runtimeSurfaceRequiresWorkspace;
|
|
@@ -127,15 +147,24 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
127
147
|
input: { ...routeState.actionInput, conversationId: input.conversationId },
|
|
128
148
|
deps: {
|
|
129
149
|
onEvent,
|
|
150
|
+
onStage,
|
|
130
151
|
onRelease(detach) {
|
|
131
152
|
if (!current()) detach();
|
|
132
153
|
else subscription.release = detach;
|
|
133
154
|
}
|
|
134
155
|
}
|
|
135
156
|
});
|
|
136
|
-
if (!current()) return;
|
|
157
|
+
if (!current()) { onStage("abandoned"); return; }
|
|
158
|
+
onStage("acknowledgement", {
|
|
159
|
+
...(Array.isArray(state?.conversationLog) ? { snapshotTurns: state.conversationLog.length } : {}),
|
|
160
|
+
...(Number.isSafeInteger(state?.pagination?.limit) ? { snapshotLimit: state.pagination.limit } : {})
|
|
161
|
+
});
|
|
137
162
|
acknowledge({ ok: true, subscriptionId, streamEpoch: subscription.epoch, state });
|
|
163
|
+
// Sending an acknowledgement does not prove that a timed-out client received it.
|
|
164
|
+
onStage("acknowledgement-sent");
|
|
138
165
|
} catch (error) {
|
|
166
|
+
onStage("failed", { code: String(error?.code || "assistant_subscription_failed"),
|
|
167
|
+
status: Number(error?.status || error?.statusCode || 500) });
|
|
139
168
|
if (subscriptions.get(subscriptionId) === subscription) release(subscriptionId);
|
|
140
169
|
if (!closed && socket.connected) acknowledge(subscriptionError(error));
|
|
141
170
|
}
|
|
@@ -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,7 +747,118 @@ 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
|
+
}
|
|
622
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);
|
|
828
|
+
});
|
|
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
|
+
}
|
|
623
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);
|
|
@@ -1866,3 +2105,70 @@ test("acceptance settles a voice-retained conversation after its text view retar
|
|
|
1866
2105
|
await expect(f.input).toHaveValue("The next conversation's draft stays.");
|
|
1867
2106
|
assert.equal((await f.sent()).length, 1);
|
|
1868
2107
|
});
|
|
2108
|
+
|
|
2109
|
+
test("transient subscription errors keep loaded history and recover through the banner while denial clears private content", options, async t => {
|
|
2110
|
+
const f = await fixture(t, 390, { acceptedDraft: true });
|
|
2111
|
+
await f.input.fill("Start a controlled ongoing answer.");
|
|
2112
|
+
await f.input.press("Enter");
|
|
2113
|
+
const stop = f.primary.getByRole("button", { name: "Stop", exact: true });
|
|
2114
|
+
await expect(stop).toBeVisible();
|
|
2115
|
+
const sentBeforeRecovery = (await f.sent()).length;
|
|
2116
|
+
assert.equal(sentBeforeRecovery, 1);
|
|
2117
|
+
const workingHint = f.primary.locator(".assistant-composer-support__assistant-status");
|
|
2118
|
+
await expect(workingHint).toHaveText("Assistant is working…");
|
|
2119
|
+
const turns = Array.from({ length: 30 }, (_, index) => ({
|
|
2120
|
+
turnId: `saved-${index}`,
|
|
2121
|
+
user: { messageId: `question-${index}`, role: "user", text: `Private saved question ${index}. ` + "Keep the original discussion. ".repeat(8) },
|
|
2122
|
+
assistant: { messageId: `answer-${index}`, role: "assistant", text: `Private saved answer ${index}. ` + "Read the saved answer without losing your place. ".repeat(8) }
|
|
2123
|
+
}));
|
|
2124
|
+
await f.command("history", { id: "chat:1", turns, limit: 30 });
|
|
2125
|
+
await f.notify("chat:1");
|
|
2126
|
+
const body = f.primary.locator(".assistant-transcript__body");
|
|
2127
|
+
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
|
|
2128
|
+
await expect(f.primary.locator(".assistant-transcript__settling")).toHaveCount(0);
|
|
2129
|
+
await f.input.fill("Keep my newer private draft.");
|
|
2130
|
+
await body.hover();
|
|
2131
|
+
await f.page.mouse.wheel(0, -1000);
|
|
2132
|
+
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
|
|
2133
|
+
await body.evaluate(element => { element.scrollTop = 420; window.loadedUpdateBody = element; });
|
|
2134
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2135
|
+
await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(2, 7); });
|
|
2136
|
+
const reads = "**/api/assistant/home/conversations/*";
|
|
2137
|
+
await f.page.route(reads, route => route.fulfill({ status: 503, json: { error: "Chat updates are temporarily unavailable." } }));
|
|
2138
|
+
await f.page.evaluate(() => window.conversationFixture.current().reload());
|
|
2139
|
+
const banner = f.primary.locator(".assistant-transcript__error");
|
|
2140
|
+
await expect(banner).toContainText("Chat updates are temporarily unavailable.");
|
|
2141
|
+
await expect(workingHint).toHaveCount(0);
|
|
2142
|
+
await expect(stop).toBeVisible();
|
|
2143
|
+
await expect(f.primary.getByText("Chat updates are temporarily unavailable.", { exact: true })).toHaveCount(1);
|
|
2144
|
+
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
|
|
2145
|
+
assert.equal(await body.evaluate(element => element === window.loadedUpdateBody), true);
|
|
2146
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2147
|
+
await expect(f.input).toBeFocused();
|
|
2148
|
+
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 7]);
|
|
2149
|
+
await expect(f.input).toHaveValue("Keep my newer private draft.");
|
|
2150
|
+
await f.page.evaluate(() => window.conversationFixture.socket.reconnect());
|
|
2151
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.socket.inspect().active)).toBe(0);
|
|
2152
|
+
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
|
|
2153
|
+
await f.page.unroute(reads);
|
|
2154
|
+
await banner.getByRole("button", { name: "Reload chat", exact: true }).click();
|
|
2155
|
+
await expect(banner).toHaveCount(0);
|
|
2156
|
+
await expect(workingHint).toHaveText("Assistant is working…");
|
|
2157
|
+
await expect(stop).toBeVisible();
|
|
2158
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.socket.inspect().active)).toBe(1);
|
|
2159
|
+
assert.equal(await body.evaluate(element => element === window.loadedUpdateBody), true);
|
|
2160
|
+
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
|
|
2161
|
+
await expect(f.input).toHaveValue("Keep my newer private draft.");
|
|
2162
|
+
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
|
|
2163
|
+
assert.equal((await f.sent()).length, sentBeforeRecovery, "Subscription recovery never sends the retained draft");
|
|
2164
|
+
await f.command("deny", { denied: true });
|
|
2165
|
+
await f.page.evaluate(() => window.conversationFixture.current().reload());
|
|
2166
|
+
await expect(banner).toContainText("Access denied.");
|
|
2167
|
+
await expect(banner.getByRole("button", { name: "Reload chat", exact: true })).toHaveCount(0);
|
|
2168
|
+
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(0);
|
|
2169
|
+
await expect(f.input).toHaveValue("");
|
|
2170
|
+
await expect(f.input).toBeDisabled();
|
|
2171
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.socket.inspect().active)).toBe(1);
|
|
2172
|
+
assert.equal((await f.sent()).length, sentBeforeRecovery);
|
|
2173
|
+
assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
2174
|
+
});
|
|
@@ -52,6 +52,7 @@ async function fixture(t) {
|
|
|
52
52
|
const fetches = [];
|
|
53
53
|
const runtimeEvents = [];
|
|
54
54
|
const authRequests = [];
|
|
55
|
+
const subscriptionTimings = [];
|
|
55
56
|
const nextReadBarriers = new Map();
|
|
56
57
|
const clientSessions = new WeakMap();
|
|
57
58
|
const context = { actor: { id: "42" }, surface: "home", channel: "internal" };
|
|
@@ -140,7 +141,8 @@ async function fixture(t) {
|
|
|
140
141
|
serverSockets.add(socket);
|
|
141
142
|
return () => { serverSockets.delete(socket); notify(); };
|
|
142
143
|
});
|
|
143
|
-
const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config
|
|
144
|
+
const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config,
|
|
145
|
+
logger: { info(record) { subscriptionTimings.push(record); notify(); } } });
|
|
144
146
|
const published = [];
|
|
145
147
|
events.register({ id: "test.assistant.published", handle(event) { published.push(event); notify(); } });
|
|
146
148
|
http.listen(0, "127.0.0.1");
|
|
@@ -201,9 +203,113 @@ async function fixture(t) {
|
|
|
201
203
|
return barrier;
|
|
202
204
|
}
|
|
203
205
|
return { ids, connect, view, changes, sessions, revoked, fetches, conversations, runtimeEvents,
|
|
204
|
-
realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests,
|
|
206
|
+
realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests, subscriptionTimings,
|
|
207
|
+
holdNextRead, active: () => active };
|
|
205
208
|
}
|
|
206
209
|
|
|
210
|
+
test("subscription timings identify a blocked snapshot and abandoned observation without stopping work", { timeout: 20_000 }, async t => {
|
|
211
|
+
const f = await fixture(t);
|
|
212
|
+
const socket = await f.connect();
|
|
213
|
+
const id = f.ids[0];
|
|
214
|
+
await f.conversations.get(id).send({ messageId: "measured-once", text: "Continue while history is being read" });
|
|
215
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
216
|
+
const barrier = f.holdNextRead(id);
|
|
217
|
+
const view = f.view(socket, id);
|
|
218
|
+
await until(f.changes, () => barrier.started);
|
|
219
|
+
assert.deepEqual(f.subscriptionTimings.map(record => record.stage), [
|
|
220
|
+
"authentication", "action-admission", "conversation-open", "observer-attach", "snapshot-read"
|
|
221
|
+
]);
|
|
222
|
+
assert.equal(f.active(), 1);
|
|
223
|
+
assert.equal(view.states.length, 0);
|
|
224
|
+
view.dispose();
|
|
225
|
+
await until(f.changes, () => f.active() === 0);
|
|
226
|
+
barrier.resolve();
|
|
227
|
+
await until(f.changes, () => f.subscriptionTimings.at(-1)?.stage === "abandoned");
|
|
228
|
+
const abandoned = f.subscriptionTimings.at(-1);
|
|
229
|
+
assert.equal(abandoned.previousStage, "snapshot-read");
|
|
230
|
+
assert.equal(abandoned.conversationId, id);
|
|
231
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
232
|
+
assert.equal(f.fetches.length, 1);
|
|
233
|
+
assert.equal(view.states.length, 0, "An abandoned read never acknowledges stale conversation content");
|
|
234
|
+
|
|
235
|
+
const recovered = f.view(socket, id);
|
|
236
|
+
await until(f.changes, () => recovered.states.length === 1);
|
|
237
|
+
const records = f.subscriptionTimings.filter(record => record.subscriptionEpoch !== abandoned.subscriptionEpoch);
|
|
238
|
+
assert.deepEqual(records.map(record => record.stage), ["authentication", "action-admission", "conversation-open",
|
|
239
|
+
"observer-attach", "snapshot-read", "acknowledgement", "acknowledgement-sent"]);
|
|
240
|
+
assert.ok(records.every(record => record.event === "assistant.conversation.subscription" &&
|
|
241
|
+
Number.isFinite(record.durationMs) && record.durationMs >= 0 &&
|
|
242
|
+
Number.isFinite(record.elapsedMs) && record.elapsedMs >= record.durationMs));
|
|
243
|
+
assert.ok(records.slice(1).every((record, index) => record.elapsedMs >= records[index].elapsedMs));
|
|
244
|
+
assert.ok(records.slice(0, 3).every(record => !Object.hasOwn(record, "conversationId")),
|
|
245
|
+
"Unvalidated request identity is never copied into diagnostics");
|
|
246
|
+
assert.ok(records.slice(3).every(record => record.conversationId === id));
|
|
247
|
+
assert.ok(records.every(record => !Object.hasOwn(record, "subscriptionId")),
|
|
248
|
+
"Correlation uses the server epoch rather than arbitrary client subscription text");
|
|
249
|
+
assert.equal(records.find(record => record.stage === "acknowledgement").snapshotTurns, 1);
|
|
250
|
+
assert.equal(recovered.states[0].conversationLog[0].user.messageId, "measured-once");
|
|
251
|
+
assert.equal(f.active(), 1);
|
|
252
|
+
assert.equal(f.fetches.length, 1);
|
|
253
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
254
|
+
recovered.dispose();
|
|
255
|
+
await until(f.changes, () => f.active() === 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("the real ten-second subscription deadline releases its late observer and Reload never repeats work", { timeout: 25_000 }, async t => {
|
|
259
|
+
const f = await fixture(t);
|
|
260
|
+
const socket = await f.connect();
|
|
261
|
+
const socketId = socket.id;
|
|
262
|
+
const id = f.ids[0];
|
|
263
|
+
await f.conversations.get(id).send({ messageId: "survives-timeout", text: "Keep working through a slow snapshot" });
|
|
264
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
265
|
+
const barrier = f.holdNextRead(id);
|
|
266
|
+
const view = f.view(socket, id);
|
|
267
|
+
await until(f.changes, () => barrier.started);
|
|
268
|
+
const firstSubscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
|
|
269
|
+
while (!view.errors.length || f.active() !== 0) {
|
|
270
|
+
await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
|
|
271
|
+
}
|
|
272
|
+
assert.equal(view.errors[0].message, "Chat updates could not reconnect. Reload chat to try again.");
|
|
273
|
+
assert.equal(view.errors[0].cause.message, "operation has timed out");
|
|
274
|
+
assert.equal(socket.id, socketId);
|
|
275
|
+
assert.equal(socket.connected, true);
|
|
276
|
+
assert.equal(view.states.length, 0);
|
|
277
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
278
|
+
view.dispose.reload();
|
|
279
|
+
await until(f.changes, () => view.states.length === 1 && f.active() === 1);
|
|
280
|
+
assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-timeout");
|
|
281
|
+
const statesBeforeLateRead = view.states.length;
|
|
282
|
+
barrier.resolve();
|
|
283
|
+
await until(f.changes, () => f.subscriptionTimings.some(record =>
|
|
284
|
+
record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned"));
|
|
285
|
+
assert.equal(view.states.length, statesBeforeLateRead, "The obsolete server read never acknowledges into the recovered view");
|
|
286
|
+
assert.equal(f.active(), 1, "The late read cannot release the recovered observer");
|
|
287
|
+
assert.equal(f.fetches.length, 1);
|
|
288
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
289
|
+
assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
|
|
290
|
+
f.fetches[0].text("Still running after the timed-out observation.");
|
|
291
|
+
await until(f.changes, () => view.received.some(event => event.text?.includes("timed-out observation")));
|
|
292
|
+
view.dispose();
|
|
293
|
+
await until(f.changes, () => f.active() === 0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("subscription diagnostics never serialize unvalidated client identifiers", async t => {
|
|
297
|
+
const f = await fixture(t);
|
|
298
|
+
const socket = await f.connect();
|
|
299
|
+
const privateText = "unvalidated-client-content-must-not-enter-logs";
|
|
300
|
+
const result = await requestSubscription(socket, {
|
|
301
|
+
subscriptionId: privateText, conversationId: { text: privateText }, targetSurfaceId: "home", hostSurfaceId: "home"
|
|
302
|
+
});
|
|
303
|
+
assert.equal(result.ok, false);
|
|
304
|
+
assert.equal(result.status, 400);
|
|
305
|
+
assert.equal(f.active(), 0);
|
|
306
|
+
assert.equal(f.subscriptionTimings.at(-1).stage, "failed");
|
|
307
|
+
assert.ok(f.subscriptionTimings.every(record => /^[0-9a-f-]{36}$/.test(record.subscriptionEpoch)));
|
|
308
|
+
assert.ok(f.subscriptionTimings.every(record => !Object.hasOwn(record, "conversationId") &&
|
|
309
|
+
!Object.hasOwn(record, "subscriptionId")));
|
|
310
|
+
assert.equal(JSON.stringify(f.subscriptionTimings).includes(privateText), false);
|
|
311
|
+
});
|
|
312
|
+
|
|
207
313
|
test("five chats share one authenticated socket and release independently without stopping turns", { timeout: 20_000 }, async t => {
|
|
208
314
|
const f = await fixture(t);
|
|
209
315
|
const socket = await f.connect();
|
|
@@ -237,6 +343,95 @@ test("five chats share one authenticated socket and release independently withou
|
|
|
237
343
|
assert.ok(views.every(view => view.errors.length === 0));
|
|
238
344
|
});
|
|
239
345
|
|
|
346
|
+
test("Reload recovers a failed initial subscription on the same connected socket without repeating native work", { timeout: 20_000 }, async t => {
|
|
347
|
+
const f = await fixture(t);
|
|
348
|
+
const socket = await f.connect();
|
|
349
|
+
const socketId = socket.id;
|
|
350
|
+
const barrier = f.holdNextRead(f.ids[0]);
|
|
351
|
+
const view = f.view(socket, f.ids[0]);
|
|
352
|
+
await until(f.changes, () => barrier.started);
|
|
353
|
+
barrier.reject(new Error("The initial history read failed."));
|
|
354
|
+
await until(f.changes, () => view.errors.length === 1 && f.active() === 0);
|
|
355
|
+
assert.equal(socket.connected, true);
|
|
356
|
+
assert.equal(view.states.length, 0);
|
|
357
|
+
|
|
358
|
+
await f.conversations.get(f.ids[0]).send({ messageId: "accepted-once", text: "Keep this admitted turn" });
|
|
359
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
360
|
+
f.revoked.add(f.ids[0]);
|
|
361
|
+
const authBeforeRetry = f.authRequests.length;
|
|
362
|
+
view.dispose.reload();
|
|
363
|
+
await until(f.changes, () => view.errors.length === 2);
|
|
364
|
+
assert.equal(view.errors.at(-1).statusCode, 403, "Reload must authorize again rather than reuse failed subscription authority");
|
|
365
|
+
assert.ok(f.authRequests.length > authBeforeRetry);
|
|
366
|
+
assert.equal(f.active(), 0);
|
|
367
|
+
assert.equal(view.states.length, 0);
|
|
368
|
+
assert.equal(socket.id, socketId);
|
|
369
|
+
|
|
370
|
+
f.revoked.delete(f.ids[0]);
|
|
371
|
+
view.dispose.reload();
|
|
372
|
+
await until(f.changes, () => view.states.length === 1 && f.active() === 1);
|
|
373
|
+
assert.equal(view.states[0].conversationLog[0].user.messageId, "accepted-once");
|
|
374
|
+
assert.equal(socket.id, socketId, "Recovery does not need a reconnect or replacement socket");
|
|
375
|
+
assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
|
|
376
|
+
assert.equal([...f.serverSockets][0].listenerCount(ASSISTANT_CONVERSATION_SUBSCRIBE), 1);
|
|
377
|
+
await view.dispose.reload();
|
|
378
|
+
assert.equal(f.active(), 1, "A healthy Reload reads without adding another observer");
|
|
379
|
+
f.fetches[0].text("Output continues after recovery.");
|
|
380
|
+
await until(f.changes, () => view.received.some(event => event.text?.includes("after recovery")));
|
|
381
|
+
assert.equal(f.fetches.length, 1, "Reload never repeats the accepted model request");
|
|
382
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
383
|
+
view.dispose();
|
|
384
|
+
await until(f.changes, () => f.active() === 0);
|
|
385
|
+
view.dispose.reload();
|
|
386
|
+
assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 0);
|
|
387
|
+
assert.equal(f.fetches[0].signal.aborted, false, "Disposing presentation does not stop admitted work");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
for (const failure of ["rejection", "timeout"]) {
|
|
391
|
+
test(`Reload after initial ${failure} retains subscription identity and ignores stale or disposed acknowledgements`, () => {
|
|
392
|
+
const socket = new EventEmitter();
|
|
393
|
+
socket.connected = true;
|
|
394
|
+
const requests = [], states = [], errors = [];
|
|
395
|
+
let reads = 0;
|
|
396
|
+
socket.timeout = timeout => ({ emit(name, input, acknowledge) {
|
|
397
|
+
assert.equal(timeout, 10_000);
|
|
398
|
+
assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
|
|
399
|
+
requests.push({ input, acknowledge });
|
|
400
|
+
} });
|
|
401
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "exact-chat", targetSurfaceId: "target", hostSurfaceId: "host",
|
|
402
|
+
workspaceSlug: "workspace", read() { reads += 1; }, onState: state => states.push(state), onError: error => errors.push(error) });
|
|
403
|
+
requests[0].acknowledge(failure === "timeout" ? new Error("operation has timed out") : null,
|
|
404
|
+
{ ok: false, error: "Temporary subscription failure.", status: 500 });
|
|
405
|
+
assert.equal(errors.length, 1);
|
|
406
|
+
dispose.reload();
|
|
407
|
+
dispose.reload();
|
|
408
|
+
assert.equal(requests.length, 2, "An acknowledgement already pending is not another subscription");
|
|
409
|
+
assert.deepEqual(requests[1].input, requests[0].input);
|
|
410
|
+
assert.deepEqual(requests[1].input, { subscriptionId: requests[0].input.subscriptionId, conversationId: "exact-chat",
|
|
411
|
+
targetSurfaceId: "target", hostSurfaceId: "host", workspaceSlug: "workspace" });
|
|
412
|
+
const state = { id: "exact-chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
413
|
+
requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state });
|
|
414
|
+
assert.equal(states.length, 0, "The earlier generation cannot replace a pending recovery");
|
|
415
|
+
socket.connected = false;
|
|
416
|
+
socket.emit("disconnect");
|
|
417
|
+
dispose.reload();
|
|
418
|
+
assert.equal(requests.length, 2, "Disconnected Reload cannot create a subscription");
|
|
419
|
+
requests[1].acknowledge(null, { ok: true, streamEpoch: "late", state });
|
|
420
|
+
assert.equal(states.length, 0);
|
|
421
|
+
socket.connected = true;
|
|
422
|
+
dispose.reload();
|
|
423
|
+
assert.equal(requests.length, 3);
|
|
424
|
+
dispose();
|
|
425
|
+
requests[2].acknowledge(null, { ok: true, streamEpoch: "disposed", state });
|
|
426
|
+
dispose.reload();
|
|
427
|
+
assert.equal(requests.length, 3);
|
|
428
|
+
assert.equal(states.length, 0);
|
|
429
|
+
assert.equal(errors.length, 1);
|
|
430
|
+
assert.equal(reads, 0, "Recovery subscribes rather than reading under missing subscription authority");
|
|
431
|
+
for (const event of [ASSISTANT_CONVERSATION_EVENT, "connect", "disconnect"]) assert.equal(socket.listenerCount(event), 0);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
240
435
|
test("reconnect reauthorizes and reads current state without repeating an admitted submission", { timeout: 20_000 }, async t => {
|
|
241
436
|
const f = await fixture(t);
|
|
242
437
|
const socket = await f.connect("owner-session", { recovery: true });
|
|
@@ -690,3 +885,108 @@ test("host subscription policy retains authenticated actor metadata and per-even
|
|
|
690
885
|
assert.ok(cookies.length >= 5);
|
|
691
886
|
assert.ok(cookies.every(value => value === "host-session"));
|
|
692
887
|
});
|
|
888
|
+
|
|
889
|
+
for (const status of [401, 403, 503]) {
|
|
890
|
+
test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
|
|
891
|
+
const changes = new EventEmitter();
|
|
892
|
+
const socket = new EventEmitter();
|
|
893
|
+
socket.connected = true;
|
|
894
|
+
const requests = [], states = [], errors = [], unsubscribed = [], events = [];
|
|
895
|
+
socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribed.push(input));
|
|
896
|
+
socket.timeout = () => ({ emit(name, input, acknowledge) {
|
|
897
|
+
assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
|
|
898
|
+
requests.push({ input, acknowledge });
|
|
899
|
+
} });
|
|
900
|
+
const user = { messageId: "question", role: "user", text: "Private saved question" };
|
|
901
|
+
const initial = { id: "chat", status: "working", conversationLog: [{ turnId: "turn", user }],
|
|
902
|
+
streaming: { revision: 0, messages: [] } };
|
|
903
|
+
const recovered = { id: "chat", status: "ready", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
904
|
+
const failure = Object.assign(new Error(status === 503 ? "Updates unavailable." : "Access denied."), { statusCode: status });
|
|
905
|
+
let reads = 0;
|
|
906
|
+
let denied = true;
|
|
907
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
908
|
+
read() { reads += 1; if (denied) throw failure; return recovered; },
|
|
909
|
+
onState(state) { states.push(state); changes.emit("change"); },
|
|
910
|
+
onError(error) { errors.push(error); changes.emit("change"); }, onEvent: event => events.push(event) });
|
|
911
|
+
t.after(dispose);
|
|
912
|
+
requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: initial });
|
|
913
|
+
const subscriptionId = requests[0].input.subscriptionId;
|
|
914
|
+
await dispose.reload();
|
|
915
|
+
assert.deepEqual(errors, [failure]);
|
|
916
|
+
assert.equal(reads, 1);
|
|
917
|
+
assert.equal(states.length, 1, "A failed read cannot replace the loaded snapshot");
|
|
918
|
+
function deliver(event, revision) {
|
|
919
|
+
socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "old",
|
|
920
|
+
streamRevision: revision, event });
|
|
921
|
+
}
|
|
922
|
+
const message = { messageId: "answer", turnId: "turn", role: "assistant", status: "inProgress", text: "Late private output" };
|
|
923
|
+
deliver({ type: "message", ...message, streaming: { revision: 1, messages: [message] } }, 1);
|
|
924
|
+
assert.equal(unsubscribed.length, 0, "The original notification observer remains available for authorized recovery");
|
|
925
|
+
if (status === 503) {
|
|
926
|
+
assert.equal(states.length, 2);
|
|
927
|
+
assert.equal(states.at(-1).turns[0].user.text, user.text);
|
|
928
|
+
assert.equal(states.at(-1).turns[0].assistant.text, message.text);
|
|
929
|
+
assert.equal(events.length, 1);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
assert.equal(states.length, 1, "Late events cannot restore content cleared by the denied access owner");
|
|
933
|
+
deliver({ type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "turn", user } } }, 2);
|
|
934
|
+
assert.equal(states.length, 1, "A late patch cannot create another denied snapshot");
|
|
935
|
+
assert.deepEqual(events, [], "Denied presentation hooks are not forwarded before a fresh authorized read");
|
|
936
|
+
requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: initial });
|
|
937
|
+
assert.equal(states.length, 1, "An obsolete acknowledgement cannot restore the denied snapshot");
|
|
938
|
+
denied = false;
|
|
939
|
+
deliver({ type: "settled", turnId: "turn" }, 3);
|
|
940
|
+
await until(changes, () => states.length === 2);
|
|
941
|
+
assert.deepEqual(states.at(-1).turns, [], "Fresh canonical state excludes old buffered output");
|
|
942
|
+
assert.equal(requests.length, 1, "Recovery uses the original observer and authoritative read");
|
|
943
|
+
assert.equal(reads, 2);
|
|
944
|
+
deliver({ type: "message", ...message, text: "Authorized new output",
|
|
945
|
+
streaming: { revision: 2, messages: [{ ...message, text: "Authorized new output" }] } }, 4);
|
|
946
|
+
assert.equal(states.length, 3);
|
|
947
|
+
assert.equal(states.at(-1).turns[0].assistant.text, "Authorized new output");
|
|
948
|
+
assert.equal(events.length, 1);
|
|
949
|
+
assert.deepEqual(errors, [failure]);
|
|
950
|
+
assert.equal(reads, 2, "Presentation after recovery does not issue another read or resend");
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
test("denied Reload before the initial acknowledgement retires only that pending observer and recovers on the same socket", async t => {
|
|
955
|
+
const socket = new EventEmitter();
|
|
956
|
+
socket.connected = true;
|
|
957
|
+
const requests = [], states = [], errors = [], unsubscribed = [];
|
|
958
|
+
socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribed.push(input));
|
|
959
|
+
socket.timeout = () => ({ emit(name, input, acknowledge) {
|
|
960
|
+
assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
|
|
961
|
+
requests.push({ input, acknowledge });
|
|
962
|
+
} });
|
|
963
|
+
const failure = Object.assign(new Error("Access denied."), { statusCode: 401 });
|
|
964
|
+
let reads = 0;
|
|
965
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
966
|
+
read() { reads += 1; throw failure; }, onState: state => states.push(state), onError: error => errors.push(error) });
|
|
967
|
+
t.after(dispose);
|
|
968
|
+
const subscriptionId = requests[0].input.subscriptionId;
|
|
969
|
+
await dispose.reload();
|
|
970
|
+
assert.deepEqual(errors, [failure]);
|
|
971
|
+
assert.deepEqual(unsubscribed, [{ subscriptionId }]);
|
|
972
|
+
assert.equal(reads, 1);
|
|
973
|
+
assert.equal(socket.connected, true, "Denied admission retires only this observer, not the shared socket");
|
|
974
|
+
const privateState = { id: "chat", status: "ready", conversationLog: [{ turnId: "private",
|
|
975
|
+
user: { messageId: "private-question", role: "user", text: "Old private history" } }], streaming: { revision: 0, messages: [] } };
|
|
976
|
+
requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: privateState });
|
|
977
|
+
socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "old",
|
|
978
|
+
streamRevision: 1, event: { type: "settled", turnId: "private" } });
|
|
979
|
+
assert.deepEqual(states, [], "Late old acknowledgements and notifications cannot restore private content");
|
|
980
|
+
assert.equal(reads, 1);
|
|
981
|
+
dispose.reload();
|
|
982
|
+
assert.equal(requests.length, 2);
|
|
983
|
+
assert.deepEqual(requests[1].input, requests[0].input);
|
|
984
|
+
requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: privateState });
|
|
985
|
+
assert.deepEqual(states, []);
|
|
986
|
+
const recovered = { id: "chat", status: "ready", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
987
|
+
requests[1].acknowledge(null, { ok: true, streamEpoch: "fresh", state: recovered });
|
|
988
|
+
assert.equal(states.length, 1);
|
|
989
|
+
assert.deepEqual(states[0].turns, []);
|
|
990
|
+
assert.equal(reads, 1, "Recovery uses fresh subscription admission rather than repeating the old read or inference");
|
|
991
|
+
assert.deepEqual(errors, [failure]);
|
|
992
|
+
});
|