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..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 @@ -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: "工具调用", @@ -103,18 +106,51 @@ 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 (loading) 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); + 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 { + 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}; 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 4163514740..9faec3d92b 100644 --- a/docs/integrations/session-runtime-control-plane-adapter.md +++ b/docs/integrations/session-runtime-control-plane-adapter.md @@ -299,6 +299,13 @@ 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. +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 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..0ee5e6d1f4 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -618,7 +618,64 @@ 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'); + 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 => { + 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 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'); + 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); + 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`); + 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, 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); + 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 +686,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..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 @@ -94,13 +95,22 @@ 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", "content-held"} 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, + "content-held": 200, + }[mode] self.send_response(status) self.send_header( "Content-Type", @@ -118,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() @@ -138,11 +150,20 @@ 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", + "/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