Skip to content

fix: handle finish_reason "length" and stream-end without finish_reason in processToolCalls (fixes #425)#426

Open
proyectoauraorg wants to merge 5 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:fix/425-processToolCalls-stream-termination
Open

fix: handle finish_reason "length" and stream-end without finish_reason in processToolCalls (fixes #425)#426
proyectoauraorg wants to merge 5 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:fix/425-processToolCalls-stream-termination

Conversation

@proyectoauraorg
Copy link
Copy Markdown
Contributor

@proyectoauraorg proyectoauraorg commented May 31, 2026

Summary

Fixes #425. When a streaming tool call is interrupted by token exhaustion, tool calls end up with empty nativeArgs because processToolCalls() only finalizes on finish_reason: "tool_calls". This affects any OpenAI-compatible provider when max_tokens is hit mid-tool-call.

Root Cause

processToolCalls() in openai.ts:498 only emits tool_call_end when finishReason === "tool_calls". It ignores:

  • finish_reason: "length" — standard OpenAI behavior when output tokens are exhausted
  • Stream termination without finish_reason — behavior of some proxies (e.g., MiMo emits choices: [])

Additionally, neither OpenAiHandler.createMessage() nor MimoHandler.createMessage() had post-loop cleanup for orphaned activeToolCallIds.

Changes

Core fix — openai.ts

  1. processToolCalls(): Also trigger tool_call_end on finish_reason: "length"
  2. createMessage(): Add post-loop cleanup — finalize orphaned tool calls when stream ends
  3. handleStreamResponse(): Same post-loop cleanup for the alternate streaming path

Provider fix — mimo.ts

  1. MimoHandler.createMessage(): Add post-loop cleanup for MiMo-specific streaming path (MiMo proxy emits choices: [] without finish_reason when tokens are exhausted)

UX improvement — presentAssistantMessage.ts

  1. Improve error message with actionable guidance: suggest simplifying the request, breaking it into smaller steps, or increasing max output tokens

Tests

openai.spec.ts

  • finish_reason: "length" triggers tool_call_end (standard OpenAI behavior)
  • Stream end without finish_reason triggers post-loop cleanup (proxy termination)

mimo.spec.ts

  • MiMo-style choices: [] termination triggers tool_call_end via post-loop cleanup
  • finish_reason: "length" triggers tool_call_end

All 134 tests pass (54 openai + 47 mimo + 33 assistant-message).

Test Plan

  1. cd src && npx vitest run api/providers/__tests__/openai.spec.ts — 54/54 ✅
  2. cd src && npx vitest run api/providers/__tests__/mimo.spec.ts — 47/47 ✅
  3. cd src && npx vitest run core/assistant-message — 33/33 ✅

Related Issues

Alignment with CONTRIBUTING.md

Reliability First: Ensure diff editing and command execution are consistently reliable. Expand robust support for a wide variety of AI providers and models.

This fix directly improves reliability for all OpenAI-compatible providers when output tokens are exhausted mid-tool-call.

Summary by CodeRabbit

  • Bug Fixes

    • Finalized incomplete tool calls when streams end unexpectedly (covers proxy/stream truncation and length-based terminations).
    • Improved user-facing error for truncated tool arguments with actionable suggestions (simplify request, split steps, increase max output tokens).
  • Tests

    • Added streaming tests to ensure tool-call completion events are emitted in advanced/edge streaming scenarios.
  • Chores

    • Updated uuid dependency version.

proyectoauraorg and others added 4 commits May 26, 2026 00:05
…provider

Merging 12 new test cases covering completePrompt, streaming resilience, and edge cases.
Dependabot bump. Compatible: project requires node>=20.20.2, uuid v14 requires node>=20.
# Conflicts:
#	pnpm-lock.yaml
…on in processToolCalls

When a streaming tool call is interrupted by token exhaustion, tool calls
ended up with empty nativeArgs because processToolCalls() only finalized
on finish_reason 'tool_calls'. This affected any OpenAI-compatible provider
when max_tokens was hit mid-tool-call.

Changes:
- processToolCalls(): Also trigger tool_call_end on finish_reason 'length'
- OpenAiHandler.createMessage(): Add post-loop cleanup for orphaned tool calls
- handleStreamResponse(): Add post-loop cleanup for orphaned tool calls
- MimoHandler.createMessage(): Add post-loop cleanup for orphaned tool calls
- presentAssistantMessage.ts: Improve error message with actionable guidance

Tests added:
- openai.spec.ts: finish_reason 'length' triggers tool_call_end
- openai.spec.ts: stream-end without finish_reason triggers post-loop cleanup
- mimo.spec.ts: MiMo-style choices:[] termination triggers tool_call_end
- mimo.spec.ts: finish_reason 'length' triggers tool_call_end

Fixes Zoo-Code-Org#425
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 31, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 88ae5acd-1fd0-44a8-88ce-5b60543a40d4

📥 Commits

Reviewing files that changed from the base of the PR and between 6a7291f and 8596ce3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • src/api/providers/__tests__/mimo.spec.ts
  • src/package.json
  • tmp/README.md

📝 Walkthrough

Walkthrough

This PR finalizes active streaming tool calls when streams end due to token exhaustion or missing finish_reason: it emits tool_call_end post-loop in OpenAI and MiMo handlers, treats finish_reason: "length" as a finalizer, updates the missing-nativeArgs error message, adds tests covering these edge cases, and bumps uuid.

Changes

Streaming Tool Call Finalization for Token Limits

Layer / File(s) Summary
OpenAI streaming tool call finalization
src/api/providers/openai.ts, src/api/providers/__tests__/openai.spec.ts
Adds end-of-stream cleanup in createMessage() and handleStreamResponse() to emit tool_call_end for any unfinalized tool call IDs, and updates processToolCalls() to finalize tool calls when finish_reason is "length" as well as "tool_calls". Tests verify both "length" and missing finish_reason terminations.
MiMo streaming tool call finalization
src/api/providers/mimo.ts, src/api/providers/__tests__/mimo.spec.ts
Adds post-loop cleanup in createMessage() to finalize any active tool calls when MiMo streams end without finish_reason, emitting tool_call_end for each active ID. Tests cover premature termination (empty choices) and finish_reason: "length". Also includes advanced streaming scenario tests.
User-facing error message for token exhaustion
src/core/assistant-message/presentAssistantMessage.ts
Updates the "missing nativeArgs" error message to state that token exhaustion commonly causes incomplete tool-argument streaming and provides actionable suggestions (simplify request, break into steps, increase max output tokens).
Dependency bump
src/package.json
Updates uuid dependency from ^11.1.0 to ^14.0.0.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • taltas
  • navedmerchant
  • hannesrudolph
  • edelauna

Poem

🐰 In streams that stop when tokens are few,

the rabbit hops in to close what's due.
A final "tool_call_end" for each active id,
so orphaned calls no longer hide.
Hooray — tidy streams, the handlers smile wide!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The uuid dependency update in package.json (^11.1.0 to ^14.0.0) is an unrelated out-of-scope change not mentioned in issue #425 or the PR objectives. Remove the uuid version bump from package.json or create a separate PR for dependency updates, as it is unrelated to fixing the tool_call_end finalization bug.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: handling finish_reason "length" and stream-end without finish_reason in processToolCalls to address issue #425.
Description check ✅ Passed The PR description is comprehensive, covering root cause analysis, implementation details, test coverage, and alignment with guidelines. It follows the template structure with clear sections.
Linked Issues check ✅ Passed All coding requirements from issue #425 are met: finish_reason "length" handling, post-loop cleanup in OpenAI and MiMo handlers, improved error message, and comprehensive test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/api/providers/__tests__/mimo.spec.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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/api/providers/openai.ts`:
- Around line 474-482: The orphaned-tool cleanup currently runs inside the
streaming chunk loop and prematurely emits tool_call_end and clears
activeToolCallIds on the first partial delta; move the block that iterates
activeToolCallIds and yields { type: "tool_call_end", id } to execute after the
stream loop completes (i.e., once end-of-stream/stream termination is reached)
so activeToolCallIds persists across subsequent chunks; update the logic that
currently references activeToolCallIds.clear() to only run in that post-loop
finalization path (referencing activeToolCallIds and the yield { type:
"tool_call_end", id } emission).
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f4d4619d-2c46-4967-a641-ce33d197baba

📥 Commits

Reviewing files that changed from the base of the PR and between 71db2e6 and 6a7291f.

📒 Files selected for processing (5)
  • src/api/providers/__tests__/mimo.spec.ts
  • src/api/providers/__tests__/openai.spec.ts
  • src/api/providers/mimo.ts
  • src/api/providers/openai.ts
  • src/core/assistant-message/presentAssistantMessage.ts

Comment on lines +474 to +482
// Finalize any tool calls that weren't explicitly ended by finish_reason.
// This handles cases where the stream terminates without a proper finish_reason
// (e.g., some OpenAI-compatible proxies emit choices: [] when tokens are exhausted).
if (activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Move the orphaned-tool cleanup outside the stream loop.

This block still runs on every chunk, not at end-of-stream. Line 477 will emit tool_call_end after the first partial tool-call delta and clear activeToolCallIds, so the O3 streaming path can finalize tools before later argument chunks arrive.

Proposed fix
 		for await (const chunk of stream) {
 			const delta = chunk.choices?.[0]?.delta
 			const finishReason = chunk.choices?.[0]?.finish_reason

 			if (delta) {
 				if (delta.content) {
 					yield {
 						type: "text",
 						text: delta.content,
 					}
 				}

 				yield* this.processToolCalls(delta, finishReason, activeToolCallIds)
 			}

 			if (chunk.usage) {
 				yield {
 					type: "usage",
 					inputTokens: chunk.usage.prompt_tokens || 0,
 					outputTokens: chunk.usage.completion_tokens || 0,
 				}
 			}
-
-			// Finalize any tool calls that weren't explicitly ended by finish_reason.
-			// This handles cases where the stream terminates without a proper finish_reason
-			// (e.g., some OpenAI-compatible proxies emit choices: [] when tokens are exhausted).
-			if (activeToolCallIds.size > 0) {
-				for (const id of activeToolCallIds) {
-					yield { type: "tool_call_end", id }
-				}
-				activeToolCallIds.clear()
-			}
 		}
+
+		// Finalize any tool calls that weren't explicitly ended by finish_reason.
+		// This handles cases where the stream terminates without a proper finish_reason
+		// (e.g., some OpenAI-compatible proxies emit choices: [] when tokens are exhausted).
+		if (activeToolCallIds.size > 0) {
+			for (const id of activeToolCallIds) {
+				yield { type: "tool_call_end", id }
+			}
+			activeToolCallIds.clear()
+		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Finalize any tool calls that weren't explicitly ended by finish_reason.
// This handles cases where the stream terminates without a proper finish_reason
// (e.g., some OpenAI-compatible proxies emit choices: [] when tokens are exhausted).
if (activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason
if (delta) {
if (delta.content) {
yield {
type: "text",
text: delta.content,
}
}
yield* this.processToolCalls(delta, finishReason, activeToolCallIds)
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
// Finalize any tool calls that weren't explicitly ended by finish_reason.
// This handles cases where the stream terminates without a proper finish_reason
// (e.g., some OpenAI-compatible proxies emit choices: [] when tokens are exhausted).
if (activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
🤖 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/api/providers/openai.ts` around lines 474 - 482, The orphaned-tool
cleanup currently runs inside the streaming chunk loop and prematurely emits
tool_call_end and clears activeToolCallIds on the first partial delta; move the
block that iterates activeToolCallIds and yields { type: "tool_call_end", id }
to execute after the stream loop completes (i.e., once end-of-stream/stream
termination is reached) so activeToolCallIds persists across subsequent chunks;
update the logic that currently references activeToolCallIds.clear() to only run
in that post-loop finalization path (referencing activeToolCallIds and the yield
{ type: "tool_call_end", id } emission).

@codecov
Copy link
Copy Markdown

codecov Bot commented May 31, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: processToolCalls() ignores finish_reason "length" and stream-end without finish_reason, causing missing nativeArgs

1 participant