-
Notifications
You must be signed in to change notification settings - Fork 854
fix(openai-chat): heartbeat while buffering tool-call deltas #2180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -40,7 +40,10 @@ function provider(overrides: Partial<OcxProviderConfig> = {}): OcxProviderConfig | |||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async function collect(stream: AsyncGenerator<AdapterEvent>): Promise<AdapterEvent[]> { | ||||||||||||||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||||||||||||||
| for await (const event of stream) if (event.type !== "heartbeat") events.push(event); | ||||||||||||||||||||||||||||||||||||||||||||||
| return events; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -920,4 +923,33 @@ describe("openai-chat response_format emission", () => { | |||||||||||||||||||||||||||||||||||||||||||||
| .toEqual({ type: "json_object" }); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // 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']; | ||||||||||||||||||||||||||||||||||||||||||||||
| // 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" }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+948
to
+953
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Assert the complete heartbeat and tool-call sequence. This fixture contains 13 tool-call deltas, but Proposed regression assertions- expect(raw.filter(e => e.type === "heartbeat").length).toBeGreaterThanOrEqual(12);
+ expect(raw.filter(e => e.type === "heartbeat").length).toBe(13);
...
+ expect(visible.map(e => e.type)).toEqual([
+ "tool_call_start",
+ "tool_call_delta",
+ "tool_call_end",
+ "done",
+ ]);
+ expect(visible).toContainEqual({
+ type: "tool_call_delta",
+ arguments: '"x"'.repeat(12),
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| }); | ||
|
Comment on lines
+202
to
+237
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Test the non-retention behavior directly. These assertions cannot detect a regression that removes the heartbeat branch in Extract the retention decision into a helper used by As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.” 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| test("stops after the configured continuation bound", async () => { | ||
| let continuations = 0; | ||
| const actual: AdapterEvent[] = []; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an
openai-chatprovider enablesterminalContinuationGuard,guardTerminalEventStreamappends every nonterminal event to itsseenarray (src/server/responses/terminal-guard.ts:193-225). Emitting a fresh heartbeat for every fragment therefore makes a finely chunked tool call—especially a stream of empty argument deltas, which consumes no tool-argument budget—retain an unbounded number of objects until the terminal frame, potentially exhausting proxy memory. Pass heartbeats through without adding them toseen(or coalesce them), and exercise the configured terminal-guard path in the regression test rather than collecting the adapter directly.AGENTS.md reference: src/AGENTS.md:L24-L26
Useful? React with 👍 / 👎.