providers: surface non-normal terminal turns instead of silent empty completions - #263
Conversation
…completions Four stream-adapter cases where a non-normal turn was treated as a clean empty completion, hiding the real outcome. Each ships a regression test. - openai/codex: a tool call arriving with output_index 0 and no item_id had its argument deltas dropped (the call dispatched with empty JSON). output_index is now a *int so a real 0 is distinguishable from absent. - openai/codex: response.failed / response.completed with a nil payload returned silently with no terminal event; now failed emits an error and completed emits done, so a failure is never masked as an empty success. - gemini: terminal finishReasons (RECITATION, MALFORMED_FUNCTION_CALL, IMAGE_SAFETY, OTHER, LANGUAGE, UNEXPECTED_TOOL_CALL) were treated as a normal stop; safety reasons now map to content_filter and the rest surface the raw reason so the truncation notice fires. - anthropic: stop_reason "refusal" was treated as a normal completion; it now maps to content_filter.
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (2)
WalkthroughThree provider-level correctness fixes: Anthropic's ChangesProvider finish/stop reason fixes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/login. Comment |
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Review: providers — surface non-normal terminal turns instead of silent empty completions
Verdict direction
The core fixes are sound and well-motivated. Three real bugs are addressed:
- M1 (Codex):
OutputIndexwas a plainintdefaulting to 0, so a no-item_idcall withoutput_index: 0was dropped (the> 0guard rejected it). Switching to*intdistinguishes "absent" from a real first-output zero — correct fix, andtoolCallKeynow keys on it. Good unit coverage inTestToolCallKeyOutputIndexZero. - M2 (Codex):
handleTerminalResponsereturned silently whenevent.Response == nil, so aresponse.failedwith no payload became a clean-looking empty completion. Now it emitsStreamEventErrorfor failed and a cleanDonefor completed. Correct, and the test inTestHandleTerminalResponseNilPayloadcovers both branches. - M3/M4 (Gemini/Anthropic): previously unmapped non-normal reasons (
RECITATION,IMAGE_SAFETYfor Gemini;refusalfor Anthropic) were collapsed to""(clean completion). Now Gemini surfaces the raw reason for anything not in the normal set, and Anthropic mapsrefusaltocontent_filter. Both are correct and align withTruncationNotice's "Response ended early ()" rendering.
Findings
P1 — None. No correctness, security, or data-loss issues in the diff.
P2
- gofmt violation in
responsesEventstruct —internal/providers/openai/codex_responses.go:118-121. The PR changesOutputIndexfromintto*int, which shifts the alignment column, butItemID's tag was not re-aligned.gofmt -dflags this:This will fail- ItemID string `json:"item_id,omitempty"` + ItemID string `json:"item_id,omitempty"`gofmt -l/ lint in CI. The other struct lines (OutputIndex,Item) are already misaligned in the same way in the PR snippet, so the whole block needs re-running throughgofmt -w. (I verified by reconstructing the changed struct and runninggofmt -d— it wantsItemID stringwith the extra padding to match the new widest type*int/*itemPayload.)
P3
-
Gemini: significant end-to-end test coverage is deleted and not replaced —
internal/providers/gemini/finish_reason_test.go. The PR replaces 145 lines with a single unit test (TestMapFinishReasonNonNormal) ofmapFinishReasononly. This deletes:TestStreamCompletionSurfacesMaxTokensFinishReasonTestStreamCompletionSurfacesSafetyFinishReasonTestStreamCompletionNormalFinishReasonHasNoReasonTestStreamCompletionEmitsDroppedOnNamelessFunctionCallPartTestStreamCompletionEmitsDroppedOnNamelessTopLevelFunctionCallTestStreamCompletionDoesNotDropValidFunctionCall
The new unit test covers
mapFinishReasonin isolation, but the deleted tests verified the full SSE →streamState.finishReason→Doneevent wiring. The M3 change widensmapFinishReason's default branch to return the raw reason, and that new behavior is exercised by the unit test — but the integration path (does aRECITATIONcandidate actually reach theDoneevent'sFinishReason?) is now untested. The threeToolCallDroppedtests are orthogonal to this PR's goal and their deletion removes the only coverage for the nameless-functionCall drop behavior in the Gemini provider (the production code inprovider.go:248andprovider.go:265is unchanged, so this is a net loss of coverage rather than a behavior regression). Recommendation: keepTestMapFinishReasonNonNormaland restore at least the three dropped-tool-call tests (they belong to a different feature and shouldn't ride along with this PR); consider restoring the finish-reason integration tests too, or moving them to a streaming-test file if the file name was the issue. -
Anthropic:
mapStopReasoncomment listspause_turnas normal without verification —internal/providers/anthropic/provider.go:607. The new default-branch comment sayspause_turn (and "")are normal. The test asserts this, but there's no citation to Anthropic's API docs thatpause_turnis a non-terminal/normal reason. Ifpause_turnis actually an early-stop signal (e.g. for the new pause-and-resume feature), collapsing it to""would re-introduce the silent-empty-completion bug this PR is fixing. Worth a quick doc/link confirmation, or dropping it from the "normal" list until confirmed. -
Codex M2:
response.incompleteis not a terminal event here —internal/providers/openai/codex_responses.go:494routes onlyresponse.completed/response.failedtohandleTerminalResponse;response.incompletefalls through to thedefault(ignored) branch. That's pre-existing behavior, but the PR's M2 comment frames the nil-payload fix as closing the "silent empty completion" hole —response.incompletewith a nilResponsepayload would still be silently ignored. Out of scope for this PR, but worth a follow-up note since the PR title is broad ("non-normal terminal turns").
Suggested actions
- Run
gofmt -w internal/providers/openai/codex_responses.goand re-check the struct alignment (P2 #1). - Restore the three Gemini
ToolCallDroppedtests (P3 #2) — they're unrelated to this PR's goal and shouldn't be deleted. - Add a one-line citation for
pause_turnbeing normal, or drop it from the comment (P3 #3).
Overall this is a good, focused fix; the blocking item is the gofmt issue, the rest are nits/coverage observations.
…ause_turn Review follow-ups on the provider silent-failure fixes: - Gemini: restore the end-to-end finish-reason and dropped-tool-call tests that the earlier refactor removed, alongside the mapFinishReason unit test. This re-covers the full SSE -> done-event wiring (MAX_TOKENS -> length, SAFETY -> content_filter, normal STOP -> "") and the nameless-functionCall drop signal, and adds a RECITATION integration test that exercises the M3 widening through the streaming path (not just mapFinishReason in isolation). - Anthropic: document why pause_turn is classified as a normal stop — it is the long-running-turn pause (server-side tools) the client resumes by sending the response back, not a truncation/refusal, so it correctly maps to "" rather than firing a spurious truncation notice. gofmt/vet/build clean; gemini/anthropic/openai suites pass.
|
Thanks for the thorough review — addressed in P2 (gofmt — P3 #2 (deleted Gemini coverage) — restored. Brought back all six tests the refactor dropped, alongside the
And added P3 #3 ( P3 #4 ( gofmt/vet/build clean; gemini/anthropic/openai suites green. |
…object A response.failed terminal event carrying a non-nil Response whose `error` object is null/omitted (the backend can report the failure only via `status`) fell through to a clean StreamEventDone — reporting a real failure as a normal successful turn. Only the nil-payload path was guarded. Branch on the failed terminal (event type / Response.Status) before the clean-done fallthrough and emit StreamEventError. Adds a regression test for the non-nil-payload-without- error case (verified to fail without the fix).
|
Follow-up hardening from a self-review pass ( |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Re-reviewed after 8efb611 ("address review: restore Gemini tests; justify pause_turn"). All three items from my prior CHANGES_REQUESTED are resolved, and a new-issue scan found nothing blocking. Approving.
Prior items — all resolved
- Blocking gofmt (responsesEvent struct alignment) — fixed.
gofmt -lis clean oninternal/providers/openai/codex_responses.go; the doc comment onOutputIndexcorrectly breaks the alignment block soOutputIndex *int/Item *itemPayloadalign as their own group. - Restore the deleted Gemini tests — fixed. All six are back, and the net diff vs main is now purely additive: the original 6 plus 3 new (
TestMapFinishReasonNonNormal,TestStreamCompletionSurfacesRecitationFinishReason, and the SSE-wired RECITATION integration test). Coverage is strictly better than main. - Justify "pause_turn is normal" — fixed.
anthropic/provider.gonow explainspause_turnis Anthropic's long-running-turn pause for server-side tools (client resumes by sending the response back — not a truncation/refusal), so mapping it to""correctly avoids a spurious truncation notice.TestMapStopReasonRefusalpinspause_turn → "".
New-issue scan — clean
- The
OutputIndex int → *intchange is internally consistent:toolCallKeykeys on*event.OutputIndexdirectly and drops the old+1offset (the offset only existed to reserve0for "absent", whichnilnow expresses).TestToolCallKeyOutputIndexZerocovers 0 / 2 / absent / item_id-precedence. - Gemini's widened
default: return reasonis safe end-to-end — it setsstate.finishReason→ Done event →Result.TruncationNotice(), surfaced in exec + TUI; no raw enum leaks improperly. - Codex nil-payload handling correctly splits
response.failed(emitsStreamEventError) fromresponse.completed(cleanDone);TestHandleTerminalResponseNilPayloadcovers both.
Thanks for the thorough turnaround on the feedback — this is a clean, well-tested fix.
Summary
Four provider stream-adapter bugs where a non-normal terminal turn was treated as a clean, empty completion — so a dropped tool call, an upstream failure, a content block, or a refusal looked like the model simply returned nothing. Surfaced by a full-tree audit; each ships a regression test.
Fixes
output_index0 and noitem_idhad its argument deltas dropped — the tool ran with empty/partial JSON.output_indexwas anint, so a real 0 was indistinguishable from "absent".output_indexis now a*int;toolCallKeykeys on it even when 0.response.failed/response.completedwith a nil payload returned with no terminal event — the collector saw a clean empty completion that masked a real failure.failedemitsStreamEventError,completedemitsStreamEventDone.finishReasons (RECITATION, MALFORMED_FUNCTION_CALL, IMAGE_SAFETY, OTHER, LANGUAGE, UNEXPECTED_TOOL_CALL) mapped to a normal stop → silent empty/truncated turn.content_filter; remaining non-STOP reasons surface the raw reason so the "ended early (…)" notice fires. Only""/STOP/FINISH_REASON_UNSPECIFIEDare normal.stop_reason: refusaltreated as a normal completion → silent empty turn.refusal→content_filter.Tests
TestToolCallKeyOutputIndexZero,TestHandleTerminalResponseNilPayload,TestMapFinishReasonNonNormal,TestMapStopReasonRefusal.go build ./...,go vet ./...,gofmt, and the fullgo test ./...are all green.Summary by CodeRabbit