From 0f9efb31fb7310137e3203d5600540551c5f5e1d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 07:48:54 +0800 Subject: [PATCH] Resume Chat history after an interrupted response body Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../dashboard/smoke/bound-conversation-smoke.ts | 13 +++++++++++-- apps/presentation/dashboard/src/data/chat.ts | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts index 6b840f5af5..b7a61c4567 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts +++ b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts @@ -132,6 +132,11 @@ try { assert.equal(complete.messages.length, expectedRows); const recoveredOffsets: number[] = []; const failedOffsets = new Set(); + const interruptedHistoryBody = (status: number) => new Response(new ReadableStream({ + start(controller) { + controller.error(new Error("Synthetic interrupted history response body")); + }, + }), {status}); globalThis.fetch = async (input, init) => { const response = await routedRead(input, init); if (new URL(String(input), origin).pathname !== `/api/chat/sessions/${sessionId}`) return response; @@ -140,7 +145,8 @@ try { recoveredOffsets.push(offset); if (offset > 0 && !failedOffsets.has(offset)) { failedOffsets.add(offset); - throw new Error("Synthetic interrupted transport after a verified prefix"); + if (offset === 200) throw new Error("Synthetic interrupted transport after a verified prefix"); + return interruptedHistoryBody(response.status); } return response; }; @@ -150,6 +156,8 @@ try { assert.deepEqual(interrupted.snapshots, []); const interruptedAgain = await fetchChatHistory(options, interrupted); assert.deepEqual(interruptedAgain.messages, []); + assert.equal(interruptedAgain.pendingReads?.get(sessionId)?.messages.length, 400, + "A lost body after successful response headers keeps the verified prefix private"); const recovered = await fetchChatHistory(options, interruptedAgain); assert.deepEqual(recovered.unavailableSessionIds, []); assert.deepEqual(recovered.snapshots[0].messages, complete.messages); @@ -188,11 +196,12 @@ try { child.stdin.write("recover\n"); assert.equal((await next()).source_verified, true); // Permanent denial, a changed source or malformed prefix cannot be retained // as transport progress. The following read must validate page zero again. - for (const fault of ["denied", "source", "digest"]) { + for (const fault of ["denied", "denied-body", "source", "digest"]) { globalThis.fetch = async (input, init) => { const response = await routedRead(input, init); if (!new URL(String(input), origin).searchParams.has("message_cursor")) return response; if (fault === "denied") return new Response("{}", {status: 403}); + if (fault === "denied-body") return interruptedHistoryBody(403); const value = await response.json(); if (fault === "source") value.session.goal_id = "unrelated-goal"; else value.message_page.snapshot_sha256 = "0".repeat(64); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d7c740c9f6..95f19d780f 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1297,8 +1297,10 @@ function sameHistoryRevision(current: ChatSessionSummary, read: ChatSessionSumma } function historyTransportUnavailable(error: unknown) { - return error instanceof ChatApiError && error.payload.error_code === "chat_api_unavailable" - && (error.payload.http_status === undefined || Number(error.payload.http_status) >= 500); + if (!(error instanceof ChatApiError) || error.payload.error_code !== "chat_api_unavailable") return false; + const status = error.payload.http_status; + // A response body can be interrupted after successful history headers. + return status === undefined || status === 200 || Number(status) >= 500; } export type ChatHistory = {