From e50b45dff3744905425e60bad3af37f607193da0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:07:10 +0800 Subject: [PATCH 1/3] Retry an interrupted original work record at the same position Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> (cherry picked from commit ed77f42b94e8416c361b0320b875b6c5abfe40a4) Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../attached-agent-conversations.tsx | 31 +++++++++++++++++-- .../attached-conversation-record.tsx | 22 +++++++++---- .../session-runtime-control-plane-adapter.md | 3 ++ .../codex-attached.mjs | 29 +++++++++++++++-- .../codex_attached_fixture.py | 12 ++++++- 5 files changed, 85 insertions(+), 12 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx index 29de78c83c..7f490d8abd 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-agent-conversations.tsx @@ -403,11 +403,36 @@ export function AttachedConversation({goalId, binding, zh, reconnect}: { if (draggingScroll.current && Math.abs(delta) > 1) scrollIntent(Math.sign(delta)); else if (scrollDirection.current) pageAtEdge(scrollDirection.current); } - async function loadContent(record: Message, offset: number): Promise { + async function loadContent(record: Message, offset: number, signal: AbortSignal): Promise { + const current = generation.current; + const source = history.current?.source; + const ensureCurrent = () => { + signal.throwIfAborted(); + if (!source || rejectedRead.current || current !== generation.current || source !== history.current?.source) { + throw new Error(zh ? "原记录已变化,请重新打开这条记录。" : "The original record changed; reopen it to continue."); + } + }; const params = new URLSearchParams(query); params.set("entry_id", record.id); params.set("content_offset", String(offset)); - params.set("source_ref", history.current?.source ?? ""); params.set("content_ref", record.content_ref ?? ""); - return request(`/api/chat/codex-attached?${params}`); + params.set("source_ref", source ?? ""); params.set("content_ref", record.content_ref ?? ""); + const read = async () => { + ensureCurrent(); + const page = await request(`/api/chat/codex-attached?${params}`, {signal}); + ensureCurrent(); + return page; + }; + try { + return await read(); + } catch (failure) { + if (!retryableRead(failure)) throw failure; + // Retry only this read: the binding, record fingerprint and offset stay fixed. + try { + return await read(); + } catch (retryFailure) { + if (!retryableRead(retryFailure)) throw retryFailure; + throw new Error(zh ? "暂时无法继续读取,已读内容保留。" : "Could not read more of this record. Existing text is retained."); + } + } } async function send() { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx index 916bb59dd1..9ddb6fba90 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx @@ -10,11 +10,12 @@ export type ConversationRecord = { tool_exit_codes?: number[]; }; export type ContentPage = {text: string; next_offset: number; has_more: boolean}; +type LoadContent = (record: ConversationRecord, offset: number, signal: AbortSignal) => Promise; /** Keep source order, but give dialogue priority over consecutive tool events. */ export function AttachedConversationTimeline({records, ...props}: { records: ConversationRecord[]; agent: string; zh: boolean; - load: (record: ConversationRecord, offset: number) => Promise; + load: LoadContent; live?: boolean; active?: boolean; }) { const groupKeys = useRef(new Map()); @@ -80,7 +81,7 @@ function ToolContent({text, complete, zh}: {text: string; complete: boolean; zh: export function AttachedConversationRecord({record, agent, zh, load, live = false, active = false}: { record: ConversationRecord; agent: string; zh: boolean; - load: (record: ConversationRecord, offset: number) => Promise; + load: LoadContent; live?: boolean; active?: boolean; }) { @@ -90,6 +91,8 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [offset, setOffset] = useState(Array.from(record.text).length); + const contentRead = useRef(null); + useLayoutEffect(() => () => contentRead.current?.abort(), []); const labels: Record = zh ? { task_started: "开始执行", task_complete: "本轮结束", turn_aborted: "执行中断", error: "执行报错", function_call: "工具调用", custom_tool_call: "工具调用", @@ -104,13 +107,20 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals `${labels[record.kind] ?? (zh ? "记录暂不可读" : "Record unavailable")}${record.label && record.label !== record.kind ? ` · ${record.label}` : ""}`; const nonzeroCodes = [...new Set(record.tool_exit_codes?.filter(code => code !== 0))]; async function continueReading() { - if (loading) return; + if (contentRead.current) return; + const controller = new AbortController(); + contentRead.current = controller; setLoading(true); setError(""); try { - const page = await load(record, offset); + const page = await load(record, offset, controller.signal); + if (controller.signal.aborted) return; setText(previous => previous + page.text); setOffset(page.next_offset); setMore(page.has_more); - } catch (failure) {setError(String(failure));} - finally {setLoading(false);} + } catch (failure) { + if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + contentRead.current = null; + if (!controller.signal.aborted) setLoading(false); + } } const content = <> {record.redacted ?

{zh ? "此段包含疑似凭据,内容已隐藏。" : "This content contains credential-shaped data and has been withheld."}

: record.role === "tool" ? : } diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index 4163514740..f2503f3db2 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -299,6 +299,9 @@ record access and explicit interruption/error or unavailable-record notices; record counts do not imply tool success or task completion. Source and content fingerprints reject stale detail reads; history replacement resets the visible window instead of mixing conversations. +An interrupted detail read retries once at the same record and offset. A rejected +or stale read stays stopped; previously loaded text stays readable after a +transient failure, and the reader can request the same page again. An interrupted or timed-out history read keeps the records already displayed and explains that updates resume automatically when the connection recovers. Replies remain disabled until a fresh observation permits them. Changing the diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index 4bafe0c7c0..495df1df0b 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -618,7 +618,32 @@ async function runConversation(browser, uncertain) { await expandActivity(toolResult); await toolResult.locator('summary').click(); const recordId = await toolResult.getAttribute('data-record-id'); - let detailPages = 0; + const readMore = toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}); + const prefix = await toolResult.locator('pre').textContent(); + const detailRequests = []; + const observeDetail = request => { + const target = new URL(request.url()); + if (target.pathname === '/api/chat/codex-attached' && target.searchParams.get('entry_id') === recordId) { + detailRequests.push(target.search); + } + }; + page.on('request', observeDetail); + await page.request.post(new URL('/fixture/content-body-denied',url).href); + await readMore.click(); + await toolResult.getByRole('alert').waitFor(); + assert.equal(await toolResult.locator('pre').textContent(), prefix); + assert.equal(detailRequests.length, 1, 'A denied detail read must not retry when its body is lost'); + await page.request.post(new URL('/fixture/content-body-drop',url).href); + await readMore.click(); + await page.waitForFunction(({id, previous}) => { + const text = document.querySelector(`article[data-record-id="${CSS.escape(id)}"] pre`)?.textContent; + return text != null && text !== previous; + }, {id: recordId, previous: prefix}); + assert.equal(detailRequests.length, 3, 'A dropped success body recovers with one read after the explicit click'); + assert.equal(new Set(detailRequests).size, 1, 'All three requests keep the original binding, record and offset'); + assert.equal(await toolResult.getByRole('alert').count(), 0); + page.off('request', observeDetail); + let detailPages = 1; while (await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).count()) { const before = await toolResult.locator('pre').textContent(); await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).click(); @@ -629,13 +654,13 @@ async function runConversation(browser, uncertain) { await toolResult.getByRole('button',{name:'正在读取…',exact:true}).waitFor({state:'hidden'}); assert.ok(++detailPages <= 32, 'The bounded reference must finish reading'); } - assert.ok(detailPages > 0, 'The genuine reference crosses a detail-page boundary'); assert.equal(await toolResult.getByRole('button',{name:'继续读取完整内容',exact:true}).count(),0); // Unicode detail offsets must count source characters, not JS UTF-16 units. const body = await toolResult.locator('pre').textContent(); assert.ok(body.startsWith('🧭\n')); assert.deepEqual(JSON.parse(body.slice('🧭\n'.length)), benchmarkReference, 'Every real observation, original wording, negative/unknown result and provenance survives native paging'); + assert.equal(await inputCount(), 5, 'Recovering original content never submits a native reply'); await page.screenshot({path:resolve(outputDir,'attached-codex-benchmark-detail-desktop.png'),animations:'disabled'}); await page.setViewportSize({width:390,height:844}); assert.ok(await page.locator('body').evaluate(node => node.scrollWidth <= innerWidth + 1)); diff --git a/examples/personal-workspace-browser/codex_attached_fixture.py b/examples/personal-workspace-browser/codex_attached_fixture.py index c3dd8f374a..10c660feb9 100644 --- a/examples/personal-workspace-browser/codex_attached_fixture.py +++ b/examples/personal-workspace-browser/codex_attached_fixture.py @@ -94,13 +94,21 @@ def do_GET(self): and ( "before" in parse_qs(urlsplit(self.path).query) if body_fault == "history" + else "entry_id" in parse_qs(urlsplit(self.path).query) + if body_fault in {"content", "content-denied"} else "agent_id" not in parse_qs(urlsplit(self.path).query) ) ): # Send actual headers, then end the connection short of the # declared body length (or let the browser's read time out). mode, body_fault = body_fault, None - status = {"timeout": 503, "drop": 403, "history": 200}[mode] + status = { + "timeout": 503, + "drop": 403, + "history": 200, + "content": 200, + "content-denied": 403, + }[mode] self.send_response(status) self.send_header( "Content-Type", @@ -138,6 +146,8 @@ def do_POST(self): "/fixture/discovery-body-timeout": "timeout", "/fixture/discovery-body-drop": "drop", "/fixture/history-body-drop": "history", + "/fixture/content-body-drop": "content", + "/fixture/content-body-denied": "content-denied", } if self.path in faults: body_fault = faults[self.path] From c001197817fd052b4a89db190d97f5978758e45d Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 16:09:03 +0800 Subject: [PATCH 2/3] Cancel original record reads when their disclosure closes Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../attached-conversation-record.tsx | 59 +++++++++++-------- .../rfcs/monorepo-distribution-split-v0.md | 16 +++++ .../monorepo-distribution-split-v0.zh-CN.md | 12 ++++ .../session-runtime-control-plane-adapter.md | 3 + .../codex-attached.mjs | 25 +++++++- .../codex_attached_fixture.py | 13 +++- 6 files changed, 103 insertions(+), 25 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx index 9ddb6fba90..611ceddad0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx @@ -11,13 +11,10 @@ export type ConversationRecord = { }; export type ContentPage = {text: string; next_offset: number; has_more: boolean}; type LoadContent = (record: ConversationRecord, offset: number, signal: AbortSignal) => Promise; +type ConversationPresentation = {agent: string; zh: boolean; load: LoadContent; live?: boolean; active?: boolean}; /** Keep source order, but give dialogue priority over consecutive tool events. */ -export function AttachedConversationTimeline({records, ...props}: { - records: ConversationRecord[]; agent: string; zh: boolean; - load: LoadContent; - live?: boolean; active?: boolean; -}) { +export function AttachedConversationTimeline({records, ...props}: ConversationPresentation & {records: ConversationRecord[]}) { const groupKeys = useRef(new Map()); const nextKeys = new Map(); useLayoutEffect(() => {groupKeys.current = nextKeys;}); @@ -37,20 +34,27 @@ export function AttachedConversationTimeline({records, ...props}: { const tokens = item.map(record => JSON.stringify([record.id, record.content_ref])); const key = tokens.map(token => groupKeys.current.get(token)).find(Boolean) ?? tokens[0]; for (const token of tokens) nextKeys.set(token, key); - const interrupted = item.some(record => record.kind === "error" || record.kind === "turn_aborted"); - const gap = item.some(record => record.unavailable); - const nonzeroResults = item.filter(record => record.tool_exit_codes?.some(code => code !== 0)).length; - return
- record.id).join(",")}> - {props.live && item.some(record => record.role === "tool") ? : null} - {props.zh ? "工作过程" : "Work activity"} · {item.length} {props.zh ? "条记录" : "records"} + return ; + }); +} + +function ActivityGroup({records, ...props}: ConversationPresentation & {records: ConversationRecord[]}) { + const [visible, setVisible] = useState(false); + const interrupted = records.some(record => record.kind === "error" || record.kind === "turn_aborted"); + const gap = records.some(record => record.unavailable); + const nonzeroResults = records.filter(record => record.tool_exit_codes?.some(code => code !== 0)).length; + return
{ + if (event.target === event.currentTarget) setVisible(event.currentTarget.open); + }}> + record.id).join(",")}> + {props.live && records.some(record => record.role === "tool") ? : null} + {props.zh ? "工作过程" : "Work activity"} · {records.length} {props.zh ? "条记录" : "records"} {interrupted ? {props.zh ? "含中断或报错" : "Includes an interruption or error"} : null} {nonzeroResults ? {props.zh ? `${nonzeroResults} 条结果含非零退出码` : `${nonzeroResults} results with nonzero exit codes`} : null} {gap ? {props.zh ? "部分记录不可读" : "Some records unavailable"} : null} -
{item.map(record => )}
+
{records.map(record => )}
; - }); } function ActivityMark({active, zh}: {active: boolean; zh: boolean}) { @@ -79,11 +83,8 @@ function ToolContent({text, complete, zh}: {text: string; complete: boolean; zh: return
{text}
; } -export function AttachedConversationRecord({record, agent, zh, load, live = false, active = false}: { - record: ConversationRecord; agent: string; zh: boolean; - load: LoadContent; - live?: boolean; - active?: boolean; +export function AttachedConversationRecord({record, agent, zh, load, live = false, active = false, visible = true}: ConversationPresentation & { + record: ConversationRecord; visible?: boolean; }) { const [storedText, setText] = useState(record.text); const text = live ? record.text : storedText; @@ -93,6 +94,14 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals const [offset, setOffset] = useState(Array.from(record.text).length); const contentRead = useRef(null); useLayoutEffect(() => () => contentRead.current?.abort(), []); + useLayoutEffect(() => {if (!visible) cancelRead();}, [visible]); + function cancelRead() { + const controller = contentRead.current; + if (!controller) return; + contentRead.current = null; + controller.abort(); + setLoading(false); + } const labels: Record = zh ? { task_started: "开始执行", task_complete: "本轮结束", turn_aborted: "执行中断", error: "执行报错", function_call: "工具调用", custom_tool_call: "工具调用", @@ -107,7 +116,7 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals `${labels[record.kind] ?? (zh ? "记录暂不可读" : "Record unavailable")}${record.label && record.label !== record.kind ? ` · ${record.label}` : ""}`; const nonzeroCodes = [...new Set(record.tool_exit_codes?.filter(code => code !== 0))]; async function continueReading() { - if (contentRead.current) return; + if (!visible || contentRead.current) return; const controller = new AbortController(); contentRead.current = controller; setLoading(true); setError(""); @@ -118,8 +127,10 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals } catch (failure) { if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : String(failure)); } finally { - contentRead.current = null; - if (!controller.signal.aborted) setLoading(false); + if (contentRead.current === controller) { + contentRead.current = null; + if (!controller.signal.aborted) setLoading(false); + } } } const content = <> @@ -131,6 +142,8 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals return
{record.role !== "tool" ?
{heading}
: null} {record.unavailable ?

{zh ? "源记录无法安全读取;此处保留缺口,未将其当作完成。" : "This source record could not be read safely. The gap is retained, not treated as completion."}

: - record.role === "tool" ?
{heading}{more ? {zh ? " · 可继续加载" : " · more available"} : null}{content}
: content} + record.role === "tool" ?
{ + if (event.target === event.currentTarget && !event.currentTarget.open) cancelRead(); + }}>{heading}{more ? {zh ? " · 可继续加载" : " · more available"} : null}{content}
: content}
; } diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index c2691e0234..1c3ef3a647 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3764,3 +3764,19 @@ entry and instruction repair, not proof that a model adopted the guidance. No new lifecycle owner, model execution, preset or installed-host release is introduced. Full R1/V1–V5/V10, progressive control, complete cost evidence and the original-goal regression remain open; all original failures are retained. + +### 2026-11-13 — Original record reads stop when their disclosure closes + +The optional attached-work conversation retries an interrupted detail read once +at the same source, record and character offset. Closing either the record or +its work-activity group cancels that read, including its hidden retry; reopening +retains the loaded text and position for an explicit continuation. The existing +HTTP reader, fingerprints and native-input boundary remain the owners. + +The unrepaired candidate's real HTTP/browser negative case issued a second read +after closure. Source and an isolated Python 3.11 wheel now qualify both closure +boundaries, interrupted-success recovery, denied-body refusal and complete +Unicode readback through the real LoopX reader with a synthetic Desktop peer. +This qualifies optional record reading, not model-context retention, managed +V4/R1, split-package M3, installed-App adoption or complete costs. The original +Goal regression, acceptance gaps, authority and frozen failures remain intact. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index 43c987a293..d52a84ed32 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2937,3 +2937,15 @@ Goal 时才可创建;工具/上下文不可用时仍保留可见 `/goal` 手 这取得入口与指令修复证据,不能证明模型采用了指引。未新增生命周期 owner、 模型执行、preset 或已安装 Host 发布。完整 R1/V1–V5/V10、渐进控制、完整成本 证据与原目标回归仍开放,保留全部原始失败。 + +### 2026-11-13 — 原记录的披露关闭后停止续读 + +可选的原工作会话在详情响应中断时,只按同一来源、记录与字符偏移重试一次。 +关闭单条记录或其工作过程分组,会取消该读取及隐藏重试;重新展开保留已读文字 +与位置,由用户显式继续。原 HTTP reader、指纹校验与原生输入边界仍承担各自职责。 + +未修复候选的真实 HTTP/浏览器反例在关闭后发出了第二次读取。源码与隔离的 +Python 3.11 wheel 现已通过真实 LoopX reader 和合成 Desktop peer,验证两层关闭、 +成功响应断流恢复、拒绝响应断流不重试及完整 Unicode 回读。这只资格化可选记录 +读取,不证明模型上下文保留、受管理 V4/R1、拆包 M3、已安装 App 采用或完整成本。 +原目标回归、未完成验收、权限边界与冻结失败全部保持。 diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index f2503f3db2..f0854559b5 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -302,6 +302,9 @@ history replacement resets the visible window instead of mixing conversations. An interrupted detail read retries once at the same record and offset. A rejected or stale read stays stopped; previously loaded text stays readable after a transient failure, and the reader can request the same page again. +Closing the record or its work-activity group cancels the pending detail read. +Reopening retains the loaded text and position; continuing requires an explicit +read, and a cancelled response cannot append content or clear a newer read. An interrupted or timed-out history read keeps the records already displayed and explains that updates resume automatically when the connection recovers. Replies remain disabled until a fresh observation permits them. Changing the diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index 495df1df0b..dbfe80e6aa 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -633,13 +633,36 @@ async function runConversation(browser, uncertain) { await toolResult.getByRole('alert').waitFor(); assert.equal(await toolResult.locator('pre').textContent(), prefix); assert.equal(detailRequests.length, 1, 'A denied detail read must not retry when its body is lost'); + for (const boundary of ['record', 'activity']) { + const before = detailRequests.length; + await page.request.post(new URL('/fixture/content-body-hold',url).href); + const headers = page.waitForResponse(response => { + const target = new URL(response.url()); + return target.pathname === '/api/chat/codex-attached' && target.searchParams.get('entry_id') === recordId; + }); + await readMore.click(); + const pending = await headers; + assert.equal(pending.status(), 200, 'The original read has received its success headers'); + const disclosure = boundary === 'record' ? toolResult.locator(':scope > details') : + toolResult.locator('xpath=ancestor::details[contains(@class,"attached-record-group")]'); + const summary = disclosure.locator(':scope > summary'); + await summary.click(); + assert.equal(await disclosure.evaluate(node => node.open), false); + await page.request.post(new URL('/fixture/content-body-release',url).href); + await summary.click(); + await readMore.waitFor(); + assert.equal(await readMore.isEnabled(), true, 'Reopening permits an explicit read at the retained position'); + assert.equal(detailRequests.length, before + 1, `Closing ${boundary} prevents a hidden retry`); + assert.equal(await toolResult.locator('pre').textContent(), prefix, 'A cancelled page cannot replace the retained prefix'); + assert.equal(await toolResult.getByRole('alert').count(), 0); + } await page.request.post(new URL('/fixture/content-body-drop',url).href); await readMore.click(); await page.waitForFunction(({id, previous}) => { const text = document.querySelector(`article[data-record-id="${CSS.escape(id)}"] pre`)?.textContent; return text != null && text !== previous; }, {id: recordId, previous: prefix}); - assert.equal(detailRequests.length, 3, 'A dropped success body recovers with one read after the explicit click'); + assert.equal(detailRequests.length, 5, 'A dropped success body recovers with one read after the explicit click'); assert.equal(new Set(detailRequests).size, 1, 'All three requests keep the original binding, record and offset'); assert.equal(await toolResult.getByRole('alert').count(), 0); page.off('request', observeDetail); diff --git a/examples/personal-workspace-browser/codex_attached_fixture.py b/examples/personal-workspace-browser/codex_attached_fixture.py index 10c660feb9..7cc3ef1a4f 100644 --- a/examples/personal-workspace-browser/codex_attached_fixture.py +++ b/examples/personal-workspace-browser/codex_attached_fixture.py @@ -61,6 +61,7 @@ live_revision = 0 live_updates = 0 body_fault = None + content_release = threading.Event() def open_spy(argv, **options): global fail_next_open @@ -95,7 +96,7 @@ def do_GET(self): "before" in parse_qs(urlsplit(self.path).query) if body_fault == "history" else "entry_id" in parse_qs(urlsplit(self.path).query) - if body_fault in {"content", "content-denied"} + if body_fault in {"content", "content-denied", "content-held"} else "agent_id" not in parse_qs(urlsplit(self.path).query) ) ): @@ -108,6 +109,7 @@ def do_GET(self): "history": 200, "content": 200, "content-denied": 403, + "content-held": 200, }[mode] self.send_response(status) self.send_header( @@ -126,6 +128,8 @@ def do_GET(self): self.wfile.flush() if mode == "timeout": time.sleep(7.5) + elif mode == "content-held": + content_release.wait(7.5) self.close_connection = True else: super().do_GET() @@ -148,11 +152,18 @@ def do_POST(self): "/fixture/history-body-drop": "history", "/fixture/content-body-drop": "content", "/fixture/content-body-denied": "content-denied", + "/fixture/content-body-hold": "content-held", } if self.path in faults: + if self.path == "/fixture/content-body-hold": + content_release.clear() body_fault = faults[self.path] self._send_json({"ok": True}) return + if self.path == "/fixture/content-body-release": + content_release.set() + self._send_json({"ok": True}) + return if self.path == "/fixture/work-records": # Genuine, already public-safe display data; only the host # protocol and carrier messages are synthetic. Keep one From 139d5344e925d941cb81f1e82fce9c365ee4c5e0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:20:19 +0800 Subject: [PATCH 3/3] Cancel pending tool output reads across immediate reopen Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../attached-conversation-record.tsx | 89 +++++++++++-------- .../session-runtime-control-plane-adapter.md | 3 +- .../codex-attached.mjs | 19 ++-- 3 files changed, 67 insertions(+), 44 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx index 611ceddad0..129b1d6d56 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attached-conversation-record.tsx @@ -11,10 +11,13 @@ export type ConversationRecord = { }; export type ContentPage = {text: string; next_offset: number; has_more: boolean}; type LoadContent = (record: ConversationRecord, offset: number, signal: AbortSignal) => Promise; -type ConversationPresentation = {agent: string; zh: boolean; load: LoadContent; live?: boolean; active?: boolean}; /** Keep source order, but give dialogue priority over consecutive tool events. */ -export function AttachedConversationTimeline({records, ...props}: ConversationPresentation & {records: ConversationRecord[]}) { +export function AttachedConversationTimeline({records, ...props}: { + records: ConversationRecord[]; agent: string; zh: boolean; + load: LoadContent; + live?: boolean; active?: boolean; +}) { const groupKeys = useRef(new Map()); const nextKeys = new Map(); useLayoutEffect(() => {groupKeys.current = nextKeys;}); @@ -34,27 +37,20 @@ export function AttachedConversationTimeline({records, ...props}: ConversationPr const tokens = item.map(record => JSON.stringify([record.id, record.content_ref])); const key = tokens.map(token => groupKeys.current.get(token)).find(Boolean) ?? tokens[0]; for (const token of tokens) nextKeys.set(token, key); - return ; - }); -} - -function ActivityGroup({records, ...props}: ConversationPresentation & {records: ConversationRecord[]}) { - const [visible, setVisible] = useState(false); - const interrupted = records.some(record => record.kind === "error" || record.kind === "turn_aborted"); - const gap = records.some(record => record.unavailable); - const nonzeroResults = records.filter(record => record.tool_exit_codes?.some(code => code !== 0)).length; - return
{ - if (event.target === event.currentTarget) setVisible(event.currentTarget.open); - }}> - record.id).join(",")}> - {props.live && records.some(record => record.role === "tool") ? : null} - {props.zh ? "工作过程" : "Work activity"} · {records.length} {props.zh ? "条记录" : "records"} + const interrupted = item.some(record => record.kind === "error" || record.kind === "turn_aborted"); + const gap = item.some(record => record.unavailable); + const nonzeroResults = item.filter(record => record.tool_exit_codes?.some(code => code !== 0)).length; + return
+ record.id).join(",")}> + {props.live && item.some(record => record.role === "tool") ? : null} + {props.zh ? "工作过程" : "Work activity"} · {item.length} {props.zh ? "条记录" : "records"} {interrupted ? {props.zh ? "含中断或报错" : "Includes an interruption or error"} : null} {nonzeroResults ? {props.zh ? `${nonzeroResults} 条结果含非零退出码` : `${nonzeroResults} results with nonzero exit codes`} : null} {gap ? {props.zh ? "部分记录不可读" : "Some records unavailable"} : null} -
{records.map(record => )}
+
{item.map(record => )}
; + }); } function ActivityMark({active, zh}: {active: boolean; zh: boolean}) { @@ -83,8 +79,11 @@ function ToolContent({text, complete, zh}: {text: string; complete: boolean; zh: return
{text}
; } -export function AttachedConversationRecord({record, agent, zh, load, live = false, active = false, visible = true}: ConversationPresentation & { - record: ConversationRecord; visible?: boolean; +export function AttachedConversationRecord({record, agent, zh, load, live = false, active = false}: { + record: ConversationRecord; agent: string; zh: boolean; + load: LoadContent; + live?: boolean; + active?: boolean; }) { const [storedText, setText] = useState(record.text); const text = live ? record.text : storedText; @@ -94,14 +93,6 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals const [offset, setOffset] = useState(Array.from(record.text).length); const contentRead = useRef(null); useLayoutEffect(() => () => contentRead.current?.abort(), []); - useLayoutEffect(() => {if (!visible) cancelRead();}, [visible]); - function cancelRead() { - const controller = contentRead.current; - if (!controller) return; - contentRead.current = null; - controller.abort(); - setLoading(false); - } const labels: Record = zh ? { task_started: "开始执行", task_complete: "本轮结束", turn_aborted: "执行中断", error: "执行报错", function_call: "工具调用", custom_tool_call: "工具调用", @@ -115,11 +106,37 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals record.role === "user" ? (zh ? "你" : "You") : record.role === "assistant" ? agent : `${labels[record.kind] ?? (zh ? "记录暂不可读" : "Record unavailable")}${record.label && record.label !== record.kind ? ` · ${record.label}` : ""}`; const nonzeroCodes = [...new Set(record.tool_exit_codes?.filter(code => code !== 0))]; - async function continueReading() { - if (!visible || contentRead.current) return; + async function continueReading(button: HTMLButtonElement) { + if (contentRead.current) return; + const disclosures: HTMLDetailsElement[] = []; + for (let element = button.parentElement; element; element = element.parentElement) { + if (element instanceof HTMLDetailsElement) { + if (!element.open) return; + disclosures.push(element); + } + } const controller = new AbortController(); contentRead.current = controller; setLoading(true); setError(""); + const finish = () => { + if (contentRead.current !== controller) return; + contentRead.current = null; + setLoading(false); + }; + // Native details can hide the record without unmounting it. Observe the + // open attributes while reading, including a close followed by a reopen. + const visibility = new MutationObserver(onVisibilityChange); + controller.signal.addEventListener("abort", () => visibility.disconnect(), {once: true}); + function onVisibilityChange(changes: MutationRecord[]) { + const reopened = changes.some(change => change.oldValue === null); + const closed = disclosures.some(disclosure => !disclosure.open); + if (!reopened && !closed) return; + controller.abort(); + finish(); + } + for (const disclosure of disclosures) { + visibility.observe(disclosure, {attributeFilter: ["open"], attributeOldValue: true}); + } try { const page = await load(record, offset, controller.signal); if (controller.signal.aborted) return; @@ -127,23 +144,19 @@ export function AttachedConversationRecord({record, agent, zh, load, live = fals } catch (failure) { if (!controller.signal.aborted) setError(failure instanceof Error ? failure.message : String(failure)); } finally { - if (contentRead.current === controller) { - contentRead.current = null; - if (!controller.signal.aborted) setLoading(false); - } + visibility.disconnect(); + if (!controller.signal.aborted) finish(); } } const content = <> {record.redacted ?

{zh ? "此段包含疑似凭据,内容已隐藏。" : "This content contains credential-shaped data and has been withheld."}

: record.role === "tool" ? : } - {more ? : null} + {more ? : null} {error ?

{error}

: null} ; const heading = <>{live && record.role !== "activity" ? : null}{title}{nonzeroCodes.length ? {zh ? "退出码" : "Exit code"} {nonzeroCodes.join(", ")} : null}{record.timestamp ? : null}; return
{record.role !== "tool" ?
{heading}
: null} {record.unavailable ?

{zh ? "源记录无法安全读取;此处保留缺口,未将其当作完成。" : "This source record could not be read safely. The gap is retained, not treated as completion."}

: - record.role === "tool" ?
{ - if (event.target === event.currentTarget && !event.currentTarget.open) cancelRead(); - }}>{heading}{more ? {zh ? " · 可继续加载" : " · more available"} : null}{content}
: content} + record.role === "tool" ?
{heading}{more ? {zh ? " · 可继续加载" : " · more available"} : null}{content}
: content}
; } diff --git a/docs/integrations/session-runtime-control-plane-adapter.md b/docs/integrations/session-runtime-control-plane-adapter.md index f0854559b5..9faec3d92b 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -302,7 +302,8 @@ history replacement resets the visible window instead of mixing conversations. An interrupted detail read retries once at the same record and offset. A rejected or stale read stays stopped; previously loaded text stays readable after a transient failure, and the reader can request the same page again. -Closing the record or its work-activity group cancels the pending detail read. +Closing the record or its work-activity group cancels the pending detail read, +including when the disclosure is reopened before its toggle event runs. Reopening retains the loaded text and position; continuing requires an explicit read, and a cancelled response cannot append content or clear a newer read. An interrupted or timed-out history read keeps the records already displayed diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index dbfe80e6aa..0ee5e6d1f4 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -633,7 +633,7 @@ async function runConversation(browser, uncertain) { await toolResult.getByRole('alert').waitFor(); assert.equal(await toolResult.locator('pre').textContent(), prefix); assert.equal(detailRequests.length, 1, 'A denied detail read must not retry when its body is lost'); - for (const boundary of ['record', 'activity']) { + for (const [boundary, reopenImmediately] of [['record', false], ['activity', false], ['record', true], ['activity', true]]) { const before = detailRequests.length; await page.request.post(new URL('/fixture/content-body-hold',url).href); const headers = page.waitForResponse(response => { @@ -643,13 +643,22 @@ async function runConversation(browser, uncertain) { await readMore.click(); const pending = await headers; assert.equal(pending.status(), 200, 'The original read has received its success headers'); + const requestCanceled = page.waitForEvent('requestfailed', request => request === pending.request()); const disclosure = boundary === 'record' ? toolResult.locator(':scope > details') : toolResult.locator('xpath=ancestor::details[contains(@class,"attached-record-group")]'); const summary = disclosure.locator(':scope > summary'); - await summary.click(); - assert.equal(await disclosure.evaluate(node => node.open), false); + if (reopenImmediately) { + await disclosure.evaluate(node => { + node.open = false; + node.open = true; + }); + } else { + await summary.press('Enter'); + assert.equal(await disclosure.evaluate(node => node.open), false); + } await page.request.post(new URL('/fixture/content-body-release',url).href); - await summary.click(); + assert.match((await requestCanceled).failure()?.errorText ?? '', /ERR_ABORTED/); + if (!reopenImmediately) await summary.press('Enter'); await readMore.waitFor(); assert.equal(await readMore.isEnabled(), true, 'Reopening permits an explicit read at the retained position'); assert.equal(detailRequests.length, before + 1, `Closing ${boundary} prevents a hidden retry`); @@ -662,7 +671,7 @@ async function runConversation(browser, uncertain) { const text = document.querySelector(`article[data-record-id="${CSS.escape(id)}"] pre`)?.textContent; return text != null && text !== previous; }, {id: recordId, previous: prefix}); - assert.equal(detailRequests.length, 5, 'A dropped success body recovers with one read after the explicit click'); + assert.equal(detailRequests.length, 7, 'A dropped success body recovers with one read after the explicit click'); assert.equal(new Set(detailRequests).size, 1, 'All three requests keep the original binding, record and offset'); assert.equal(await toolResult.getByRole('alert').count(), 0); page.off('request', observeDetail);