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 16e16b06da..29de78c83c 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 @@ -16,7 +16,12 @@ type LiveObservation = {kind: "records"; revision: number; records: Message[]} | {kind: "unavailable"; retry: boolean}; class ConversationRequestError extends Error { - constructor(message: string, readonly unsubmittedOperation?: string, readonly httpStatus?: number) {super(message);} + constructor( + message: string, + readonly unsubmittedOperation?: string, + readonly httpStatus?: number, + readonly interruptedSuccessfulResponse = false, + ) {super(message);} } export async function request(url: string, options?: RequestInit): Promise { @@ -30,11 +35,9 @@ export async function request(url: string, options?: RequestInit): Promise try { body = await response.text(); } catch { - // The headers arrived, so the status is already known even though the body - // did not. Drop the status here and a stalled 503 body looks like a plain - // transport failure while a truncated 403 body looks retryable; the caller - // must classify on this status, not on how the body read failed. - throw new ConversationRequestError("Conversation unavailable", undefined, response.status); + // Preserve rejected statuses even when their bodies drop. A successful + // response interrupted in transit is different from a complete invalid body. + throw new ConversationRequestError("Conversation unavailable", undefined, response.status, response.ok); } let value: Record | null = null; try {value = body ? JSON.parse(body) as Record : null;} catch {value = null;} @@ -51,7 +54,8 @@ function interruptedRead(failure: unknown): boolean { } export function retryableRead(failure: unknown): boolean { - return interruptedRead(failure) || (failure instanceof ConversationRequestError && (failure.httpStatus ?? 0) >= 500); + return interruptedRead(failure) || (failure instanceof ConversationRequestError && + (failure.interruptedSuccessfulResponse || (failure.httpStatus ?? 0) >= 500)); } export function readFailureMessage(failure: unknown, zh: boolean): string { diff --git a/examples/personal-workspace-browser/codex-attached.mjs b/examples/personal-workspace-browser/codex-attached.mjs index 0ccfa144b4..4bafe0c7c0 100644 --- a/examples/personal-workspace-browser/codex-attached.mjs +++ b/examples/personal-workspace-browser/codex-attached.mjs @@ -740,28 +740,41 @@ async function runConversation(browser, uncertain) { let releasePage, pageStarted; const heldPage = new Promise(resolve => {releasePage = resolve;}); const pagingStarted = new Promise(resolve => {pageStarted = resolve;}); - let held = false, historyOutage = true; + const olderReads = []; const holdHistory = async route => { - if (!new URL(route.request().url()).searchParams.has('before') || held) return route.continue(); - if (historyOutage) { - historyOutage = false; - return route.fulfill({status:503,contentType:'text/html',body:'Temporary history outage'}); - } - held = true; + const query = new URL(route.request().url()).searchParams; + if (!query.has('before') || olderReads.length === 2) return route.continue(); + olderReads.push([query.get('before'), query.get('source_ref'), query.get('binding_ref')]); + if (olderReads.length === 1) return route.continue(); const response = await route.fetch(); pageStarted(); await heldPage; await route.fulfill({response}); }; + await page.request.post(new URL('/fixture/history-body-drop',url).href); await page.route('**/api/chat/codex-attached?*',holdHistory); const transcript = panel.locator('.attached-conversation-messages'); + const partial = page.waitForResponse(response => + new URL(response.url()).searchParams.has('before') && response.status() === 200); await transcript.focus(); await page.keyboard.press('Home'); + await partial; await panel.getByRole('alert').waitFor(); - await Promise.race([pagingStarted, page.waitForTimeout(10_000).then(() => {throw new Error('Scrolling to earlier history did not request a source page');})]); + assert.equal(await longDialogue.count(),1,'An interrupted page keeps the saved conversation visible'); + await page.screenshot({path:resolve(outputDir,'attached-history-interrupted-desktop.png'),animations:'disabled'}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(outputDir,'attached-history-interrupted-narrow.png'),animations:'disabled'}); + await page.setViewportSize({width:1512,height:982}); + await Promise.race([pagingStarted, page.waitForTimeout(4_000).then(() => {throw new Error('An interrupted response with successful headers did not automatically resume history');})]); + assert.ok(olderReads[0].every(Boolean),'The request identifies the original history and position'); + assert.deepEqual(olderReads[1],olderReads[0],'Automatic recovery retries the same source and position'); await longDialogue.locator('p').first().click(); const summaryTop = (await workSummary.boundingBox()).y; releasePage(); await work.getByText('Saved output 110',{exact:true}).waitFor({state:'attached'}); await panel.getByRole('alert').waitFor({state:'hidden'}); assert.ok(Math.abs((await workSummary.boundingBox()).y - summaryTop) < 2,'Prepending a page keeps the source summary at the same reading position'); + await page.screenshot({path:resolve(outputDir,'attached-history-recovered-desktop.png'),animations:'disabled'}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(outputDir,'attached-history-recovered-narrow.png'),animations:'disabled'}); + await page.setViewportSize({width:1512,height:982}); await page.unroute('**/api/chat/codex-attached?*',holdHistory); await workSummary.focus(); await page.keyboard.press('Enter'); const expandedGroup = await work.elementHandle(); diff --git a/examples/personal-workspace-browser/codex_attached_fixture.py b/examples/personal-workspace-browser/codex_attached_fixture.py index a509cd1073..c3dd8f374a 100644 --- a/examples/personal-workspace-browser/codex_attached_fixture.py +++ b/examples/personal-workspace-browser/codex_attached_fixture.py @@ -8,6 +8,7 @@ import tempfile import threading import time +from urllib.parse import parse_qs, urlsplit from contextlib import ExitStack from types import SimpleNamespace @@ -90,18 +91,30 @@ def do_GET(self): elif ( body_fault and self.path.startswith("/api/chat/codex-attached?") - and "agent_id=" not in self.path + and ( + "before" in parse_qs(urlsplit(self.path).query) + if body_fault == "history" + else "agent_id" not in parse_qs(urlsplit(self.path).query) + ) ): - # Headers first, then a body that never completes: the client - # read limit aborts a stalled body, and a short close against - # the declared length surfaces as a network error. Both keep - # the status the response already carried. + # 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 - self.send_response(503 if mode == "timeout" else 403) - self.send_header("Content-Type", "text/html; charset=utf-8") + status = {"timeout": 503, "drop": 403, "history": 200}[mode] + self.send_response(status) + self.send_header( + "Content-Type", + "application/json" + if status == 200 + else "text/html; charset=utf-8", + ) self.send_header("Content-Length", "200") self.end_headers() - self.wfile.write(b"Service unavailable") + self.wfile.write( + b'{"ok":' + if status == 200 + else b"Service unavailable" + ) self.wfile.flush() if mode == "timeout": time.sleep(7.5) @@ -121,11 +134,13 @@ def do_POST(self): ) self._send_json({"ok": True}) return - if self.path in { - "/fixture/discovery-body-timeout", - "/fixture/discovery-body-drop", - }: - body_fault = "timeout" if self.path.endswith("timeout") else "drop" + faults = { + "/fixture/discovery-body-timeout": "timeout", + "/fixture/discovery-body-drop": "drop", + "/fixture/history-body-drop": "history", + } + if self.path in faults: + body_fault = faults[self.path] self._send_json({"ok": True}) return if self.path == "/fixture/work-records":