diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 5fa1287..54a16a0 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 StreamModelOverflowError ? err.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");