From 85fbf9a3c55addfd46528a2e89f5c09d6d175eb5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 15:06:05 +0900 Subject: [PATCH 1/3] fix(openai-chat): heartbeat while buffering tool-call deltas Tool-call deltas are buffered until a terminal signal, so this adapter can consume upstream frames for a long time while yielding nothing. The Responses bridge arms its stall watchdog on ADAPTER activity, not socket activity, so a model streaming a large argument payload was indistinguishable from a hung upstream and could have its turn aborted while it was progressing normally. Found while investigating #2156. It is not the reported error -- that one is the EOF fail-closed guard, and the guard is correct: a stream that ends mid tool call with neither finish_reason nor [DONE] may have truncated the arguments, and promoting them would execute a partial call. But the silent buffering phase is our own hazard and it is worth closing on its own. A heartbeat is invisible downstream: the bridge consumes it to re-arm the watchdog and emits nothing. The Cursor, Anthropic, Google, and Kiro adapters already use exactly this for their own silent phases. The two test collectors now drop heartbeats, which keeps their assertions about the wire the client actually sees. --- src/adapters/openai-chat.ts | 10 ++++++++++ tests/openai-chat-eof.test.ts | 5 ++++- tests/openai-chat-hardening.test.ts | 30 ++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a637747cef..00a2ecf760 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1743,6 +1743,16 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (idDelta && !call.id) call.id = idDelta; if (typeof rawName === "string" && rawName && !call.name) call.name = rawName; if (typeof rawArguments === "string") call.sawArgumentsString = true; + // Tool-call deltas are BUFFERED until a terminal signal, so this adapter can + // consume upstream frames for a long time while yielding nothing. The Responses + // bridge reads adapter activity, not socket activity, so a model that streams a + // large argument payload looks identical to a hung upstream and the stall + // watchdog can abort a turn that was progressing normally (#2156). + // + // A heartbeat is invisible downstream — the bridge consumes it to re-arm the + // watchdog and emits nothing — which is the same remedy the Cursor, Anthropic, + // Google, and Kiro adapters already use for their own silent phases. + yield { type: "heartbeat" }; if (typeof rawArguments === "string" && rawArguments) { const previousBytes = call.argsBytes; const nextBytes = previousBytes + budgetEncoder.encode(rawArguments).byteLength; diff --git a/tests/openai-chat-eof.test.ts b/tests/openai-chat-eof.test.ts index f87ff4c89f..66eb211b71 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -11,7 +11,10 @@ const provider = { adapter: "openai-chat", baseUrl: "https://example.test/v1", a async function collect(gen: AsyncGenerator): Promise { const out: AdapterEvent[] = []; - for await (const e of gen) out.push(e); + // Heartbeats are invisible downstream: the bridge consumes them to re-arm its stall + // watchdog and emits nothing. Dropping them here keeps these assertions about the wire + // the client actually sees (#2156). + for await (const e of gen) if (e.type !== "heartbeat") out.push(e); return out; } diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 81671b0968..20adc0cc53 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -40,7 +40,10 @@ function provider(overrides: Partial = {}): OcxProviderConfig async function collect(stream: AsyncGenerator): Promise { const events: AdapterEvent[] = []; - for await (const event of stream) events.push(event); + // Heartbeats are invisible downstream: the bridge consumes them to re-arm its stall + // watchdog and emits nothing. Dropping them here keeps these assertions about the wire + // the client actually sees (#2156). + for await (const event of stream) if (event.type !== "heartbeat") events.push(event); return events; } @@ -920,4 +923,29 @@ describe("openai-chat response_format emission", () => { .toEqual({ type: "json_object" }); }); }); + +// #2156: tool-call deltas are BUFFERED until a terminal signal, so this adapter can consume +// upstream frames for a long time while yielding nothing downstream. The Responses bridge +// arms its stall watchdog on ADAPTER activity, not socket activity, so a model streaming a +// large argument payload was indistinguishable from a hung upstream. +test("tool-call deltas emit heartbeats so a long buffering phase is not read as a stall", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const frames = ['data: ' + JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_a", function: { name: "shell", arguments: "" } }] } }] }) + '\n\n']; + // Many argument chunks and nothing else: exactly the shape that looked like silence. + for (let i = 0; i < 12; i += 1) { + frames.push('data: ' + JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"x"' } }] } }] }) + '\n\n'); + } + frames.push('data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n', "data: [DONE]\n\n"); + + const raw: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(frames.join("")))) raw.push(event); + + // One per consumed tool-call delta: the watchdog sees activity for the whole phase. + expect(raw.filter(e => e.type === "heartbeat").length).toBeGreaterThanOrEqual(12); + // And the client-visible wire is unchanged -- a heartbeat is consumed by the bridge. + const visible = raw.filter(e => e.type !== "heartbeat"); + expect(visible.some(e => e.type === "error")).toBe(false); + expect(visible).toContainEqual({ type: "tool_call_start", id: "call_a", name: "shell" }); + expect(visible.at(-1)).toMatchObject({ type: "done" }); +}); }); From 9a9349739fd877b667b27ec6ca04e7ab7ba51c17 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 15:13:47 +0900 Subject: [PATCH 2/3] fix(responses): do not retain adapter heartbeats in the terminal guard A heartbeat is adapter liveness, not turn content. guardTerminalEventStream pushed every nonterminal event into `seen`, and `seen` feeds both the continuation analysis and the rebuilt request. The openai-chat adapter now emits one heartbeat per tool-call delta, so a single large argument payload could grow that array without bound on a provider with terminalContinuationGuard enabled. The empty-completion guard already passes heartbeats through unretained; this matches it. They still reach the consumer, because the bridge needs them to re-arm its stall watchdog. Also corrects the attribution on the heartbeat itself. It was described as fixing #2156, and it does not: the reporter's error is emitted after the adapter reads EOF with pending tool calls, while a stall timeout produces response.incomplete with reason upstream_stall_timeout on a path the bridge has already closed. The heartbeat fixes a real false-stall hazard; #2156 needs the reporter's raw SSE comparison before anyone can say what closed that stream. --- src/server/responses/terminal-guard.ts | 10 ++++++ tests/terminal-guard.test.ts | 48 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index 22865c7274..347a489f19 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -193,6 +193,16 @@ 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") { + yield event; + continue; + } if (event.type === "done") { terminalSeen = true; const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat") diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 9fe5b68d4d..906c726997 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -188,6 +188,54 @@ describe("terminal guard", () => { expect(actual.at(-1)).toMatchObject({ usage: { inputTokens: 30, outputTokens: 5, totalTokens: 35 } }); }); + + // A heartbeat is adapter liveness, not turn content. The openai-chat adapter emits one per + // tool-call delta while it buffers, so retaining them here would let a single large argument + // payload grow `seen` without bound — and `seen` is what both the continuation analysis and + // the rebuilt request read. Passing them through unretained is what the empty-completion + // guard already does. + // A heartbeat is adapter liveness, not turn content. The openai-chat adapter emits one per + // tool-call delta while it buffers, so retaining them would grow the guard's record without + // bound on a large argument payload. `analyzeTerminalTurn` and `buildContinuationRequest` + // both read that record, so pin the contract on the pure functions that consume it plus the + // observable passthrough. + test("a retained heartbeat would corrupt the continuation record", () => { + const clean: AdapterEvent[] = [ + { type: "text_delta", text: "我接下来会修改相关文件。" }, + ]; + const padded: AdapterEvent[] = [ + { type: "text_delta", text: "我接下来会修改相关文件。" }, + ...Array.from({ length: 50 }, () => ({ type: "heartbeat" }) as AdapterEvent), + ]; + const request = parsed("继续检查"); + // The guard must not let liveness markers change what the continuation decides or sends. + expect(analyzeTerminalTurn(request, padded).assistantText) + .toBe(analyzeTerminalTurn(request, clean).assistantText); + expect(JSON.stringify(buildContinuationRequest(request, padded).context.messages)) + .toBe(JSON.stringify(buildContinuationRequest(request, clean).context.messages)); + }); + + test("heartbeats reach the consumer so the bridge watchdog stays armed", async () => { + const actual: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("继续检查"), + firstEvents: (async function* () { + yield { type: "text_delta", text: "我接下来会修改相关文件。" } as AdapterEvent; + for (let i = 0; i < 50; i++) yield { type: "heartbeat" } as AdapterEvent; + yield { type: "tool_call_start", id: "call_1", name: "exec_command" } as AdapterEvent; + yield { type: "tool_call_end" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } } as AdapterEvent; + })(), + continuation: () => (async function* () { + yield { type: "done" } as AdapterEvent; + })(), + adapterName: "openai-chat", + })) actual.push(event); + + expect(actual.filter(event => event.type === "heartbeat")).toHaveLength(50); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + }); + test("stops after the configured continuation bound", async () => { let continuations = 0; const actual: AdapterEvent[] = []; From 2a8b81e79779e5c6879a0baf28e5e66f8b1ea1bc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Thu, 20 Aug 2026 15:14:19 +0900 Subject: [PATCH 3/3] docs(openai-chat): stop attributing the heartbeat to #2156 --- src/adapters/openai-chat.ts | 7 ++++++- tests/openai-chat-hardening.test.ts | 14 +++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 00a2ecf760..80eacb0c1e 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1747,7 +1747,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // consume upstream frames for a long time while yielding nothing. The Responses // bridge reads adapter activity, not socket activity, so a model that streams a // large argument payload looks identical to a hung upstream and the stall - // watchdog can abort a turn that was progressing normally (#2156). + // watchdog can abort a turn that was progressing normally. + // + // Found while investigating #2156, but it is NOT that bug: a stall abort emits + // `response.incomplete` with `upstream_stall_timeout` from the bridge, whereas + // that report shows the adapter's own end-of-stream error after `reader.read()` + // returned EOF with tool calls still pending. Different path, different frame. // // A heartbeat is invisible downstream — the bridge consumes it to re-arm the // watchdog and emits nothing — which is the same remedy the Cursor, Anthropic, diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 20adc0cc53..e3ead6e28d 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -42,7 +42,7 @@ async function collect(stream: AsyncGenerator): Promise { }); }); -// #2156: tool-call deltas are BUFFERED until a terminal signal, so this adapter can consume -// upstream frames for a long time while yielding nothing downstream. The Responses bridge -// arms its stall watchdog on ADAPTER activity, not socket activity, so a model streaming a -// large argument payload was indistinguishable from a hung upstream. +// Tool-call deltas are BUFFERED until a terminal signal, so this adapter can consume upstream +// frames for a long time while yielding nothing downstream. The Responses bridge arms its +// stall watchdog on ADAPTER activity, not socket activity, so a model streaming a large +// argument payload was indistinguishable from a hung upstream. +// +// Found while investigating #2156 but deliberately NOT claimed as its fix: a stall abort +// emits `response.incomplete` with `upstream_stall_timeout`, while that report shows the +// adapter's own EOF error with tool calls still pending. This pins the mechanism only. test("tool-call deltas emit heartbeats so a long buffering phase is not read as a stall", async () => { const adapter = createOpenAIChatAdapter(provider()); const frames = ['data: ' + JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_a", function: { name: "shell", arguments: "" } }] } }] }) + '\n\n'];