Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContentPage> {
async function loadContent(record: Message, offset: number, signal: AbortSignal): Promise<ContentPage> {
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<ContentPage>(`/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<ContentPage>(`/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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContentPage>;

/** 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<ContentPage>;
load: LoadContent;
live?: boolean; active?: boolean;
}) {
const groupKeys = useRef(new Map<string, string>());
Expand Down Expand Up @@ -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<ContentPage>;
load: LoadContent;
live?: boolean;
active?: boolean;
}) {
Expand All @@ -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<AbortController | null>(null);
useLayoutEffect(() => () => contentRead.current?.abort(), []);
Comment thread
loopx-agent marked this conversation as resolved.
Resolved
const labels: Record<string, string> = zh ? {
task_started: "开始执行", task_complete: "本轮结束", turn_aborted: "执行中断", error: "执行报错",
function_call: "工具调用", custom_tool_call: "工具调用",
Expand All @@ -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 ? <p>{zh ? "此段包含疑似凭据,内容已隐藏。" : "This content contains credential-shaped data and has been withheld."}</p> : record.role === "tool" ? <ToolContent text={text} complete={!more} zh={zh}/> : <MarkdownText text={text}/>}
{more ? <button type="button" disabled={loading} onClick={() => void continueReading()}>{loading ? (zh ? "正在读取…" : "Loading…") : (zh ? "继续读取完整内容" : "Read more of this record")}</button> : null}
{more ? <button type="button" disabled={loading} onClick={event => void continueReading(event.currentTarget)}>{loading ? (zh ? "正在读取…" : "Loading…") : (zh ? "继续读取完整内容" : "Read more of this record")}</button> : null}
{error ? <p role="alert">{error}</p> : null}
</>;
const heading = <><strong>{live && record.role !== "activity" ? <ActivityMark active={active} zh={zh}/> : null}{title}</strong>{nonzeroCodes.length ? <span className="attached-record-warning">{zh ? "退出码" : "Exit code"} {nonzeroCodes.join(", ")}</span> : null}{record.timestamp ? <time dateTime={record.timestamp}>{new Date(record.timestamp).toLocaleString()}</time> : null}</>;
Expand Down
16 changes: 16 additions & 0 deletions docs/architecture/rfcs/monorepo-distribution-split-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 采用或完整成本。
原目标回归、未完成验收、权限边界与冻结失败全部保持。
7 changes: 7 additions & 0 deletions docs/integrations/session-runtime-control-plane-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 59 additions & 2 deletions examples/personal-workspace-browser/codex-attached.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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));
Expand Down
23 changes: 22 additions & 1 deletion examples/personal-workspace-browser/codex_attached_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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()
Expand All @@ -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
Expand Down
Loading