From bbf29b3c92a3b6b9af81d8ef275f4bb3bed92977 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Thu, 20 Aug 2026 11:53:29 +0000 Subject: [PATCH] fix(responses): bound terminal guard delta retention --- src/server/responses/terminal-guard.ts | 21 ++++++++++++++------- tests/terminal-guard.test.ts | 11 +++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index 347a489f19..67a55c2bfe 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -153,6 +153,16 @@ export interface GuardedEventStreamOptions { maxAutoContinuations?: number; } +/** + * Events that are useful to the downstream stream consumer but carry no state used by the + * terminal-continuation decision or its rebuilt request. Keep them out of the retained event + * list so adapter liveness markers and arbitrarily large tool-argument fragments cannot make + * the guard's per-turn memory grow without adding any continuation semantics. + */ +export function isTerminalGuardPassthroughOnly(event: AdapterEvent): boolean { + return event.type === "heartbeat" || event.type === "tool_call_delta"; +} + function mergeUsage(first: OcxUsage | undefined, second: OcxUsage | undefined): OcxUsage | undefined { if (!first) return second; if (!second) return first; @@ -193,13 +203,10 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio const seen: AdapterEvent[] = []; let terminalSeen = false; for await (const event of source) { - // A heartbeat is adapter liveness, not turn content: it exists so the bridge watchdog - // can tell a buffering adapter from a hung one. Retaining it here would put an - // unbounded number of empty markers into `seen`, which feeds both the continuation - // analysis and the rebuilt request — and the openai-chat adapter now emits one per - // tool-call delta, so a long argument payload alone could grow this array without - // limit. The empty-completion guard already passes them through unretained; match it. - if (event.type === "heartbeat") { + // Liveness markers and tool argument fragments are passed through to the bridge, but + // neither analyzeTerminalTurn nor buildContinuationRequest consumes them. Retaining the + // fragments would duplicate arbitrarily large argument payloads in `seen` for no effect. + if (isTerminalGuardPassthroughOnly(event)) { yield event; continue; } diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 906c726997..356e54fb3d 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -3,6 +3,7 @@ import { analyzeTerminalTurn, buildContinuationRequest, guardTerminalEventStream, + isTerminalGuardPassthroughOnly, } from "../src/server/responses/terminal-guard"; import { buildResponseJSON } from "../src/bridge"; import type { AdapterEvent, OcxParsedRequest } from "../src/types"; @@ -236,6 +237,16 @@ describe("terminal guard", () => { expect(actual.filter(event => event.type === "done")).toHaveLength(1); }); + test("does not retain passthrough-only liveness or tool argument fragments", () => { + expect(isTerminalGuardPassthroughOnly({ type: "heartbeat" })).toBe(true); + expect(isTerminalGuardPassthroughOnly({ + type: "tool_call_delta", + arguments: "x".repeat(1024 * 1024), + })).toBe(true); + expect(isTerminalGuardPassthroughOnly({ type: "tool_call_start", id: "call_1", name: "exec_command" })).toBe(false); + expect(isTerminalGuardPassthroughOnly({ type: "text_delta", text: "working" })).toBe(false); + }); + test("stops after the configured continuation bound", async () => { let continuations = 0; const actual: AdapterEvent[] = [];