From f1b7fb4e7ceed94b4842732d29fa2ef798b641b1 Mon Sep 17 00:00:00 2001 From: Roy Osherove <575051+royosherove@users.noreply.github.com> Date: Sat, 16 May 2026 20:53:27 +0000 Subject: [PATCH 1/2] fix(streaming-overflow): preserve hadVisibleText state when escalating to gateway recovery (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a text_delta is emitted before a model_error overflow event, the hadVisibleText flag was lost because handleStreaming() threw before completing normally. The gateway catch then always saw hadVisibleText=false and posted 'Please resend your last message' copy — misleading after partial output. Attach hadVisibleText to the StreamModelOverflowError so it survives the throw. Extract it in gateway.ts catch and pass to recovery, which then posts 'Response was interrupted; session recovered' wording for partial output (correct UX, prevents duplicate side effects). Test: updated streaming-overflow.test.ts to extract state from error object instead of relying on gateway hoisted-local pattern (which was the bug). All 591 tests green. --- src/gateway/gateway.ts | 6 ++++-- src/gateway/streaming.ts | 7 +++++-- test/streaming-overflow.test.ts | 23 +++++++++-------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 5fa1287..55c698b 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -21,7 +21,7 @@ import type { PressureLevel } from "../memory/types"; // progress messages now flow through the transport via this.transport.progress(). import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThreadId as _resolveThread, getSystemResources as _getSysRes } from "./helpers"; import { saveAttachments, type AttachmentResult } from "./attachments"; -import { handleStreaming as _handleStream } from "./streaming"; +import { handleStreaming as _handleStream, StreamModelOverflowError } from "./streaming"; import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands"; import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command"; import { handleLater } from "./later-command"; @@ -515,9 +515,11 @@ export class Gateway { console.error(`[roundhouse] memory finalize error:`, (err as Error).message); } } catch (err) { + // Extract hadVisibleText from StreamModelOverflowError if present + const errorHadVisibleText = err instanceof Error && 'hadVisibleText' in err ? (err as any).hadVisibleText : streamHadVisibleText; await recoverFromAgentTurnOverflow(thread, agentThreadId, agent, err, { turnSource, - hadVisibleText: streamHadVisibleText, + hadVisibleText: errorHadVisibleText, }); } finally { if (stopTyping) stopTyping(); diff --git a/src/gateway/streaming.ts b/src/gateway/streaming.ts index d81d5ba..5d3b935 100644 --- a/src/gateway/streaming.ts +++ b/src/gateway/streaming.ts @@ -32,8 +32,11 @@ import { isContextOverflowError } from "../agents/shared/error-classifiers"; */ export class StreamModelOverflowError extends Error { override readonly name = "StreamModelOverflowError"; - constructor(message: string) { + readonly hadVisibleText: boolean; + + constructor(message: string, hadVisibleText: boolean = false) { super(message); + this.hadVisibleText = hadVisibleText; } } @@ -213,7 +216,7 @@ export async function handleStreaming( // Non-overflow errors keep today's inline post + continue-loop. if (isContextOverflowError({ message: event.message })) { console.warn(`[roundhouse] streamed model_error: context overflow — escalating to gateway recovery`); - throw new StreamModelOverflowError(event.message); + throw new StreamModelOverflowError(event.message, hasVisibleText); } modelErrorPosted = true; const safeMsg = event.message.split("\n")[0].slice(0, 400); diff --git a/test/streaming-overflow.test.ts b/test/streaming-overflow.test.ts index 997f6ae..e13991d 100644 --- a/test/streaming-overflow.test.ts +++ b/test/streaming-overflow.test.ts @@ -244,25 +244,20 @@ describe("streaming-overflow → gateway recovery (F1 end-to-end)", () => { let caught: unknown; let hadVisibleText = false; try { - // We can't read the StreamResult after a throw; the gateway tracks - // hadVisibleText separately via a hoisted local that's updated only - // on the success path. Simulate that by inspecting whether the stream - // produced visible text before the throw. const r = await handleStreaming(stream, ctxFor(thread)); hadVisibleText = r.hadVisibleText; - } catch (e) { caught = e; } - - // In the gateway, `streamHadVisibleText` is set only on the success - // assignment — but the production gateway actually tracks it across the - // throw via the hoisted-local pattern in handleAgentTurn. To validate - // the recovery copy here we set hadVisibleText=true to mirror the - // gateway's behavior after a partial-text emit (see gateway.ts comment). - // The pure-streaming visible-text path is covered by other tests. - void hadVisibleText; + } catch (e) { + caught = e; + // StreamModelOverflowError now carries hadVisibleText from the stream. + // In production gateway.ts, this is extracted and passed to recovery. + if (e instanceof StreamModelOverflowError) { + hadVisibleText = e.hadVisibleText; + } + } const result = await recoverFromAgentTurnOverflow(thread, tid, agent, caught, { turnSource: "user", - hadVisibleText: true, + hadVisibleText, }); expect(result.outcome?.kind).toBe("recovered"); From 38d48e8f4c4e583136037762b74c73804c9606be Mon Sep 17 00:00:00 2001 From: Roy Osherove <575051+royosherove@users.noreply.github.com> Date: Sat, 16 May 2026 20:54:32 +0000 Subject: [PATCH 2/2] style: use instanceof for type-safe hadVisibleText extraction (arch review) --- src/gateway/gateway.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 55c698b..54a16a0 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -516,7 +516,7 @@ export class Gateway { } } catch (err) { // Extract hadVisibleText from StreamModelOverflowError if present - const errorHadVisibleText = err instanceof Error && 'hadVisibleText' in err ? (err as any).hadVisibleText : streamHadVisibleText; + const errorHadVisibleText = err instanceof StreamModelOverflowError ? err.hadVisibleText : streamHadVisibleText; await recoverFromAgentTurnOverflow(thread, agentThreadId, agent, err, { turnSource, hadVisibleText: errorHadVisibleText,