@jskit-ai/assistant-runtime 0.1.198 → 0.1.200
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 +5 -4
- 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 +67 -0
- 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.200",
|
|
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.207",
|
|
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.227",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.74",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.229",
|
|
22
|
+
"@jskit-ai/realtime": "0.1.226",
|
|
23
|
+
"@jskit-ai/shell-web": "0.1.233",
|
|
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.229"
|
|
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>
|
|
@@ -579,7 +579,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
579
579
|
|
|
580
580
|
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
581
581
|
snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
|
|
582
|
-
error, loading, stopping, delivery, turns,
|
|
582
|
+
error, accessDenied, loading, stopping, delivery, turns,
|
|
583
583
|
hasMoreBefore, loadingMore, loadMoreError, loadMore,
|
|
584
584
|
send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
|
|
585
585
|
reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
|
|
@@ -624,7 +624,7 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
|
|
|
624
624
|
conversation: {
|
|
625
625
|
turns: conversationTurnsFromMessages(messages.value.map(message => ({ ...message, text: message.content }))),
|
|
626
626
|
assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage,
|
|
627
|
-
visible: display.visible, variant: display.variant, scrollKey: toValue(scope)
|
|
627
|
+
visible: display.visible, retainWhenHidden: display.retainWhenHidden, variant: display.variant, scrollKey: toValue(scope)
|
|
628
628
|
},
|
|
629
629
|
composer: {
|
|
630
630
|
draft: draft.value, disabled: busy.value, pending: busy.value,
|
|
@@ -862,12 +862,13 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
862
862
|
models: current?.available.value ? modelOwner.value : null, questions: prompts,
|
|
863
863
|
goal: (toValue(goal) === true ? null : toValue(goal)) || current?.goalState.value || null,
|
|
864
864
|
conversation: { turns: current?.turns.value || [], interimReply: state?.interimReply || null,
|
|
865
|
+
error: current?.error.value || state?.error || "", errorReloadable: Boolean(current) && !current.accessDenied.value,
|
|
865
866
|
scrollKey: JSON.stringify(identity.value),
|
|
866
867
|
hasMoreBefore: current?.hasMoreBefore.value || false,
|
|
867
868
|
loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
|
|
868
|
-
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,
|
|
869
870
|
assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage ?? "What would you like to do?",
|
|
870
|
-
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible,
|
|
871
|
+
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
|
|
871
872
|
userMessageFormat: display.userMessageFormat, progressPreviewLimit: display.progressPreviewLimit,
|
|
872
873
|
previewMessage: current?.available.value ? display.previewMessage : null },
|
|
873
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
|
}
|
|
@@ -2105,3 +2105,70 @@ test("acceptance settles a voice-retained conversation after its text view retar
|
|
|
2105
2105
|
await expect(f.input).toHaveValue("The next conversation's draft stays.");
|
|
2106
2106
|
assert.equal((await f.sent()).length, 1);
|
|
2107
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
|
+
});
|