Skip to content

fix(chat-completions): emit tool call arguments once#363

Merged
lidge-jun merged 1 commit into
lidge-jun:devfrom
snowyukitty:fix/chat-completions-tool-call-deltas
Jul 24, 2026
Merged

fix(chat-completions): emit tool call arguments once#363
lidge-jun merged 1 commit into
lidge-jun:devfrom
snowyukitty:fix/chat-completions-tool-call-deltas

Conversation

@snowyukitty

@snowyukitty snowyukitty commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • buffer incremental and finalized Responses function-call argument events in the Chat Completions
    outbound bridge
  • emit each tool call at most once from output_item.done, with a terminal flush when a valid
    completed/incomplete response omits the item-done event
  • preserve stable parallel-call indices and retain buffered arguments when a final snapshot is
    missing or unexpectedly empty

Fixes #361.

Why

The internal Responses stream can contain incremental
response.function_call_arguments.delta events, a finalized
response.function_call_arguments.done event, and a complete response.output_item.done
snapshot. The converter forwarded incremental arguments and the item snapshot as Chat Completions
deltas. Clients that correctly append function.arguments therefore received duplicated JSON such
as:

{"command":"ls"}{"command":"ls"}

Buffering only the tool-call arguments and emitting one complete tool-call delta keeps ordinary
text/reasoning streaming, preserves the final snapshot, and leaves clients that replace the
function object with a complete id / name / arguments tuple. An emitted-index guard prevents
duplicate item-done events from recreating the corruption, while a terminal flush preserves known
tool calls on valid incomplete responses.

This intentionally delays tool-argument visibility until the call or response is finalized;
ordinary text and reasoning remain incremental.

Verification

  • bun install --frozen-lockfile
  • bun run test tests/chat-completions-endpoint.test.ts — 25 passed, 0 failed
  • bun run typecheck
  • bun run privacy:scan
  • git diff --check upstream/dev...HEAD
  • bun run test — changed suite passed; the full Windows run reached 3,878 passed and 4 skipped,
    with 2 unrelated Cursor native exec bridge failures because this environment lacks printf
  • bun run test tests/cursor-native-exec.test.ts with a temporary compatible printf on PATH
    — 16 passed, confirming the two full-suite failures were environment-dependent

The focused tests cover the issue #361 duplicate sequence, authoritative done-frame reconciliation,
interleaved parallel calls with complete stable identities, missing/empty final arguments,
duplicate done events, terminal flushing, and finalized arguments without an item-done event.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Behavior changes have focused regression coverage.
  • No authentication, credential, dependency, workflow, or logging behavior changes.

Summary by CodeRabbit

  • Bug Fixes
    • Improved streaming of tool and function calls.
    • Ensured tool-call names and arguments are delivered completely and only once, including for parallel calls.
    • Prevented tool calls from being lost when streams end unexpectedly or provide incomplete, empty, or duplicate final updates.
    • Preserved stable tool-call ordering and indexing during streamed responses.

Buffer Responses function-call argument events and emit one complete Chat Completions tool-call delta per index. Preserve pending calls across valid terminal frames, use finalized snapshots defensively, and ignore duplicate done events.
@github-actions github-actions Bot added the bug Something isn't working label Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The SSE converter now buffers streamed tool-call identity and arguments by stable index, emits each complete tool call once, preserves non-empty buffered arguments, and flushes pending calls before terminal frames. Tests cover parallel calls and multiple incomplete or duplicate event sequences.

Changes

Tool-call streaming

Layer / File(s) Summary
Buffered tool-call emission
src/chat/outbound.ts
Adds per-tool buffers, final-argument resolution, duplicate suppression, and flushing before completion frames.
Upstream event reconciliation
src/chat/outbound.ts
Updates output-item and argument event handlers to populate stable indexes, accumulate arguments, and emit finalized calls centrally.
End-to-end stream validation
tests/chat-completions-endpoint.test.ts
Adds shared SSE conversion utilities and tests for parallel calls, omitted or empty snapshots, incomplete streams, duplicate events, and missing item completion events.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ResponsesEvents
  participant responsesSseToChatCompletionsSse
  participant ChatCompletionsClient
  ResponsesEvents->>responsesSseToChatCompletionsSse: send output-item and argument events
  responsesSseToChatCompletionsSse->>responsesSseToChatCompletionsSse: buffer identity and argument text
  responsesSseToChatCompletionsSse->>ChatCompletionsClient: emit one complete tool-call chunk
  responsesSseToChatCompletionsSse->>ChatCompletionsClient: flush pending calls before finish
Loading

