From eb514ff5c1548078810f3fbf864d89e8ee394da8 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Thu, 5 Nov 2026 19:59:06 +0800
Subject: [PATCH 1/4] fix(chat): recover queue edits after dispatch
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
apps/presentation/dashboard/package.json | 1 +
.../smoke/queue-input-recovery-smoke.mjs | 53 ++++++++++++++
.../src/data/use-conversation-queue.ts | 2 +-
.../personal-workspace/conversation-queue.css | 6 ++
.../personal-workspace/conversation-queue.tsx | 27 ++++---
.../personal-workspace-page.tsx | 1 +
.../queue-input-recovery.ts | 57 +++++++++++++++
.../retained-queue-inputs.tsx | 71 +++++++++++++++++++
.../attached-host-follow-up.mjs | 46 ++++++++++++
9 files changed, 248 insertions(+), 16 deletions(-)
create mode 100644 apps/presentation/dashboard/smoke/queue-input-recovery-smoke.mjs
create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/queue-input-recovery.ts
create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx
diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json
index 6c8f2b23ee..77cc3c7161 100644
--- a/apps/presentation/dashboard/package.json
+++ b/apps/presentation/dashboard/package.json
@@ -5,6 +5,7 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
+ "smoke:queue-input-recovery": "vite build --ssr smoke/queue-input-recovery-smoke.mjs --outDir node_modules/.cache/queue-input-recovery --emptyOutDir && node node_modules/.cache/queue-input-recovery/queue-input-recovery-smoke.js",
"smoke:todo-ownership": "vite build --ssr smoke/todo-ownership-smoke.mjs --outDir node_modules/.cache/todo-ownership --emptyOutDir && node node_modules/.cache/todo-ownership/todo-ownership-smoke.js",
"smoke:workspace-progressive-loader": "node smoke/workspace-progressive-status-smoke.mjs",
"smoke:workspace-progressive": "node ../../../examples/workspace-progressive-loading-browser-smoke.mjs",
diff --git a/apps/presentation/dashboard/smoke/queue-input-recovery-smoke.mjs b/apps/presentation/dashboard/smoke/queue-input-recovery-smoke.mjs
new file mode 100644
index 0000000000..bc7cc657c1
--- /dev/null
+++ b/apps/presentation/dashboard/smoke/queue-input-recovery-smoke.mjs
@@ -0,0 +1,53 @@
+import assert from "node:assert/strict";
+import { listQueueInputRecovery, queueInputKeys, readQueueInputRecovery, retireQueueInputRequest } from "../src/features/personal-workspace/queue-input-recovery";
+
+class MemoryStorage {
+ rows = new Map();
+ get length() {return this.rows.size;}
+ key(index) {return [...this.rows.keys()][index] ?? null;}
+ getItem(key) {return this.rows.get(key) ?? null;}
+ setItem(key, value) {this.rows.set(key, value);}
+ removeItem(key) {this.rows.delete(key);}
+}
+const storage = new MemoryStorage(), session = "original-session", turn = "original-turn";
+const keys = queueInputKeys(session, turn);
+const request = {action: "edit", operation_id: "original-operation", expected_revision: 0, message: "First correction"};
+function retain(editor = {message: request.message, revision: 0}) {
+ storage.setItem(keys.request, JSON.stringify(request));
+ storage.setItem(keys.draft, JSON.stringify(editor));
+}
+retain();
+assert.deepEqual(listQueueInputRecovery(storage, "other-session").entries, [], "Session isolation applies to local recovery");
+assert.equal(listQueueInputRecovery(storage, session).entries.length, 1, "Request and draft describe one retained input");
+// While A's response is pending, another page changes its draft to B.
+retain({message: "Newer correction from another page", revision: 0});
+retireQueueInputRequest(storage, session, turn, request, true);
+assert.equal(readQueueInputRecovery(storage, session, turn).editor.message, "Newer correction from another page");
+assert.equal(readQueueInputRecovery(storage, session, turn).request, null);
+// A's response must not retire a later operation identity either.
+const newer = {...request, operation_id: "later-operation", message: "Later request"};
+storage.setItem(keys.request, JSON.stringify(newer));
+retireQueueInputRequest(storage, session, turn, request, true);
+assert.deepEqual(readQueueInputRecovery(storage, session, turn).request, newer);
+// A request retained before IO can be rejected after the original starts.
+storage.removeItem(keys.draft); storage.setItem(keys.request, JSON.stringify(request));
+retireQueueInputRequest(storage, session, turn, request, false);
+assert.equal(readQueueInputRecovery(storage, session, turn).editor.message, request.message);
+assert.equal(readQueueInputRecovery(storage, session, turn).request, null);
+// Successful cleanup removes only matching text and revision.
+retain({message: request.message, revision: 1});
+retireQueueInputRequest(storage, session, turn, request, true);
+assert.equal(readQueueInputRecovery(storage, session, turn).editor.revision, 1);
+retain(); retireQueueInputRequest(storage, session, turn, request, true);
+assert.deepEqual(listQueueInputRecovery(storage, session).entries, []);
+// Failure while retiring the draft keeps the original retry identity.
+retain();
+const remove = storage.removeItem.bind(storage);
+storage.removeItem = key => {if (key === keys.draft) throw new Error("Storage unavailable"); remove(key);};
+assert.throws(() => retireQueueInputRequest(storage, session, turn, request, true));
+assert.equal(readQueueInputRecovery(storage, session, turn).request.operation_id, request.operation_id);
+storage.removeItem = remove;
+storage.setItem(keys.draft, "0");
+assert.equal(listQueueInputRecovery(storage, session).failed, true, "Invalid recovery is not an empty success");
+assert.equal(listQueueInputRecovery({get length() {throw new Error("Storage blocked");}}, session).failed, true);
+console.log("queue-input-recovery smoke ok");
diff --git a/apps/presentation/dashboard/src/data/use-conversation-queue.ts b/apps/presentation/dashboard/src/data/use-conversation-queue.ts
index 76de2df8e1..a869d42ff1 100644
--- a/apps/presentation/dashboard/src/data/use-conversation-queue.ts
+++ b/apps/presentation/dashboard/src/data/use-conversation-queue.ts
@@ -36,6 +36,6 @@ export function useConversationQueue({sessionId, scope, active, refreshKey}: {
return () => { controller.abort(); clearTimeout(timer); };
}, [key, sessionId, active, refreshKey, attempt]);
const current = sessionId && state?.key === key ? state : null;
- return {turns: current?.turns ?? [], activeTurnId: current?.activeTurnId ?? null, error: current?.error ?? null,
+ return {turns: current?.turns ?? [], activeTurnId: current?.activeTurnId ?? null, error: current?.error ?? null, readReady: Boolean(current && !current.error),
retry: () => setAttempt(value => value + 1)};
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.css b/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.css
index daabb32619..273217988a 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.css
+++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.css
@@ -22,3 +22,9 @@
.personal-queue-editor { padding: 0 10px 8px; }
.personal-queue-editor textarea { display: block; box-sizing: border-box; width: 100%; resize: vertical; min-height: 72px; padding: 8px; border: 1px solid var(--pw-line-strong); border-radius: 6px; font: inherit; font-size: var(--text-body); color: var(--pw-text); background: #fff; }
.personal-queue-editor > div { display: flex; justify-content: flex-end; gap: 6px; margin-top: 4px; }
+.personal-queue-recovery > details { border: 1px solid var(--pw-line-strong); border-radius: 8px; padding: 8px 10px; }
+.personal-queue-recovery summary { cursor: pointer; }
+.personal-queue-retained-input { margin-top: 8px; }
+.personal-queue-retained-input p { margin: 6px 0; color: var(--pw-muted); }
+.personal-queue-retained-input textarea { box-sizing: border-box; width: 100%; resize: vertical; padding: 8px; font: inherit; font-size: var(--text-body); line-height: var(--leading-body); color: var(--pw-text); background: var(--pw-surface, #fff); border: 1px solid var(--pw-line-strong); border-radius: 6px; }
+.personal-queue-retained-actions { display: flex; flex-wrap: wrap; gap: 6px; }
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx
index b734e12b05..939a9fa733 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx
@@ -5,6 +5,8 @@ import { ChatApiError, chatQueueInputRequestSchema, fetchQueuedChatInput, mutate
import { composerKeyAction } from "./composer-keyboard";
import { DocumentAttachment, isTextDocument } from "./document-attachment";
import { ContextHelp } from "./context-help";
+import { queueInputKeys, readQueueInputRecovery, retireQueueInputRequest } from "./queue-input-recovery";
+import { RetainedQueueInputs } from "./retained-queue-inputs";
import { useWorkspaceI18n } from "./i18n";
import "./conversation-queue.css";
@@ -18,16 +20,11 @@ function QueuedMessage({sessionId, turn, index, targetTurnId, onChange, onInputC
const [failed, setFailed] = useState(false);
const lock = useRef(false);
const [busy, setBusy] = useState(false), [actionError, setActionError] = useState("");
- const recoveryKey = `loopx.queue-input:${JSON.stringify([sessionId, turn.turn_id])}`;
- const draftKey = `${recoveryKey}:draft`;
+ const {request: recoveryKey, draft: draftKey} = queueInputKeys(sessionId, turn.turn_id);
const [recovery] = useState(() => {
try {
if (typeof window === "undefined") return {request: null, editor: null, failed: false};
- const raw = localStorage.getItem(recoveryKey);
- const saved = localStorage.getItem(draftKey);
- const editor = saved ? JSON.parse(saved) as {message: string; revision: number} : null;
- if (editor && (typeof editor.message !== "string" || !Number.isSafeInteger(editor.revision) || editor.revision < 0)) throw new Error("Invalid edit recovery");
- return {request: raw ? chatQueueInputRequestSchema.parse(JSON.parse(raw)) : null, editor, failed: false};
+ return {...readQueueInputRecovery(localStorage, sessionId, turn.turn_id), failed: false};
} catch {return {request: null, editor: null, failed: true};}
});
const [pending, setPending] = useState(recovery.request);
@@ -61,8 +58,7 @@ function QueuedMessage({sessionId, turn, index, targetTurnId, onChange, onInputC
setPending(request);
await mutateQueuedChatTurn(sessionId, turn.turn_id, request);
setPending(null); setEditing(false); setDraft(null); setMessage(null); setEditRevision(null);
- localStorage.removeItem(recoveryKey);
- localStorage.removeItem(draftKey);
+ retireQueueInputRequest(localStorage, sessionId, turn.turn_id, request, true);
} catch (cause) {
const code = cause instanceof ChatApiError ? cause.payload.error_code : "";
const rejected = cause instanceof ChatApiError && (cause.payload.http_status === 400 || cause.payload.http_status === 404 ||
@@ -70,7 +66,7 @@ function QueuedMessage({sessionId, turn, index, targetTurnId, onChange, onInputC
"queue_mutation_source_unavailable", "queue_steering_in_progress", "queue_mutation_in_progress", "session_closed"].includes(String(code)));
if (rejected) {
setPending(null);
- try {localStorage.removeItem(recoveryKey);} catch { /* Preserve the draft if storage is unavailable. */ }
+ try {retireQueueInputRequest(localStorage, sessionId, turn.turn_id, request, false);} catch { /* Preserve the draft if storage is unavailable. */ }
}
setActionError(!retained
? (zh ? "恢复信息无法保存,修改尚未提交。请保留文本后重试。" : "Recovery could not be saved. The change was not submitted; retain the text and retry.")
@@ -177,18 +173,19 @@ function QueuedMessage({sessionId, turn, index, targetTurnId, onChange, onInputC
;
}
-export function ConversationQueue({sessionId, turns, error, onRetry, targetTurnId, onInputChanged = onRetry}: {
- sessionId: string; turns: ChatQueuedTurn[]; error: Error | null; onRetry: () => void; targetTurnId?: string; onInputChanged?: () => void;
+export function ConversationQueue({sessionId, turns, error, readReady, onRetry, targetTurnId, onInputChanged = onRetry}: {
+ sessionId: string; turns: ChatQueuedTurn[]; error: Error | null; readReady: boolean; onRetry: () => void; targetTurnId?: string; onInputChanged?: () => void;
}) {
const { locale } = useWorkspaceI18n();
const zh = locale === "zh-CN";
- if (!turns.length && !error) return null;
- return
+ return <>{turns.length || error ?
{zh ? "待处理" : "Queued"}{error ? "" : ` · ${turns.length}`}
{error ? <>{zh ? "读取失败" : "Read failed"}
{error.message}
> : null}
{turns.map((turn, index) => )}
- ;
+ : null}
+ {readReady && !error ? : null}
+ >;
}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
index e76c1c78ce..b390d1071f 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx
@@ -3224,6 +3224,7 @@ export function PersonalWorkspacePage({
? "上次投递尚未确认,发送将重试原请求。"
: "The previous delivery is unconfirmed. Send retries the original request."}
: null}
{conversationSessionId ? {conversationQueue.retry(); conversationHistoryState?.retry();}}
targetTurnId={steeringTurnId} turns={conversationQueue.turns} error={conversationQueue.error} onRetry={conversationQueue.retry}/> : null}
();
+ let failed = false;
+ const entries: QueueInputRecovery[] = [];
+ try {
+ for (let i = 0; i < storage.length; i += 1) {
+ const key = storage.key(i);
+ if (!key?.startsWith("loopx.queue-input:")) continue;
+ try {
+ const identity: unknown = JSON.parse(key.slice("loopx.queue-input:".length).replace(/:draft$/, ""));
+ if (Array.isArray(identity) && identity.length === 2 && identity[0] === sessionId && typeof identity[1] === "string") ids.add(identity[1]);
+ } catch { /* An unresolvable identity must not be assigned to this Session. */ }
+ }
+ for (const turnId of ids) {
+ try {
+ const input = readQueueInputRecovery(storage, sessionId, turnId);
+ if (input.request || input.editor) entries.push({turnId, ...input});
+ } catch {failed = true;}
+ }
+ } catch {failed = true;}
+ return {entries, failed};
+}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx b/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx
new file mode 100644
index 0000000000..d858bb3f9d
--- /dev/null
+++ b/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useRef, useState } from "react";
+import { ChatApiError, mutateQueuedChatTurn, type ChatQueuedTurn } from "../../data/chat";
+import { listQueueInputRecovery, queueInputKeys, retireQueueInputRequest, type QueueInputRecovery } from "./queue-input-recovery";
+import { useWorkspaceI18n } from "./i18n";
+
+function readSessionRecovery(sessionId: string) {
+ try {
+ return typeof window === "undefined" ? {entries: [], failed: false} : listQueueInputRecovery(window.localStorage, sessionId);
+ } catch {return {entries: [], failed: true};}
+}
+
+function RetainedInput({sessionId, input, onChange}: {sessionId: string; input: QueueInputRecovery; onChange: () => void}) {
+ const zh = useWorkspaceI18n().locale === "zh-CN";
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const lock = useRef(false), text = useRef
(null);
+ const draft = input.editor?.message ?? (input.request?.action === "edit" ? input.request.message : null);
+ async function check() {
+ if (!input.request || lock.current) return;
+ lock.current = true; setBusy(true); setError("");
+ try {
+ // Retry only the retained operation. The owner can replay its receipt
+ // after dispatch; a new edit against an already started Turn is rejected.
+ await mutateQueuedChatTurn(sessionId, input.turnId, input.request);
+ retireQueueInputRequest(localStorage, sessionId, input.turnId, input.request, true);
+ } catch (cause) {
+ const code = cause instanceof ChatApiError ? cause.payload.error_code : "";
+ if (["queue_turn_already_started", "queue_input_revision_conflict", "queue_mutation_identity_conflict"].includes(String(code))) {
+ // Keep the full text, including an edit retained only in the request.
+ try {retireQueueInputRequest(localStorage, sessionId, input.turnId, input.request, false);}
+ catch { /* An unreadable record must not be replaced with a fresh send. */ }
+ setError(zh ? "修改未应用,草稿已保留。" : "The change was not applied. Your draft is retained.");
+ } else setError(zh ? "原操作尚未确认,恢复记录已保留。" : "The original operation is unconfirmed. Recovery is retained.");
+ } finally {lock.current = false; setBusy(false); onChange();}
+ }
+ function discard() {
+ try {localStorage.removeItem(queueInputKeys(sessionId, input.turnId).draft); onChange();}
+ catch {setError(zh ? "草稿无法清除,请保留文本后重试。" : "The draft could not be cleared. Retain the text and retry.");}
+ }
+ return
+ {draft !== null ? (zh ? "保留的编辑草稿" : "Retained edit draft") : (zh ? "未确认的队列修改" : "Unconfirmed queue change")}
+ {zh ? "原消息已不在待处理队列中。" : "The original message is no longer in the pending queue."}
+ {draft !== null ? : null}
+
+ {draft !== null ? : null}
+ {input.request ?
+ : }
+
+ {error ? {error}
: null}
+ ;
+}
+
+export function RetainedQueueInputs({sessionId, turns, onChange}: {sessionId: string; turns: ChatQueuedTurn[]; onChange: () => void}) {
+ const zh = useWorkspaceI18n().locale === "zh-CN";
+ const [recovery, setRecovery] = useState(() => readSessionRecovery(sessionId));
+ const [attempt, setAttempt] = useState(0);
+ useEffect(() => {
+ const read = () => setRecovery(readSessionRecovery(sessionId));
+ read(); window.addEventListener("storage", read);
+ return () => window.removeEventListener("storage", read);
+ }, [sessionId, turns, attempt]);
+ const inputs = recovery.entries.filter(input => !turns.some(turn => turn.turn_id === input.turnId));
+ if (!inputs.length && !recovery.failed) return null;
+ return
+ {inputs.length ? {zh ? "保留的修改" : "Retained changes"} · {inputs.length}
+ {inputs.map(input => {setAttempt(value => value + 1); onChange();}}/>)} : null}
+ {recovery.failed ? {zh ? "队列修改的恢复信息不可读。" : "Queue change recovery is unreadable."}
: null}
+ ;
+}
diff --git a/examples/personal-workspace-browser/attached-host-follow-up.mjs b/examples/personal-workspace-browser/attached-host-follow-up.mjs
index 99c5ac80f4..09203ef3a4 100644
--- a/examples/personal-workspace-browser/attached-host-follow-up.mjs
+++ b/examples/personal-workspace-browser/attached-host-follow-up.mjs
@@ -172,6 +172,52 @@ export const attachedHostFollowUpScenario = {
await racedStop.click();
for (let attempt = 0; attempt < 100 && context.api.interrupts.length !== interruptsBefore + 2; attempt += 1) await page.waitForTimeout(50);
if (context.api.interrupts.at(-1)?.turnId !== racedTurnId) throw new Error("A native-queued admission took the other page's running Turn controls");
+ // A host may consume a queued input while its editor is open. Retention
+ // must remain reachable after the row disappears, including after reload.
+ await page.setViewportSize({width: 1440, height: 900});
+ const retainedDraft = "保留这份尚未保存的更正。\n原消息启动后也不能把它重新发送或丢失。";
+ await queuedFollowUp.getByRole("button", {name: "更多操作:待处理消息 1", exact: true}).click();
+ await queuedFollowUp.getByRole("button", {name: "编辑", exact: true}).click();
+ await page.getByRole("textbox", {name: "编辑待处理消息 1", exact: true}).fill(retainedDraft);
+ await composer.fill("主输入框的另一份草稿");
+ page.__loopxRuntime.queuedTurns.set(attachedSessionId,
+ page.__loopxRuntime.queuedTurns.get(attachedSessionId).filter(turn => turn.turn_id !== "turn-queued-1"));
+ const recovery = page.getByRole("region", {name: "保留的队列修改", exact: true});
+ await recovery.waitFor({timeout: 15_000});
+ await recovery.locator("summary").first().click();
+ await recovery.locator(".personal-queue-retained-input > summary").click();
+ if (await recovery.getByRole("textbox", {name: "保留的编辑草稿", exact: true}).inputValue() !== retainedDraft) throw new Error("Consumed input lost its edit draft");
+ if (await composer.inputValue() !== "主输入框的另一份草稿" || posts.length !== 2) throw new Error("Draft recovery resent input or replaced the composer");
+ await page.evaluate(() => {
+ window.__queueStorageDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage");
+ Object.defineProperty(window, "localStorage", {configurable: true, get() {throw new Error("Storage unavailable");}});
+ window.dispatchEvent(new Event("storage"));
+ });
+ await recovery.getByRole("alert").filter({hasText: "队列修改的恢复信息不可读"}).waitFor();
+ await page.evaluate(() => {
+ Object.defineProperty(window, "localStorage", window.__queueStorageDescriptor);
+ window.dispatchEvent(new Event("storage"));
+ });
+ await page.reload();
+ await recovery.waitFor({timeout: 15_000});
+ await recovery.locator("summary").first().click();
+ await recovery.locator(".personal-queue-retained-input > summary").click();
+ const retainedText = recovery.getByRole("textbox", {name: "保留的编辑草稿", exact: true});
+ if (await retainedText.inputValue() !== retainedDraft) throw new Error("Reload hid a consumed input's retained draft");
+ await recovery.getByRole("button", {name: "选择草稿", exact: true}).click();
+ if (!await retainedText.evaluate(el => document.activeElement === el && el.selectionEnd === el.value.length && el.selectionStart === 0)) throw new Error("Retained draft is not keyboard-copyable");
+ await page.locator(".personal-goal-link", {hasText: managedGoal.label}).click();
+ await recovery.waitFor({state: "hidden"});
+ await page.locator(".personal-goal-link", {hasText: attachedGoal.label}).click();
+ await recovery.waitFor();
+ await recovery.locator("summary").first().click();
+ await recovery.locator(".personal-queue-retained-input > summary").click();
+ await page.setViewportSize({width: 390, height: 844});
+ const recoveryBounds = await recovery.boundingBox();
+ if (!recoveryBounds || recoveryBounds.x < 0 || recoveryBounds.x + recoveryBounds.width > 391) throw new Error("Draft recovery overflows narrow viewport");
+ await recovery.getByRole("button", {name: "清除草稿", exact: true}).click();
+ await recovery.waitFor({state: "hidden"});
+ if (posts.length !== 2) throw new Error("Reading or clearing retained edits sent another Turn");
} finally {
for (const route of heldStreams.splice(0)) await route.abort().catch(() => {});
await context.close();
From ee92578eac0f3283a564aab4fca3f5cf0c7195ec Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Thu, 5 Nov 2026 20:13:21 +0800
Subject: [PATCH 2/4] test(chat): release held history page after navigation
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../conversation-history-recovery.mjs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/examples/personal-workspace-browser/conversation-history-recovery.mjs b/examples/personal-workspace-browser/conversation-history-recovery.mjs
index 67ad3bc8d4..a423a148d8 100644
--- a/examples/personal-workspace-browser/conversation-history-recovery.mjs
+++ b/examples/personal-workspace-browser/conversation-history-recovery.mjs
@@ -12,6 +12,8 @@ async function pagedHistoryRecovery(browser, url) {
role: index % 2 ? "agent" : "user", text: index === 201 ? "原始长文。".repeat(2400) : `原始历史第 ${index} 条。`,
created_at: "2026-08-13T01:00:00Z"}));
const context = await openWorkspacePage(browser, url, {
+ // The second page stays in flight until this setup returns to the test.
+ gotoWaitUntil: "domcontentloaded",
async beforeGoto(_api, page) {
const runtime = page.__loopxRuntime;
runtime.sessions.clear();
From 4f1edbd0d44ddef04a64304d36a14f6e5b434679 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Thu, 5 Nov 2026 20:18:53 +0800
Subject: [PATCH 3/4] fix(chat): retry unreadable queue recovery in place
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../src/features/personal-workspace/retained-queue-inputs.tsx | 3 ++-
.../personal-workspace-browser/attached-host-follow-up.mjs | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx b/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx
index d858bb3f9d..6df45b7070 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/retained-queue-inputs.tsx
@@ -66,6 +66,7 @@ export function RetainedQueueInputs({sessionId, turns, onChange}: {sessionId: st
{inputs.length ? {zh ? "保留的修改" : "Retained changes"} · {inputs.length}
{inputs.map(input => {setAttempt(value => value + 1); onChange();}}/>)} : null}
- {recovery.failed ? {zh ? "队列修改的恢复信息不可读。" : "Queue change recovery is unreadable."}
: null}
+ {recovery.failed ? {zh ? "队列修改的恢复信息不可读。" : "Queue change recovery is unreadable."}
+
: null}
;
}
diff --git a/examples/personal-workspace-browser/attached-host-follow-up.mjs b/examples/personal-workspace-browser/attached-host-follow-up.mjs
index 09203ef3a4..0f269fefc1 100644
--- a/examples/personal-workspace-browser/attached-host-follow-up.mjs
+++ b/examples/personal-workspace-browser/attached-host-follow-up.mjs
@@ -196,8 +196,9 @@ export const attachedHostFollowUpScenario = {
await recovery.getByRole("alert").filter({hasText: "队列修改的恢复信息不可读"}).waitFor();
await page.evaluate(() => {
Object.defineProperty(window, "localStorage", window.__queueStorageDescriptor);
- window.dispatchEvent(new Event("storage"));
});
+ await recovery.getByRole("button", {name: "重试读取恢复信息", exact: true}).click();
+ await recovery.getByRole("alert").waitFor({state: "hidden"});
await page.reload();
await recovery.waitFor({timeout: 15_000});
await recovery.locator("summary").first().click();
From 890e0986068e2fa12aa587e55c5dd978c979b82b Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Thu, 5 Nov 2026 23:18:17 +0800
Subject: [PATCH 4/4] fix(chat): keep one reply across history and admission
races
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../src/data/conversation-returns.test.mjs | 25 +++++
.../src/data/conversation-returns.ts | 5 +
.../dashboard/src/views/dashboard-page.tsx | 97 +++++++++++-------
.../managed-agent-sessions.mjs | 98 ++++++++++++++++---
4 files changed, 179 insertions(+), 46 deletions(-)
diff --git a/apps/presentation/dashboard/src/data/conversation-returns.test.mjs b/apps/presentation/dashboard/src/data/conversation-returns.test.mjs
index 0cebf54a37..db2c63cc08 100644
--- a/apps/presentation/dashboard/src/data/conversation-returns.test.mjs
+++ b/apps/presentation/dashboard/src/data/conversation-returns.test.mjs
@@ -142,6 +142,31 @@ assert.equal(observedBoth[0].text, "Ask both");
assert.deepEqual(conversationPendingReturnSessions(observedBoth), []);
assert.equal(reconcileConversationReturns(observedBoth, "original", [{message_id: "multi"}], createReply), observedBoth);
+// History can be displayed before the original admission ACK supplies the
+// live row's identity. Its stream callback target must survive that handoff.
+const historyFirst = {sourceSessionId: "current", sourceMessageId: "agent-message", sourceTurnId: "same-turn",
+ role: "assistant", text: "Stored answer", marker: "history"};
+const lateBoundStream = {sourceSessionId: "current", sourceTurnId: "same-turn", role: "assistant",
+ text: "Live fragment", pending: true, marker: "stream", activity: ["Running"]};
+const admittedAfterRead = reconcileConversationHistory([historyFirst, lateBoundStream], [storedRoles[1]], createHistory);
+assert.equal(admittedAfterRead.length, 1, "One canonical reply remains when history arrived first");
+assert.equal(admittedAfterRead[0].marker, "stream", "The original stream callback target and controls survive");
+assert.equal(admittedAfterRead[0].sourceMessageId, "agent-message");
+assert.equal(admittedAfterRead[0].pending, true);
+assert.equal(admittedAfterRead[0].text, "Live fragment", "Readback does not overwrite streaming text");
+assert.deepEqual(admittedAfterRead[0].activity, ["Running"]);
+assert.equal(reconcileConversationHistory(admittedAfterRead, [storedRoles[1]], createHistory), admittedAfterRead);
+const separateReplies = [historyFirst, lateBoundStream,
+ {...historyFirst, sourceSessionId: "foreign", marker: "foreign"},
+ {...historyFirst, sourceMessageId: "another-answer", sourceTurnId: "another-turn", marker: "another-turn"},
+ {...historyFirst, sourceMessageId: "followup", marker: "followup"}];
+const separateSources = [storedRoles[1], {...storedRoles[1], message_id: "followup", origin: "manager_followup"},
+ {...storedRoles[1], message_id: "another-answer", turn_id: "another-turn"}];
+const distinct = reconcileConversationHistory(separateReplies, separateSources, createHistory);
+assert.deepEqual(distinct.map(row => row.marker), ["stream", "foreign", "another-turn", "followup"],
+ "Equal text, other Sessions/Turns and separate follow-up identities remain distinct");
+console.log("conversation-returns: history-before-admission preserves the live identity and distinct replies");
+
// A revised brief keeps its return obligation despite a delivered old conclusion.
const revised = [{sourceSessionId: "original", collaborations: [
{request_id: "a", request_revision: 2, returns: [{phase: "conclusion", status: "delivered", request_revision: 1}]},
diff --git a/apps/presentation/dashboard/src/data/conversation-returns.ts b/apps/presentation/dashboard/src/data/conversation-returns.ts
index 20b4e4c5fc..6404e98339 100644
--- a/apps/presentation/dashboard/src/data/conversation-returns.ts
+++ b/apps/presentation/dashboard/src/data/conversation-returns.ts
@@ -112,10 +112,15 @@ export function reconcileConversationHistory(
}
// A history read can arrive before admission hydrates the optimistic row.
// Deduplicate only established Session/message identities, never body text.
+ // A live row owns the stream callbacks and controls even when the history
+ // row appeared first. Keep that row when both acquire the same identity.
+ const pendingIdentities = new Set(updated.filter(message => message.pending && message.sourceSessionId && message.sourceMessageId)
+ .map(message => JSON.stringify([message.sourceSessionId, message.sourceMessageId])));
const identities = new Set();
const unique = updated.filter((message) => {
if (!message.sourceSessionId || !message.sourceMessageId) return true;
const key = JSON.stringify([message.sourceSessionId, message.sourceMessageId]);
+ if (pendingIdentities.has(key) && !message.pending) return false;
if (identities.has(key)) return false;
identities.add(key);
return true;
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx
index 542093f88b..c7235bc23b 100644
--- a/apps/presentation/dashboard/src/views/dashboard-page.tsx
+++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx
@@ -1523,6 +1523,7 @@ function PersonalGoalHome({
const newSessionRequired = useRef(new Set());
const activeTurnIds = useRef(new Map());
const streamControllers = useRef(new Map());
+ const pendingTurnAdmissions = useRef(new Map());
const preparationControllers = useRef(new Map());
const interruptedTurnIds = useRef(new Set());
const recoveringTurnKeys = useRef(new Map());
@@ -1740,31 +1741,42 @@ function PersonalGoalHome({
}
}, [selectedAgents]);
- useEffect(() => {
- if (!conversationHistory.history) return;
- const messages = conversationHistory.history.messages;
+ // Admission and history can arrive in either order. Stream callbacks need
+ // the current read owner, with its exact context, rather than the snapshot
+ // captured when the original send began.
+ const reconcileReadHistory = useRef<(targetContextId: string, previous: PersonalManagerMessage[], sessionId?: string) => PersonalManagerMessage[]>(
+ (_targetContextId, previous) => previous,
+ );
+ reconcileReadHistory.current = (targetContextId, previous, sessionId) => {
+ if (targetContextId !== contextId || !conversationHistory.history) return previous;
+ const messages = sessionId ? conversationHistory.history.messages.filter(message => message.session_id === sessionId)
+ : conversationHistory.history.messages;
const sessionLabels = new Map(conversationHistory.history.sessions.map(session => [session.session_id,
runtimeAgents.find(agent => agent.agent_id === session.agent_id)?.display_name ?? session.agent_id]));
+ return reconcileConversationHistory(previous, messages, (message): PersonalManagerMessage => ({
+ sourceMessageId: message.message_id,
+ sourceSessionId: message.session_id,
+ sourceTurnId: message.turn_id ?? undefined,
+ sourceCreatedAt: message.created_at,
+ goalDraft: normalizeGoalDraft(message.goal_draft),
+ agentLabel: message.role === "user" ? message.origin === "peer" ? (locale === "zh-CN" ? "协作者" : "Collaborator") : undefined : message.origin === "manager_followup"
+ ? "协作回执" : answerIdentityLabel(contextId, sessionLabels.get(message.session_id ?? "") ?? selectedAgent.label),
+ attachments: workspaceImageAttachments(message.attachments),
+ id: managerMessageId.current++, lines: [],
+ role: message.role === "user" ? "user" : "assistant",
+ returnDelivery: message.return_delivery, collaboration: message.collaboration, collaborations: message.collaborations,
+ replyContext: message.reply_context,
+ queueInputRevision: message.queue_input_revision, queueInputCancelled: message.queue_input_cancelled,
+ sourceLabel: message.role === "user" ? undefined : message.role === "error" ? "本地会话记录"
+ : contextId === "manager" ? `恢复的${t("header.manager")}会话` : `恢复的 ${sessionLabels.get(message.session_id ?? "") ?? selectedAgent.label} 会话`,
+ text: message.role === "user" ? message.reply_context?.source_text ?? message.text : visibleAgentMessage(message.text),
+ }));
+ };
+ useEffect(() => {
+ if (!conversationHistory.history) return;
setMessagesByContext((current) => {
const previous = current[contextId] ?? [];
- const updated = reconcileConversationHistory(previous, messages, (message): PersonalManagerMessage => ({
- sourceMessageId: message.message_id,
- sourceSessionId: message.session_id,
- sourceTurnId: message.turn_id ?? undefined,
- sourceCreatedAt: message.created_at,
- goalDraft: normalizeGoalDraft(message.goal_draft),
- agentLabel: message.role === "user" ? message.origin === "peer" ? (locale === "zh-CN" ? "协作者" : "Collaborator") : undefined : message.origin === "manager_followup"
- ? "协作回执" : answerIdentityLabel(contextId, sessionLabels.get(message.session_id ?? "") ?? selectedAgent.label),
- attachments: workspaceImageAttachments(message.attachments),
- id: managerMessageId.current++, lines: [],
- role: message.role === "user" ? "user" : "assistant",
- returnDelivery: message.return_delivery, collaboration: message.collaboration, collaborations: message.collaborations,
- replyContext: message.reply_context,
- queueInputRevision: message.queue_input_revision, queueInputCancelled: message.queue_input_cancelled,
- sourceLabel: message.role === "user" ? undefined : message.role === "error" ? "本地会话记录"
- : contextId === "manager" ? `恢复的${t("header.manager")}会话` : `恢复的 ${sessionLabels.get(message.session_id ?? "") ?? selectedAgent.label} 会话`,
- text: message.role === "user" ? message.reply_context?.source_text ?? message.text : visibleAgentMessage(message.text),
- }));
+ const updated = reconcileReadHistory.current(contextId, previous);
return updated === previous ? current : { ...current, [contextId]: updated };
});
}, [conversationHistory.history, contextId, selectedAgent.label, runtimeAgents]);
@@ -1838,10 +1850,13 @@ function PersonalGoalHome({
// A source index can observe the Turn this very composer is already
// streaming. Exact contexts already key ownership by their Session;
// keep that live observer instead of replacing its controls and reply.
+ // The history may discover our native Turn before the original ACK
+ // supplies its id. Its in-flight admission still owns observation.
if ((targetContext.kind === "bound" || targetContext.kind === "managed")
&& targetContext.sessionId === created.session_id
- && activeTurnIds.current.get(targetContextId) === activeTurnId
- && streamControllers.current.get(targetContextId)?.signal.aborted === false) return;
+ && (pendingTurnAdmissions.current.get(targetContextId)?.signal.aborted === false
+ || (activeTurnIds.current.get(targetContextId) === activeTurnId
+ && streamControllers.current.get(targetContextId)?.signal.aborted === false))) return;
const recoveryKey = `${created.session_id}:${activeTurnId}`;
if (recoveringTurnKeys.current.has(recoveryKey)) return;
const observer = new AbortController();
@@ -2244,13 +2259,14 @@ function PersonalGoalHome({
message: Omit,
) {
const id = managerMessageId.current++;
- setMessagesByContext((messages) => ({
- ...messages,
- [targetContextId]: [
+ setMessagesByContext((messages) => {
+ const updated: PersonalManagerMessage[] = [
...(messages[targetContextId] ?? []),
{ sourceCreatedAt: new Date().toISOString(), startedAt: message.pending ? Date.now() : undefined, updatedAt: Date.now(), ...message, id, role: "assistant" },
- ],
- }));
+ ];
+ return {...messages, [targetContextId]: message.sourceSessionId && message.sourceTurnId
+ ? reconcileReadHistory.current(targetContextId, updated, message.sourceSessionId) : updated};
+ });
return id;
}
@@ -2259,9 +2275,14 @@ function PersonalGoalHome({
messageId: number,
update: Partial>,
) {
- setMessagesByContext((messages) => ({
- ...messages,
- [targetContextId]: (messages[targetContextId] ?? []).filter((message) =>
+ setMessagesByContext((messages) => {
+ const previous = messages[targetContextId] ?? [];
+ const original = previous.find(message => message.id === messageId);
+ if (!original) return messages;
+ const identityChanged = (update.sourceSessionId !== undefined && update.sourceSessionId !== original.sourceSessionId)
+ || (update.sourceTurnId !== undefined && update.sourceTurnId !== original.sourceTurnId)
+ || ("sourceMessageId" in update && update.sourceMessageId !== original.sourceMessageId);
+ const updated = previous.filter((message) =>
// A read may arrive before the original send's storage receipt. Keep
// the live row when its exact persisted identity becomes known.
message.id === messageId || !update.sourceMessageId || !update.sourceSessionId
@@ -2270,8 +2291,11 @@ function PersonalGoalHome({
...(update.text !== undefined || update.activity !== undefined ? { updatedAt: Date.now() } : {}),
...(message.pending && update.pending === false ? { preparing: false, endedAt: Date.now() } : {}),
...update,
- } : message),
- }));
+ } : message);
+ return {...messages, [targetContextId]: identityChanged
+ ? reconcileReadHistory.current(targetContextId, updated, update.sourceSessionId ?? original.sourceSessionId)
+ : updated};
+ });
}
async function prepareGoalConversation(goalId: string, agentId: string, signal?: AbortSignal) {
@@ -2411,7 +2435,10 @@ function PersonalGoalHome({
signal: (() => {
const controller = new AbortController();
streamController = controller;
- if (!route?.queue) streamControllers.current.set(targetContextId, controller);
+ if (!route?.queue) {
+ streamControllers.current.set(targetContextId, controller);
+ pendingTurnAdmissions.current.set(targetContextId, controller);
+ }
return controller.signal;
})(),
onDelta: (delta: string) => {
@@ -2427,6 +2454,7 @@ function PersonalGoalHome({
}));
},
onPhase: (phase: string, turnId: string) => {
+ if (pendingTurnAdmissions.current.get(targetContextId) === streamController) pendingTurnAdmissions.current.delete(targetContextId);
if (submittedTurnId !== turnId) updateConversationMessage(targetContextId, userMessageId, {
sourceTurnId: turnId, sourceSessionId: sessionId,
...(route?.queue ? {sourceMessageId: undefined} : {}),
@@ -2665,6 +2693,7 @@ function PersonalGoalHome({
&& payloadError.turn_replay_safe === true)) throw error;
} finally {
streamEnded = true;
+ if (pendingTurnAdmissions.current.get(targetContextId) === streamController) pendingTurnAdmissions.current.delete(targetContextId);
// A handed-off Turn is still running: its ownership stays for the
// recovery that adopts it or the read that finds it ended.
const ownsTurn = !handedOff && activeTurnIds.current.get(targetContextId) === submittedTurnId;
diff --git a/examples/personal-workspace-browser/managed-agent-sessions.mjs b/examples/personal-workspace-browser/managed-agent-sessions.mjs
index 3d60a9c779..5b8335007f 100644
--- a/examples/personal-workspace-browser/managed-agent-sessions.mjs
+++ b/examples/personal-workspace-browser/managed-agent-sessions.mjs
@@ -49,12 +49,42 @@ async function verifyManagedTurnObservers({page, origin, sessions, panel, choose
await page.reload({waitUntil: "domcontentloaded"});
await chooser.selectOption(`managed:${sessionId}`);
await panel.getByText("Review the release checklist before the next handoff.", {exact: true}).waitFor();
- const accepted = page.waitForResponse(response => response.request().method() === "POST"
- && response.url() === `${origin}/api/chat/sessions/${sessionId}/turns`);
- await composer.fill("Review the remaining release steps; wait for interrupt.");
- await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled);
- await composer.press("Enter");
- const ownTurn = (await (await accepted).json()).turn_id;
+ const admissionPath = `/api/chat/sessions/${sessionId}/turns`;
+ let releaseAdmission, admitted;
+ const heldAdmission = new Promise(resolve => {releaseAdmission = resolve;});
+ const admissionReady = new Promise(resolve => {admitted = resolve;});
+ const admissionMatch = url => url.origin === origin && url.pathname === admissionPath;
+ const holdAdmission = async route => {
+ if (route.request().method() !== "POST") {await route.fallback(); return;}
+ const response = await route.fetch();
+ admitted((await response.json()).turn_id);
+ await heldAdmission;
+ await route.fulfill({response});
+ };
+ await page.route(admissionMatch, holdAdmission);
+ let ownTurn, admissionTimeout;
+ try {
+ const accepted = page.waitForResponse(response => response.request().method() === "POST"
+ && response.url() === origin + admissionPath);
+ await composer.fill("Review the remaining release steps; wait for interrupt.");
+ await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled);
+ await composer.press("Enter");
+ ownTurn = await Promise.race([admissionReady, new Promise((_, reject) => {
+ admissionTimeout = setTimeout(() => reject(new Error("The original admission did not reach its owning backend")), 12_000);
+ })]);
+ clearTimeout(admissionTimeout);
+ // History can discover this active native Turn before the original ACK.
+ // Its composer already owns observation: recovery must not add a stream.
+ await afterHistoryPoll(ownTurn);
+ assert.equal(requests.length, 0, "An original admission in flight must not acquire a recovery observer");
+ assert.equal(await pending.count(), 1, "The original pending reply survives active history discovery");
+ releaseAdmission();
+ assert.equal((await (await accepted).json()).turn_id, ownTurn);
+ } finally {
+ clearTimeout(admissionTimeout);
+ releaseAdmission();
+ await page.unroute(admissionMatch, holdAdmission);
+ }
await afterHistoryPoll(ownTurn);
await page.screenshot({path: resolve(outputDir, "managed-observer-desktop.png")});
await page.setViewportSize({width: 390, height: 844});
@@ -149,13 +179,57 @@ export const managedAgentSessionsScenario = {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
assert.equal(await panel.getByText("Original managed question 1", {exact: true}).count(), 0);
assert.equal(await composer.inputValue(), "Retained draft for second Session");
- await composer.fill("Owner correction only to second Session");
- await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled);
- await composer.press("Enter");
- await panel.getByText("Runtime response.", {exact: true}).waitFor().catch(async error => {
- console.log(JSON.stringify({diagnostic: await control("inspect"), body: await page.locator("body").innerText(), ui: await composer.evaluate(node => ({value: node.value, disabled: node.disabled, readOnly: node.readOnly, form: node.closest("form")?.outerHTML.slice(-1000)})), errors: context.errors, stderr: fixture.stderr}));
+ // The owning backend may finish and publish history before its original
+ // admission response reaches the composer. Hold that real response,
+ // rather than forging a second message or loosening the reply selector.
+ let releaseAdmission, releaseSnapshots;
+ const admissionHeld = new Promise(resolve => {releaseAdmission = resolve;});
+ const snapshotsHeld = new Promise(resolve => {releaseSnapshots = resolve;});
+ let holdSnapshots = false;
+ const sessionPath = "/api/chat/sessions/" + sessions[1];
+ const snapshotMatch = url => url.origin === origin && url.pathname === sessionPath;
+ const admissionMatch = url => url.origin === origin && url.pathname === sessionPath + "/turns";
+ const holdSnapshot = async route => {
+ const response = await route.fetch();
+ if (holdSnapshots) await snapshotsHeld;
+ await route.fulfill({response});
+ };
+ const holdAdmission = async route => {
+ if (route.request().method() !== "POST") {await route.fallback(); return;}
+ const response = await route.fetch();
+ await admissionHeld;
+ await route.fulfill({response});
+ };
+ await page.route(snapshotMatch, holdSnapshot);
+ await page.route(admissionMatch, holdAdmission);
+ try {
+ await composer.fill("Owner correction only to second Session");
+ await page.waitForFunction(() => !document.querySelector('button[aria-label="发送"]')?.disabled);
+ await composer.press("Enter");
+ await panel.getByText("Runtime response.", {exact: true}).waitFor({timeout: 12_000});
+ const streamResponse = page.waitForResponse(response => new URL(response.url()).origin === origin
+ && new URL(response.url()).pathname.startsWith(sessionPath + "/turns/")
+ && new URL(response.url()).pathname.endsWith("/events"), {timeout: 12_000});
+ // Keep the canonical-identity follow-up read outstanding while the
+ // original stream completes. Both legal orderings must show one reply.
+ holdSnapshots = true;
+ releaseAdmission();
+ assert.equal((await streamResponse).status(), 200);
+ await panel.locator(".personal-message-pending").waitFor({state: "hidden"});
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+ await panel.getByText("Runtime response.", {exact: true}).waitFor();
+ assert.equal(await panel.getByText("Runtime response.", {exact: true}).count(), 1,
+ "History before the original admission ACK must not duplicate the same native reply");
+ assert.equal(await panel.locator(".personal-message").count(), (await control("inspect")).messages[sessions[1]].length,
+ "The settled UI contains exactly the owning Session's saved messages");
+ } catch (error) {
+ console.log(JSON.stringify({diagnostic: await control("inspect"), body: await page.locator("body").innerText(), errors: context.errors}));
throw error;
- });
+ } finally {
+ releaseAdmission(); releaseSnapshots();
+ await page.unroute(admissionMatch, holdAdmission);
+ await page.unroute(snapshotMatch, holdSnapshot);
+ }
const accepted = await control("inspect");
assert.equal(accepted.sessions, 4, "Continuing uses the original Session, without replacements");
assert.ok(!accepted.messages[sessions[0]].some(row => row.text === "Owner correction only to second Session"));