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 @@ -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<T>(url: string, options?: RequestInit): Promise<T> {
Expand All @@ -30,11 +35,9 @@ export async function request<T>(url: string, options?: RequestInit): Promise<T>
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<string, unknown> | null = null;
try {value = body ? JSON.parse(body) as Record<string, unknown> : null;} catch {value = null;}
Expand All @@ -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 {
Expand Down
29 changes: 21 additions & 8 deletions examples/personal-workspace-browser/codex-attached.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:'<html>Temporary history outage</html>'});
}
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();
Expand Down
41 changes: 28 additions & 13 deletions examples/personal-workspace-browser/codex_attached_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"<html><body>Service unavailable")
self.wfile.write(
b'{"ok":'
if status == 200
else b"<html><body>Service unavailable"
)
self.wfile.flush()
if mode == "timeout":
time.sleep(7.5)
Expand All @@ -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":
Expand Down
Loading