Suggested reviewers: ingwannu, hayderncenterpoint, lidge-jun

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: emitting Chat Completions tool-call arguments only once.
Linked Issues check ✅ Passed src/chat/outbound.ts buffers and flushes tool-call args once, satisfying #361's requirement to stop duplicate streamed arguments.
Out of Scope Changes check ✅ Passed The changes stay within the streaming tool-call fix and its regression tests, with no unrelated feature work apparent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chat/outbound.ts`:
- Around line 277-285: Extract a shared resolveToolIndex helper for the
response.output_item.added and response.output_item.done handlers in
outbound.ts. Centralize the lookup order of callId, itemId, and new index
allocation, and ensure the helper keeps toolCallIdByIndex, toolIndexByItemId,
and related index maps synchronized; replace both duplicated resolution
implementations with this helper while preserving each handler’s existing
argument and name updates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0077a661-bfa4-40a1-ac4a-432a3d815fe2

📥 Commits

Reviewing files that changed from the base of the PR and between 61b1854 and b9194da.

📒 Files selected for processing (2)
  • src/chat/outbound.ts
  • tests/chat-completions-endpoint.test.ts

Comment thread src/chat/outbound.ts
Comment on lines +277 to +285
toolCallIdByIndex.set(toolIndex, callId);
if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
if (name) toolNameByIndex.set(toolIndex, name);
const frame = chunkBase(id, model, created);
frame.choices = [{
index: 0,
delta: {
tool_calls: [{
index: toolIndex,
id: callId,
type: "function",
// Always include name so clients that replace (not merge) tool_calls
// deltas never end up with function.name-less history.
function: { name, arguments: "" },
}],
},
finish_reason: null,
}];
emit(frame);
if (!toolArgumentsByIndex.has(toolIndex)) {
toolArgumentsByIndex.set(
toolIndex,
typeof item.arguments === "string" ? item.arguments : "",
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract a shared resolveToolIndex helper to dedupe index resolution.

response.output_item.added (Lines 272-279) and response.output_item.done (Lines 322-328) both re-implement "look up toolIndex by callId, fall back to itemId, else allocate new, then keep all three maps in sync." The two implementations are subtly different (added only checks the callId map before allocating; done checks both) which makes them easy to accidentally diverge the next time either handler is touched — exactly the kind of drift this PR's fix is designed to prevent.

♻️ Proposed refactor
+      const resolveToolIndex = (callId: string, itemId: string | undefined): number => {
+        const existing = toolIndexByCallId.get(callId)
+          ?? (itemId ? toolIndexByItemId.get(itemId) : undefined);
+        const toolIndex = existing ?? nextToolIndex++;
+        toolIndexByCallId.set(callId, toolIndex);
+        toolCallIdByIndex.set(toolIndex, callId);
+        if (itemId) toolIndexByItemId.set(itemId, toolIndex);
+        return toolIndex;
+      };

In response.output_item.added:

-            let toolIndex = toolIndexByCallId.get(callId);
-            if (toolIndex === undefined) {
-              toolIndex = nextToolIndex++;
-              toolIndexByCallId.set(callId, toolIndex);
-            }
-            toolCallIdByIndex.set(toolIndex, callId);
-            if (typeof item.id === "string") toolIndexByItemId.set(item.id, toolIndex);
+            const toolIndex = resolveToolIndex(callId, typeof item.id === "string" ? item.id : undefined);

In response.output_item.done:

-              const itemId = typeof item.id === "string" ? item.id : undefined;
-              const existingIndex = toolIndexByCallId.get(callId)
-                ?? (itemId ? toolIndexByItemId.get(itemId) : undefined);
-              const toolIndex = existingIndex ?? nextToolIndex++;
-              toolIndexByCallId.set(callId, toolIndex);
-              toolCallIdByIndex.set(toolIndex, callId);
-              if (itemId) toolIndexByItemId.set(itemId, toolIndex);
+              const toolIndex = resolveToolIndex(callId, typeof item.id === "string" ? item.id : undefined);

Also applies to: 321-339

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/outbound.ts` around lines 277 - 285, Extract a shared
resolveToolIndex helper for the response.output_item.added and
response.output_item.done handlers in outbound.ts. Centralize the lookup order
of callId, itemId, and new index allocation, and ensure the helper keeps
toolCallIdByIndex, toolIndexByItemId, and related index maps synchronized;
replace both duplicated resolution implementations with this helper while
preserving each handler’s existing argument and name updates.

@lidge-jun
lidge-jun merged commit affc477 into lidge-jun:dev Jul 24, 2026
12 checks passed
lidge-jun added a commit that referenced this pull request Jul 24, 2026
lidge-jun added a commit that referenced this pull request Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants