From 46d16c510b1598f3a0781877e52510b6a8a2569d Mon Sep 17 00:00:00 2001 From: song Date: Mon, 28 Sep 2026 23:01:51 +0800 Subject: [PATCH 1/7] fix(workspace): wait for a running Turn before sending The Chat service accepts one Turn per Session. After a reload the page only knew about its own in-flight send, so while a recovered Turn was still running the composer stayed open and a new message was rejected with HTTP 409, shown as the raw English service error. While the conversation shows a Turn in flight, the send button and quick prompts wait and the composer explains that the Turn can be adjusted or interrupted from the reply. LoopX mode keeps delivering into its running Turn. A 409 that still arrives is shown as the same localized message. Signed-off-by: song --- .../src/features/personal-workspace/i18n.tsx | 2 ++ .../personal-workspace-page.tsx | 25 +++++++++++++------ .../personal-workspace/personal-workspace.css | 1 + .../dashboard/src/views/dashboard-page.tsx | 2 ++ 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 5d077767c3..e76c687f95 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -95,6 +95,7 @@ const en = { "composer.send": "Send", "composer.sendMessage": "Send a message to LoopX", "composer.sendMessageHint": "Send message", + "composer.turnRunning": "A turn is in progress. Adjust or interrupt it from the reply, then send.", "composer.sentImageAlt": "Remove image {name}", "conversation.agentPending": "Working…", "conversation.close": "Close conversation receipt", @@ -1265,6 +1266,7 @@ const zhCN: Record = { "composer.send": "发送", "composer.sendMessage": "向 LoopX 发送消息", "composer.sendMessageHint": "发送消息", + "composer.turnRunning": "本轮回答进行中。可在回答里调整或中断本轮,结束后再发送。", "composer.sentImageAlt": "移除图片 {name}", "conversation.agentPending": "正在整理…", "conversation.close": "关闭对话回执", 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 dab614eca8..68b07cc8a6 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 @@ -993,6 +993,15 @@ export function PersonalWorkspacePage({ setGoalConversationReceiptVisible(true); } }, [goalMessages, selectedGoal, selectedGoalTab]); + // The Chat service accepts one Turn per Session. While this conversation shows + // a Turn in flight, a new message would only be rejected, so the composer + // waits and points to the reply's own adjust/interrupt controls. LoopX mode + // delivers into a running Turn through its own queue, so it stays open. + const loopxDeliveryOpen = Boolean(conversationSessionId && loopxMode?.session_id === conversationSessionId + && loopxMode?.enabled && loopxMode.active_turn_id); + const conversationTurnRunning = !loopxDeliveryOpen + && managerMessages.some((message) => message.pending && Boolean(message.sourceTurnId)); + const composerBlocked = sending || conversationTurnRunning; const managerChatItems = useMemo( () => items.filter((item) => item.kind === "message" || (item.kind === "proposal" && (sessionProposalIds.includes(item.proposal.previewId) @@ -1577,6 +1586,7 @@ export function PersonalWorkspacePage({ finally {setSending(false);} return; } + if (conversationTurnRunning) return; if (!messageOverride) { setComposer(""); setImageAttachments([]); @@ -1867,16 +1877,16 @@ export function PersonalWorkspacePage({ {locale === "zh-CN" ? "快捷提问" : "Suggestions"} {selectedGoal ? (
- - + + - - + +
) : (
- - + +
)} @@ -1889,6 +1899,7 @@ export function PersonalWorkspacePage({ ))} : null} {imageAttachmentError ?

{imageAttachmentError}

: null} + {conversationTurnRunning ?

{t("composer.turnRunning")}

: null}
{ @@ -1929,7 +1940,7 @@ export function PersonalWorkspacePage({ rows={1} value={composer} /> - +
} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 45257e8b74..d54f13286d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1078,6 +1078,7 @@ .personal-composer-images img { width: 100%; height: 100%; object-fit: cover; border: 1px solid var(--pw-line-strong); border-radius: 10px; background: #fff; } .personal-composer-images button { position: absolute; top: -5px; right: -5px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 1px solid var(--pw-line-strong); border-radius: 50%; background: #fff; color: var(--pw-text); cursor: pointer; box-shadow: 0 1px 4px rgb(30 28 20 / 18%); } .personal-composer-error { margin: 0 0 7px; color: var(--pw-red); font-size: 11.5px; } +.personal-composer-status { margin: 0 0 7px; color: var(--pw-muted); font-size: 12px; } .personal-context-drawer { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100vh; } .personal-drawer-header { display: flex; align-items: flex-start; justify-content: space-between; min-height: 84px; padding: 20px 18px 16px; border-bottom: 1px solid var(--pw-line); } diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index d589a01065..14d0be9ba0 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -2267,6 +2267,8 @@ function PersonalGoalHome({ sourceLabel: "LoopX Chat 本地后端", text: payloadError?.error_code === "resume_failed" ? `原 ${answerIdentityLabel(targetContextId, selectedRoute.label)} 会话无法恢复。本地历史已经保留,请在运行详情里选择“重试恢复”或“开始新 Session”。` + : payloadError?.active_turn_id + ? t("composer.turnRunning") : error instanceof Error ? error.message : `${answerIdentityLabel(targetContextId, selectedRoute.label)} 会话暂时不可用。`, From 4929e8fa9c240ca0e01e30f85ede9aeb02eee19c Mon Sep 17 00:00:00 2001 From: song Date: Mon, 28 Sep 2026 23:01:51 +0800 Subject: [PATCH 2/7] test(workspace): cover the composer during a recovered running Turn Sends a long Turn, reloads while it runs, and requires the composer to wait with its hint, send nothing, and reopen once the Turn completes. Signed-off-by: song --- .../chat-recovery.mjs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 12cbc7ba0f..246d381a30 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -260,6 +260,31 @@ export const chatRecoveryScenario = { observations.push(`Refresh recovery failure: ${error.message}`); } + // The Chat service accepts one Turn per Session. After a reload the page + // only learns about a running Turn from the Session snapshot, so the + // composer must wait for it instead of sending into a 409. + const turnsBeforeRunningCheck = api.turnRequests.length; + await page.getByLabel("向 LoopX 发送消息").fill("刷新后验证中断控制:输入框应等待本轮。"); + await page.getByRole("button", { name: "发送", exact: true }).click(); + while (api.turnRequests.length === turnsBeforeRunningCheck) await page.waitForTimeout(50); + await page.reload({ waitUntil: "domcontentloaded" }); + await page.getByTestId("personal-goal-home").waitFor({ state: "visible" }); + await page.locator(".personal-goal-link").first().click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + const turnRunningHint = page.locator(".personal-composer-status", { hasText: "本轮回答进行中" }); + await turnRunningHint.waitFor({ state: "visible", timeout: 5_000 }); + await page.getByLabel("向 LoopX 发送消息").fill("回合进行中不应发送"); + if (!await page.getByRole("button", { name: "发送", exact: true }).isDisabled()) { + throw new Error("Composer stayed sendable while the recovered Turn was running"); + } + await turnRunningHint.waitFor({ state: "hidden", timeout: 10_000 }); + if (await page.getByRole("button", { name: "发送", exact: true }).isDisabled()) { + throw new Error("Composer stayed blocked after the running Turn completed"); + } + if (api.turnRequests.length !== turnsBeforeRunningCheck + 1) throw new Error("A message was sent while the Turn was running"); + await page.getByLabel("向 LoopX 发送消息").fill(""); + pass("composer-running-turn", "After a reload the composer waits for the running Turn and reopens when it completes"); + if (failures.length) throw new Error(failures.join(" | ")); } finally { await context.close(); From 4ee26f4f292b3b07bd58ef0987d8cb3538c4285f Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:47:36 -0400 Subject: [PATCH 3/7] fix(workspace): retire a cancelled recovery's pending reply Leaving a conversation cancels its Turn recovery, but the recovery's pending placeholder was never settled. Returning started a new recovery with its own placeholder, so after the Turn completed the stale one stayed pending and the composer guard kept Send disabled. A cancelled recovery now removes its placeholder; the next recovery streams the same Turn from the start into a fresh one. The composer guard also only counts pending replies from the current Session, so a new Session never inherits an older Session's placeholder. The chat-recovery scenario leaves the Goal during a recovered Turn, checks the other Goal's composer is free, returns before completion and requires Send to reopen when the Turn completes. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../personal-workspace/personal-workspace-page.tsx | 7 ++++--- .../dashboard/src/views/dashboard-page.tsx | 9 +++++++++ .../personal-workspace-browser/chat-recovery.mjs | 13 ++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) 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 68b07cc8a6..c7bfc6027f 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 @@ -993,14 +993,15 @@ export function PersonalWorkspacePage({ setGoalConversationReceiptVisible(true); } }, [goalMessages, selectedGoal, selectedGoalTab]); - // The Chat service accepts one Turn per Session. While this conversation shows + // The Chat service accepts one Turn per Session. While the current Session shows // a Turn in flight, a new message would only be rejected, so the composer // waits and points to the reply's own adjust/interrupt controls. LoopX mode // delivers into a running Turn through its own queue, so it stays open. const loopxDeliveryOpen = Boolean(conversationSessionId && loopxMode?.session_id === conversationSessionId && loopxMode?.enabled && loopxMode.active_turn_id); - const conversationTurnRunning = !loopxDeliveryOpen - && managerMessages.some((message) => message.pending && Boolean(message.sourceTurnId)); + const conversationTurnRunning = !loopxDeliveryOpen && Boolean(conversationSessionId) + && managerMessages.some((message) => message.pending && Boolean(message.sourceTurnId) + && message.sourceSessionId === conversationSessionId); const composerBlocked = sending || conversationTurnRunning; const managerChatItems = useMemo( () => items.filter((item) => item.kind === "message" diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 14d0be9ba0..f25b7ff647 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1774,6 +1774,15 @@ function PersonalGoalHome({ text: interrupted ? [streamedText.trim(), "已中断。你可以在当前会话继续发送消息。"].filter(Boolean).join("\n\n") : error instanceof Error ? error.message : "无法恢复进行中的 Agent 回合。", }); } finally { + // A cancelled recovery never settles its placeholder. Retire it, so + // it cannot stay pending beside the placeholder of the recovery that + // replaces it when the user returns to this conversation. + if (cancelled) { + setMessagesByContext((messages) => ({ + ...messages, + [targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== streamingMessageId), + })); + } recoveringTurnKeys.current.delete(recoveryKey); if (activeTurnIds.current.get(targetContextId) === activeTurnId) { activeTurnIds.current.delete(targetContextId); diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 246d381a30..c521c3c997 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -277,13 +277,24 @@ export const chatRecoveryScenario = { if (!await page.getByRole("button", { name: "发送", exact: true }).isDisabled()) { throw new Error("Composer stayed sendable while the recovered Turn was running"); } + // Leaving cancels the recovery and returning starts a new one for the + // same Turn. The cancelled recovery must not leave a pending reply that + // keeps the composer blocked after the Turn completes. + await page.locator(".personal-goal-link").nth(1).click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await page.getByLabel("向 LoopX 发送消息").waitFor({ state: "visible" }); + if (await turnRunningHint.count()) throw new Error("Another Goal's composer waited for this Goal's running Turn"); + await page.locator(".personal-goal-link").first().click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await turnRunningHint.waitFor({ state: "visible", timeout: 5_000 }); + await page.getByLabel("向 LoopX 发送消息").fill("回合进行中不应发送"); await turnRunningHint.waitFor({ state: "hidden", timeout: 10_000 }); if (await page.getByRole("button", { name: "发送", exact: true }).isDisabled()) { throw new Error("Composer stayed blocked after the running Turn completed"); } if (api.turnRequests.length !== turnsBeforeRunningCheck + 1) throw new Error("A message was sent while the Turn was running"); await page.getByLabel("向 LoopX 发送消息").fill(""); - pass("composer-running-turn", "After a reload the composer waits for the running Turn and reopens when it completes"); + pass("composer-running-turn", "After a reload, and after leaving and returning, the composer waits for the running Turn and reopens when it completes"); if (failures.length) throw new Error(failures.join(" | ")); } finally { From 9741362697602d7ff4e555ca51f0f58ccad1325e Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:05:57 -0400 Subject: [PATCH 4/7] fix(workspace): adopt the running Turn a 409 send reports When another page started a Turn after this page's last snapshot, the ordinary send was the first to learn of it: the service answered 409 with active_turn_id. The page only translated that into text, then marked the Session ready, so Send stayed enabled, the reply had no adjust/interrupt controls, the draft was gone, and a second click posted again. A 409 carrying active_turn_id now withdraws the unaccepted message and its placeholder, asks the existing Turn recovery effect to re-read the Session and adopt the running Turn (pending reply with Session/Turn identity, runtime binding and controls), and rejects the send so the composer keeps the draft and images. The chat-recovery scenario makes the Session busy behind the page's back, answers the POST with the service's 409 payload, and requires one POST, a kept draft, visible adjust/interrupt controls, Send disabled until the Turn completes, and no stale user bubble. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../dashboard/src/views/dashboard-page.tsx | 23 ++++++++++-- .../chat-recovery.mjs | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index f25b7ff647..f8d5061391 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1401,6 +1401,9 @@ function PersonalGoalHome({ const [sendingContextId, setSendingContextId] = useState(null); const [runtimeBindings, setRuntimeBindings] = useState>({}); const [executionSessions, setExecutionSessions] = useState([]); + // Bumped when the service reports a running Turn this page did not know + // about, so the Turn recovery effect re-reads the Session and adopts it. + const [turnRecoveryRequest, setTurnRecoveryRequest] = useState(0); const [executionDiscoveryError, setExecutionDiscoveryError] = useState<"partial" | "offline" | null>(null); const [executionSessionSnapshots, setExecutionSessionSnapshots] = useState>({}); // undefined: not read yet; null: the session owner could not be read. @@ -1819,7 +1822,7 @@ function PersonalGoalHome({ cancelled = true; recoveryController?.abort(); }; - }, [contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents]); + }, [contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents, turnRecoveryRequest]); useEffect(() => { if (readOnly) return; @@ -2251,6 +2254,22 @@ function PersonalGoalHome({ return; } const payloadError = error instanceof ChatApiError ? error.payload : null; + const runningTurnId = !route?.loopxMode && typeof payloadError?.active_turn_id === "string" + ? payloadError.active_turn_id + : ""; + if (runningTurnId) { + // The Session already runs a Turn this page had not seen, so the + // message was not accepted. Withdraw it, let the recovery effect adopt + // the running Turn with its identity and controls, and reject the send + // so the composer keeps the draft. + setMessagesByContext((messages) => ({ + ...messages, + [targetContextId]: (messages[targetContextId] ?? []).filter((message) => + message.id !== userMessageId && message.id !== streamingMessageId), + })); + if (targetContextId === contextId) setTurnRecoveryRequest((current) => current + 1); + throw new ChatApiError(t("composer.turnRunning"), payloadError ?? {}); + } if (payloadError && sessionInvalidatedByPayload(payloadError)) { sessionIds.current.delete(sessionKey); } @@ -2276,8 +2295,6 @@ function PersonalGoalHome({ sourceLabel: "LoopX Chat 本地后端", text: payloadError?.error_code === "resume_failed" ? `原 ${answerIdentityLabel(targetContextId, selectedRoute.label)} 会话无法恢复。本地历史已经保留,请在运行详情里选择“重试恢复”或“开始新 Session”。` - : payloadError?.active_turn_id - ? t("composer.turnRunning") : error instanceof Error ? error.message : `${answerIdentityLabel(targetContextId, selectedRoute.label)} 会话暂时不可用。`, diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index c521c3c997..a2355b829f 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -296,6 +296,42 @@ export const chatRecoveryScenario = { await page.getByLabel("向 LoopX 发送消息").fill(""); pass("composer-running-turn", "After a reload, and after leaving and returning, the composer waits for the running Turn and reopens when it completes"); + // Another page starts a Turn after this page's last snapshot, so the + // ordinary POST is the first to learn of it: the service answers 409 + // with the running Turn. The page must adopt that Turn with its + // controls, keep the draft, and stay blocked until the Turn completes. + const busySessionId = api.turnRequests.at(-1).sessionId; + const foreignTurnId = `turn-foreign-${Date.now()}`; + page.__loopxRuntime.turnMessages.set(foreignTurnId, "另一页面发起的中断控制回合"); + page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: foreignTurnId, status: "busy" }); + let rejectedPosts = 0; + await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + rejectedPosts += 1; + await route.fulfill({ contentType: "application/json", status: 409, + json: { ok: false, error: "another turn is already running for this session", active_turn_id: foreignTurnId } }); + }); + const draft = "这条消息在另一回合运行时发出"; + const composerInput = page.getByLabel("向 LoopX 发送消息"); + const sendButton = page.getByRole("button", { name: "发送", exact: true }); + await composerInput.fill(draft); + await sendButton.click(); + await turnRunningHint.waitFor({ state: "visible", timeout: 5_000 }); + await page.getByRole("button", { name: "中断本轮" }).waitFor({ state: "visible" }); + await page.getByRole("button", { name: "调整本轮" }).waitFor({ state: "visible" }); + if (await composerInput.inputValue() !== draft) throw new Error("The draft rejected by a running Turn was not kept"); + if (!(await sendButton.isDisabled())) throw new Error("Send stayed enabled after the service reported a running Turn"); + await sendButton.click({ force: true }); + if (await page.locator(".personal-channel-timeline .personal-message").filter({ hasText: draft }).count()) { + throw new Error("A message the service did not accept stayed in the conversation"); + } + await turnRunningHint.waitFor({ state: "hidden", timeout: 10_000 }); + if (await sendButton.isDisabled()) throw new Error("Send stayed blocked after the adopted Turn completed"); + if (rejectedPosts !== 1) throw new Error(`The composer posted ${rejectedPosts} times into a running Turn`); + await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); + await composerInput.fill(""); + pass("composer-running-turn-409", "A 409 running-Turn receipt is adopted with its controls, keeps the draft and blocks Send until completion"); + if (failures.length) throw new Error(failures.join(" | ")); } finally { await context.close(); From 6c56202029989c9328a3efc034cf22c81ec114c7 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:32:52 -0400 Subject: [PATCH 5/7] fix(workspace): keep Send closed through the 409 Turn handoff After a 409 reported a running Turn, the send withdrew its reply and asked the recovery effect to re-read the Session. Until that read came back no pending reply carried the Turn, the send's finally had cleared sending, and Send was usable again, so a second click posted again. The 409 now turns the send's reply into the running Turn's pending reply at once (Session and Turn identity, adjust/interrupt controls), so the composer guard holds from the receipt on. The recovery effect adopts that reply instead of appending another, and a run that finds no such Turn retires it so the composer reopens. The chat-recovery scenario holds the post-409 Session re-read and requires Send and the sending quick prompts to stay disabled, a forced click to post nothing, and exactly one pending reply after adoption. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../dashboard/src/views/dashboard-page.tsx | 40 ++++++++++++++++--- .../chat-recovery.mjs | 32 ++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index f8d5061391..7cafc48725 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1416,6 +1416,10 @@ function PersonalGoalHome({ const streamControllers = useRef(new Map()); const interruptedTurnIds = useRef(new Set()); const recoveringTurnKeys = useRef(new Set()); + // A running Turn a 409 reported, keyed by context: its pending reply holds + // the composer closed until the recovery effect adopts it or finds no such + // Turn, so the handoff never leaves a sendable gap. + const turnHandoffs = useRef(new Map()); const agentMenuRef = useRef(null); const agentTriggerRef = useRef(null); const detailsCloseRef = useRef(null); @@ -1694,7 +1698,9 @@ function PersonalGoalHome({ recoveryController = new AbortController(); streamControllers.current.set(targetContextId, recoveryController); let streamedText = ""; - const streamingMessageId = appendManagerAssistantMessage(targetContextId, { + const handoff = turnHandoffs.current.get(targetContextId); + if (handoff?.turnId === activeTurnId) turnHandoffs.current.delete(targetContextId); + const streamingMessageId = handoff?.turnId === activeTurnId ? handoff.messageId : appendManagerAssistantMessage(targetContextId, { activity: ["正在恢复进行中的 Agent 回合"], sourceTurnId: activeTurnId, sourceSessionId: created.session_id, @@ -1816,6 +1822,17 @@ function PersonalGoalHome({ }); } } + } finally { + // A reported Turn this run did not adopt has ended (or belongs to + // another Session), so its reply no longer holds the composer. + const unadopted = turnHandoffs.current.get(targetContextId); + if (!cancelled && unadopted) { + turnHandoffs.current.delete(targetContextId); + setMessagesByContext((messages) => ({ + ...messages, + [targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== unadopted.messageId), + })); + } } })(); return () => { @@ -2092,6 +2109,7 @@ function PersonalGoalHome({ const sessionKey = `${targetContextId}:${selectedRoute.agentId}`; let streamingMessageId: number | null = null; let submittedTurnId: string | undefined; + let submittedSessionId: string | undefined; let streamedText = ""; try { let sessionId = targetContextId === "manager" ? sessionIds.current.get(sessionKey) : await prepareGoalConversation(targetContextId, selectedRoute.agentId); @@ -2118,6 +2136,7 @@ function PersonalGoalHome({ }); newSessionRequired.current.delete(sessionKey); } + submittedSessionId = sessionId; streamingMessageId = appendManagerAssistantMessage(targetContextId, { activity: [targetContextId === "manager" ? "正在连接管家" : "正在连接 Agent"], agentLabel: answerIdentityLabel(targetContextId, selectedRoute.label), @@ -2259,14 +2278,23 @@ function PersonalGoalHome({ : ""; if (runningTurnId) { // The Session already runs a Turn this page had not seen, so the - // message was not accepted. Withdraw it, let the recovery effect adopt - // the running Turn with its identity and controls, and reject the send - // so the composer keeps the draft. + // message was not accepted. Withdraw it and turn its reply into the + // running Turn's pending reply at once, so the composer stays closed + // while the recovery effect re-reads the Session and adopts that + // reply. Rejecting the send keeps the draft. setMessagesByContext((messages) => ({ ...messages, - [targetContextId]: (messages[targetContextId] ?? []).filter((message) => - message.id !== userMessageId && message.id !== streamingMessageId), + [targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== userMessageId), })); + if (streamingMessageId !== null) { + updateManagerAssistantMessage(targetContextId, streamingMessageId, { + activity: ["正在接管进行中的 Agent 回合"], + pending: true, + sourceSessionId: submittedSessionId, + sourceTurnId: runningTurnId, + }); + turnHandoffs.current.set(targetContextId, { messageId: streamingMessageId, turnId: runningTurnId }); + } if (targetContextId === contextId) setTurnRecoveryRequest((current) => current + 1); throw new ChatApiError(t("composer.turnRunning"), payloadError ?? {}); } diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index a2355b829f..daa6204702 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -311,13 +311,43 @@ export const chatRecoveryScenario = { await route.fulfill({ contentType: "application/json", status: 409, json: { ok: false, error: "another turn is already running for this session", active_turn_id: foreignTurnId } }); }); + // Hold the Session re-read that follows the 409, so the check covers + // the handoff before the recovery adopts the Turn, not only after. + const heldReads = []; + let holdReads = false; + await page.route("**/api/chat/sessions?*", async (route) => { + if (!holdReads || route.request().method() !== "GET") return route.fallback(); + heldReads.push(route); + }); const draft = "这条消息在另一回合运行时发出"; const composerInput = page.getByLabel("向 LoopX 发送消息"); const sendButton = page.getByRole("button", { name: "发送", exact: true }); await composerInput.fill(draft); + holdReads = true; await sendButton.click(); + for (let attempt = 0; attempt < 100 && (!rejectedPosts || !heldReads.length); attempt += 1) await page.waitForTimeout(50); + if (!heldReads.length) throw new Error("The 409 did not make the page re-read the Session"); + for (let check = 0; check < 10; check += 1) { + if (!(await sendButton.isDisabled())) throw new Error("Send reopened before the recovery adopted the reported Turn"); + await page.waitForTimeout(100); + } + await sendButton.click({ force: true }); + await page.locator(".personal-composer-tools > summary").click(); + // The schedule shortcut opens a draft instead of sending, so it stays usable. + const quickPrompts = page.locator(".personal-quick-prompts button[aria-label]:not([aria-label=\"配置定时检查\"])"); + for (const prompt of await quickPrompts.all()) { + if (!(await prompt.isDisabled())) throw new Error(`Quick prompt "${await prompt.getAttribute("aria-label")}" stayed usable during the handoff`); + } + await page.locator(".personal-composer-tools > summary").click(); + if (rejectedPosts !== 1) throw new Error(`The composer posted ${rejectedPosts} times before the recovery adopted the Turn`); + holdReads = false; + for (const route of heldReads.splice(0)) await route.fallback(); + await page.unroute("**/api/chat/sessions?*"); await turnRunningHint.waitFor({ state: "visible", timeout: 5_000 }); await page.getByRole("button", { name: "中断本轮" }).waitFor({ state: "visible" }); + if (await page.getByRole("button", { name: "中断本轮" }).count() !== 1) { + throw new Error("The recovery added a second pending reply instead of adopting the handoff reply"); + } await page.getByRole("button", { name: "调整本轮" }).waitFor({ state: "visible" }); if (await composerInput.inputValue() !== draft) throw new Error("The draft rejected by a running Turn was not kept"); if (!(await sendButton.isDisabled())) throw new Error("Send stayed enabled after the service reported a running Turn"); @@ -330,7 +360,7 @@ export const chatRecoveryScenario = { if (rejectedPosts !== 1) throw new Error(`The composer posted ${rejectedPosts} times into a running Turn`); await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); await composerInput.fill(""); - pass("composer-running-turn-409", "A 409 running-Turn receipt is adopted with its controls, keeps the draft and blocks Send until completion"); + pass("composer-running-turn-409", "A 409 running-Turn receipt keeps Send closed through the handoff, is adopted with its controls, keeps the draft and blocks Send until completion"); if (failures.length) throw new Error(failures.join(" | ")); } finally { From 0dc1946c022f39d733927b7ff3620f84eaf297fa Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:46:23 -0400 Subject: [PATCH 6/7] fix(workspace): keep the 409 handoff when the Session read fails The recovery effect's outer finally retired an unadopted 409 handoff on any exit that was not a cancel, including a failed Session read. A read that failed says nothing about the reported Turn, so Send reopened while that Turn could still run. Retire the handoff only after an authoritative answer: a Session read that did not adopt the Turn, or the service refusing the resume. On any other failure keep the pending reply, with its Turn controls, and re-read the Session with a bounded backoff (1s doubling to 10s). The backoff only schedules re-reads; whether the Turn ended still comes from the snapshot. chat-recovery covers 409, a 503 Session read, a held retry with Send closed, adoption and completion; it fails on the previous head with "Send reopened after the Session read following the 409 failed". Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../dashboard/src/views/dashboard-page.tsx | 34 ++++++++++--- .../chat-recovery.mjs | 51 +++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 7cafc48725..72c77879ae 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1417,9 +1417,11 @@ function PersonalGoalHome({ const interruptedTurnIds = useRef(new Set()); const recoveringTurnKeys = useRef(new Set()); // A running Turn a 409 reported, keyed by context: its pending reply holds - // the composer closed until the recovery effect adopts it or finds no such - // Turn, so the handoff never leaves a sendable gap. - const turnHandoffs = useRef(new Map()); + // the composer closed until the recovery effect adopts it or an + // authoritative Session read finds no such Turn, so the handoff never leaves + // a sendable gap. A failed read is no such finding: it keeps the handoff and + // counts the attempt toward the next re-read's backoff. + const turnHandoffs = useRef(new Map()); const agentMenuRef = useRef(null); const agentTriggerRef = useRef(null); const detailsCloseRef = useRef(null); @@ -1602,6 +1604,8 @@ function PersonalGoalHome({ let cancelled = false; let recoveryController: AbortController | null = null; let latestDiscoveredSessionId: string | null = null; + let sessionReadFailed = false; + let handoffRetryTimer: number | undefined; void (async () => { try { const history = await fetchChatHistory({ @@ -1811,6 +1815,9 @@ function PersonalGoalHome({ } } catch (error) { if (cancelled) return; + // The service refusing the resume is an answer about the Session; any + // other failure left this run without one. + sessionReadFailed = !(error instanceof ChatApiError && error.payload.error_code === "resume_failed"); if (error instanceof ChatApiError && error.payload.error_code === "resume_failed") { newSessionRequired.current.add(sessionKey); if (latestDiscoveredSessionId) { @@ -1823,10 +1830,22 @@ function PersonalGoalHome({ } } } finally { - // A reported Turn this run did not adopt has ended (or belongs to - // another Session), so its reply no longer holds the composer. + // A reported Turn this run read the Session but did not adopt has + // ended (or belongs to another Session), so its reply no longer holds + // the composer. When the read itself failed the Turn may still run: + // keep the reply pending, with its Turn controls, and read again. const unadopted = turnHandoffs.current.get(targetContextId); - if (!cancelled && unadopted) { + if (!cancelled && unadopted && sessionReadFailed) { + const failedReads = unadopted.failedReads + 1; + turnHandoffs.current.set(targetContextId, { ...unadopted, failedReads }); + updateManagerAssistantMessage(targetContextId, unadopted.messageId, { + activity: ["暂时无法读取会话状态,正在重试"], + }); + handoffRetryTimer = window.setTimeout( + () => setTurnRecoveryRequest((current) => current + 1), + Math.min(1000 * 2 ** (failedReads - 1), 10_000), + ); + } else if (!cancelled && unadopted) { turnHandoffs.current.delete(targetContextId); setMessagesByContext((messages) => ({ ...messages, @@ -1838,6 +1857,7 @@ function PersonalGoalHome({ return () => { cancelled = true; recoveryController?.abort(); + window.clearTimeout(handoffRetryTimer); }; }, [contextId, model.goals[0]?.goalId, readOnly, selectedGoal?.goalId, selectedAgent.agentId, selectedAgent.available, selectedAgent.label, selectedAgents, turnRecoveryRequest]); @@ -2293,7 +2313,7 @@ function PersonalGoalHome({ sourceSessionId: submittedSessionId, sourceTurnId: runningTurnId, }); - turnHandoffs.current.set(targetContextId, { messageId: streamingMessageId, turnId: runningTurnId }); + turnHandoffs.current.set(targetContextId, { failedReads: 0, messageId: streamingMessageId, turnId: runningTurnId }); } if (targetContextId === contextId) setTurnRecoveryRequest((current) => current + 1); throw new ChatApiError(t("composer.turnRunning"), payloadError ?? {}); diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index daa6204702..3d3aeedc37 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -362,6 +362,57 @@ export const chatRecoveryScenario = { await composerInput.fill(""); pass("composer-running-turn-409", "A 409 running-Turn receipt keeps Send closed through the handoff, is adopted with its controls, keeps the draft and blocks Send until completion"); + // A failed Session read after the 409 says nothing about the reported + // Turn, so the handoff must keep Send closed and read again rather than + // treat the failure as the Turn having ended. + const retriedTurnId = `turn-foreign-retry-${Date.now()}`; + page.__loopxRuntime.turnMessages.set(retriedTurnId, "读取失败后仍在运行的中断控制回合"); + page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: retriedTurnId, status: "busy" }); + let retryRejectedPosts = 0; + await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + retryRejectedPosts += 1; + await route.fulfill({ contentType: "application/json", status: 409, + json: { ok: false, error: "another turn is already running for this session", active_turn_id: retriedTurnId } }); + }); + let failedReads = 0; + const heldRetryReads = []; + let failReads = false; + await page.route("**/api/chat/sessions?*", async (route) => { + if (!failReads || route.request().method() !== "GET") return route.fallback(); + if (failedReads === 0) { + failedReads += 1; + return route.fulfill({ contentType: "application/json", status: 503, json: { ok: false, error: "chat store temporarily unavailable" } }); + } + heldRetryReads.push(route); + }); + await composerInput.fill(draft); + failReads = true; + await sendButton.click(); + for (let attempt = 0; attempt < 200 && !heldRetryReads.length; attempt += 1) { + if (failedReads && !(await sendButton.isDisabled())) throw new Error("Send reopened after the Session read following the 409 failed"); + await page.waitForTimeout(50); + } + if (!failedReads) throw new Error("The 409 did not make the page re-read the Session"); + if (!heldRetryReads.length) throw new Error("A failed Session read was not retried while the reported Turn could still run"); + if (!(await sendButton.isDisabled())) throw new Error("Send reopened while the retried Session read was pending"); + await turnRunningHint.waitFor({ state: "visible", timeout: 5_000 }); + await page.getByRole("button", { name: "中断本轮" }).waitFor({ state: "visible" }); + if (retryRejectedPosts !== 1) throw new Error(`The composer posted ${retryRejectedPosts} times while the Session read was failing`); + failReads = false; + for (const route of heldRetryReads.splice(0)) await route.fallback(); + await page.unroute("**/api/chat/sessions?*"); + if (await page.getByRole("button", { name: "中断本轮" }).count() !== 1) { + throw new Error("The retried recovery added a second pending reply instead of adopting the handoff reply"); + } + if (await composerInput.inputValue() !== draft) throw new Error("The draft rejected by a running Turn was not kept across the failed read"); + await turnRunningHint.waitFor({ state: "hidden", timeout: 10_000 }); + if (await sendButton.isDisabled()) throw new Error("Send stayed blocked after the retried recovery saw the Turn complete"); + if (retryRejectedPosts !== 1) throw new Error(`The composer posted ${retryRejectedPosts} times into a running Turn`); + await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); + await composerInput.fill(""); + pass("composer-running-turn-409-read-failure", "A failed Session read after a 409 keeps Send closed with the Turn controls, is retried, adopts the Turn and reopens only once it completes"); + if (failures.length) throw new Error(failures.join(" | ")); } finally { await context.close(); From 29bc039888379db96c7fab8a002f29f244ec6e0f Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:47:24 -0400 Subject: [PATCH 7/7] fix(workspace): let the 409 handoff own its Turn controls From the 409 on, the pending reply shows Adjust and Interrupt for the reported Turn, but both handlers act only when activeTurnIds and the runtime binding name that Turn. Only recovery adoption set them, and the rejected send's finally reset them to ready, so until a Session read returned both controls failed locally with "already ended". The 409 receipt now records the reported Session and Turn as the context's active Turn and running binding, and the rejected send's finally leaves them to the handoff. The handoff carries its Session, so a Session read that finds the Turn ended retires that ownership with the reply. An interrupt during the handoff has no stream to settle the reply, so its receipt settles it and reopens Send. chat-recovery holds every Session read after the 409, then adjusts and interrupts from the handoff reply and checks both requests name the exact Session and Turn; a late read must not revive the Turn. The scenario fails on the previous head at the adjustment. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- .../dashboard/src/views/dashboard-page.tsx | 52 ++++++++++++++-- .../chat-recovery.mjs | 61 +++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 72c77879ae..20c2176d89 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1420,8 +1420,10 @@ function PersonalGoalHome({ // the composer closed until the recovery effect adopts it or an // authoritative Session read finds no such Turn, so the handoff never leaves // a sendable gap. A failed read is no such finding: it keeps the handoff and - // counts the attempt toward the next re-read's backoff. - const turnHandoffs = useRef(new Map()); + // counts the attempt toward the next re-read's backoff. From the 409 on, the + // reported Session and Turn also own the context's Turn controls, so the + // Adjust/Interrupt the pending reply shows act on that exact Turn. + const turnHandoffs = useRef(new Map()); const agentMenuRef = useRef(null); const agentTriggerRef = useRef(null); const detailsCloseRef = useRef(null); @@ -1847,6 +1849,15 @@ function PersonalGoalHome({ ); } else if (!cancelled && unadopted) { turnHandoffs.current.delete(targetContextId); + if (activeTurnIds.current.get(targetContextId) === unadopted.turnId) { + activeTurnIds.current.delete(targetContextId); + recordRuntimeBinding(targetContextId, { + agentId: unadopted.agentId, + resumable: true, + sessionId: unadopted.sessionId, + status: "ready", + }); + } setMessagesByContext((messages) => ({ ...messages, [targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== unadopted.messageId), @@ -2130,6 +2141,7 @@ function PersonalGoalHome({ let streamingMessageId: number | null = null; let submittedTurnId: string | undefined; let submittedSessionId: string | undefined; + let handedOff = false; let streamedText = ""; try { let sessionId = targetContextId === "manager" ? sessionIds.current.get(sessionKey) : await prepareGoalConversation(targetContextId, selectedRoute.agentId); @@ -2306,14 +2318,29 @@ function PersonalGoalHome({ ...messages, [targetContextId]: (messages[targetContextId] ?? []).filter((message) => message.id !== userMessageId), })); - if (streamingMessageId !== null) { + if (streamingMessageId !== null && submittedSessionId) { updateManagerAssistantMessage(targetContextId, streamingMessageId, { activity: ["正在接管进行中的 Agent 回合"], pending: true, sourceSessionId: submittedSessionId, sourceTurnId: runningTurnId, }); - turnHandoffs.current.set(targetContextId, { failedReads: 0, messageId: streamingMessageId, turnId: runningTurnId }); + turnHandoffs.current.set(targetContextId, { + agentId: selectedRoute.agentId, + failedReads: 0, + messageId: streamingMessageId, + sessionId: submittedSessionId, + turnId: runningTurnId, + }); + activeTurnIds.current.set(targetContextId, runningTurnId); + recordRuntimeBinding(targetContextId, { + agentId: selectedRoute.agentId, + resumable: true, + sessionId: submittedSessionId, + status: "running", + turnId: runningTurnId, + }); + handedOff = true; } if (targetContextId === contextId) setTurnRecoveryRequest((current) => current + 1); throw new ChatApiError(t("composer.turnRunning"), payloadError ?? {}); @@ -2353,10 +2380,12 @@ function PersonalGoalHome({ updateManagerAssistantMessage(targetContextId, streamingMessageId, failureMessage); } } finally { - activeTurnIds.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. + if (!handedOff) activeTurnIds.current.delete(targetContextId); streamControllers.current.delete(targetContextId); const boundSessionId = sessionIds.current.get(sessionKey); - if (boundSessionId) { + if (boundSessionId && !handedOff) { recordRuntimeBinding(targetContextId, { agentId: selectedRoute.agentId, resumable: true, @@ -2392,6 +2421,17 @@ function PersonalGoalHome({ sessionId, status: "ready", }); + // No stream settles a Turn still in its 409 handoff, so the receipt + // settles its pending reply. + const handoff = turnHandoffs.current.get(targetContextId); + if (handoff?.turnId === turnId) { + turnHandoffs.current.delete(targetContextId); + updateManagerAssistantMessage(targetContextId, handoff.messageId, { + lines: [], + pending: false, + text: "已中断。你可以在当前会话继续发送消息。", + }); + } setSendingContextId((current) => current === targetContextId ? null : current); } } diff --git a/examples/personal-workspace-browser/chat-recovery.mjs b/examples/personal-workspace-browser/chat-recovery.mjs index 3d3aeedc37..aa37f820ea 100644 --- a/examples/personal-workspace-browser/chat-recovery.mjs +++ b/examples/personal-workspace-browser/chat-recovery.mjs @@ -413,6 +413,67 @@ export const chatRecoveryScenario = { await composerInput.fill(""); pass("composer-running-turn-409-read-failure", "A failed Session read after a 409 keeps Send closed with the Turn controls, is retried, adopts the Turn and reopens only once it completes"); + // The pending reply shows Adjust/Interrupt from the 409 on, so before any + // Session read returns, both must act on the exact reported Turn. + const controlledTurnId = `turn-foreign-controls-${Date.now()}`; + page.__loopxRuntime.turnMessages.set(controlledTurnId, "交接期间可调整和中断的中断控制回合"); + page.__loopxRuntime.sessions.set(busySessionId, { ...page.__loopxRuntime.sessions.get(busySessionId), active_turn_id: controlledTurnId, status: "busy" }); + let controlRejectedPosts = 0; + await page.route(`**/api/chat/sessions/${busySessionId}/turns`, async (route) => { + if (route.request().method() !== "POST") return route.fallback(); + controlRejectedPosts += 1; + await route.fulfill({ contentType: "application/json", status: 409, + json: { ok: false, error: "another turn is already running for this session", active_turn_id: controlledTurnId } }); + }); + const heldControlReads = []; + await page.route("**/api/chat/sessions?*", async (route) => { + if (route.request().method() !== "GET") return route.fallback(); + heldControlReads.push(route); + }); + const steers = []; + await page.route("**/steer", async (route) => { + const body = route.request().postDataJSON(); + const [sessionId, turnId] = new URL(route.request().url()).pathname.match(/sessions\/([^/]+)\/turns\/([^/]+)\/steer/).slice(1); + steers.push({ sessionId, turnId }); + await route.fulfill({ json: { ok: true, session_id: sessionId, turn_id: turnId, client_ingress_id: body.client_ingress_id, status: "delivered" } }); + }); + const interruptsBefore = api.interrupts.length; + await composerInput.fill(draft); + await sendButton.click(); + for (let attempt = 0; attempt < 100 && (!controlRejectedPosts || !heldControlReads.length); attempt += 1) await page.waitForTimeout(50); + if (!heldControlReads.length) throw new Error("The 409 did not make the page re-read the Session"); + const handoffReply = page.locator(".personal-message").filter({ has: page.getByRole("button", { name: "中断本轮", exact: true }) }); + await handoffReply.getByRole("button", { name: "调整本轮", exact: true }).click(); + await handoffReply.getByLabel("追加给本轮的指令").fill("交接期间先核对依赖。"); + await handoffReply.getByRole("button", { name: "发送调整", exact: true }).click(); + await handoffReply.getByText("执行器已接收本轮追加指令。", { exact: true }).waitFor({ timeout: 5_000 }); + if (steers.length !== 1 || steers[0].sessionId !== busySessionId || steers[0].turnId !== controlledTurnId) { + throw new Error(`Adjust during the handoff did not reach the reported Turn: ${JSON.stringify(steers)}`); + } + if (!(await sendButton.isDisabled())) throw new Error("Send reopened after adjusting the handed-off Turn"); + await handoffReply.getByRole("button", { name: "中断本轮", exact: true }).click(); + for (let attempt = 0; attempt < 100 && api.interrupts.length === interruptsBefore; attempt += 1) await page.waitForTimeout(50); + const interrupted = api.interrupts.slice(interruptsBefore); + if (interrupted.length !== 1 || interrupted[0].sessionId !== busySessionId || interrupted[0].turnId !== controlledTurnId) { + throw new Error(`Interrupt during the handoff did not reach the reported Turn: ${JSON.stringify(interrupted)}`); + } + await page.locator(".personal-message").filter({ hasText: "已中断。你可以在当前会话继续发送消息。" }).last().waitFor({ timeout: 5_000 }); + await turnRunningHint.waitFor({ state: "hidden", timeout: 5_000 }); + if (await sendButton.isDisabled()) throw new Error("Send stayed blocked after the handed-off Turn was interrupted"); + if (await composerInput.inputValue() !== draft) throw new Error("The draft rejected by a running Turn was not kept through the handoff controls"); + for (const route of heldControlReads.splice(0)) await route.fallback(); + await page.unroute("**/api/chat/sessions?*"); + await page.waitForTimeout(500); + if (await page.getByRole("button", { name: "中断本轮", exact: true }).count()) { + throw new Error("A late Session read revived the interrupted handoff Turn"); + } + if (await sendButton.isDisabled()) throw new Error("A late Session read closed Send after the interrupt"); + if (controlRejectedPosts !== 1) throw new Error(`The composer posted ${controlRejectedPosts} times during the handoff`); + await page.unroute("**/steer"); + await page.unroute(`**/api/chat/sessions/${busySessionId}/turns`); + await composerInput.fill(""); + pass("composer-running-turn-409-controls", "Before any Session read returns, Adjust and Interrupt on the 409 handoff reply reach the exact reported Session and Turn, and the interrupt settles the reply and reopens Send"); + if (failures.length) throw new Error(failures.join(" | ")); } finally { await context.close();