Skip to content

fix(openai): handle Ollama reasoning stream deltas#486

Merged
kevincodex1 merged 2 commits into
Gitlawb:mainfrom
Ashwinhegde19:fix-ollama-reasoning-alias
Jul 5, 2026
Merged

fix(openai): handle Ollama reasoning stream deltas#486
kevincodex1 merged 2 commits into
Gitlawb:mainfrom
Ashwinhegde19:fix-ollama-reasoning-alias

Conversation

@Ashwinhegde19

@Ashwinhegde19 Ashwinhegde19 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #439

Summary

  • parse OpenAI-compatible streamed delta.reasoning as an alias for delta.reasoning_content
  • emit alias reasoning deltas as reasoning events so long-thinking Ollama models count as active
  • preserve existing reasoning_content precedence when both fields are present

Tests

  • go test ./internal/providers/openai -count=1
  • make lint
  • make test-quick

Summary by CodeRabbit

  • New Features
    • Stream-JSON output now emits separate reasoning events, and the CLI routes reasoning output during headless exec runs.
  • Bug Fixes
    • Improved reasoning streaming mapping to handle alternate delta fields and correctly prioritize reasoning_content.
    • Updated no-output guardrails so reasoning-only turns count as progress (preventing premature stopping).
  • Documentation
    • Expanded Stream-JSON protocol docs with the new reasoning event behavior and examples.
  • Tests
    • Added unit and CLI integration coverage for reasoning alias/prioritization, guardrail behavior, and exec Stream-JSON reasoning ordering.

Copilot AI review requested due to automatic review settings July 4, 2026 12:49

Copilot AI left a comment

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.

Pull request overview

This PR fixes a compatibility gap in the OpenAI-compatible streaming adapter so Ollama “thinking” models that stream deltas in delta.reasoning are treated as active and their reasoning deltas are surfaced as reasoning stream events, preventing “no output” stalls.

Changes:

  • Extend streamed delta parsing to accept reasoning as an alias for reasoning_content.
  • Emit reasoning events using a helper that preserves reasoning_content precedence when both fields are present.
  • Add unit tests covering alias emission and precedence behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
internal/providers/openai/types.go Adds delta.reasoning to the streaming delta struct to support Ollama’s alias field.
internal/providers/openai/provider.go Emits reasoning using a precedence helper so alias deltas produce StreamEventReasoning events.
internal/providers/openai/provider_test.go Adds tests verifying alias deltas emit reasoning events (and not text) and that reasoning_content wins when both exist.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2a4c3359-8091-4f0c-a3dc-d6e347953b6b

📥 Commits

Reviewing files that changed from the base of the PR and between 26df701 and afd5d00.

📒 Files selected for processing (1)
  • internal/cli/exec_protocol_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/cli/exec_protocol_test.go

Walkthrough

Adds reasoning alias handling in OpenAI streaming, propagates reasoning presence through collected streams, emits reasoning in CLI output, and treats reasoning-only turns as progress in agent guardrails.

Changes

Reasoning delta flow

Layer / File(s) Summary
OpenAI reasoning alias mapping
internal/providers/openai/types.go, internal/providers/openai/provider.go, internal/providers/openai/provider_test.go
Adds reasoning to streamed deltas, resolves reasoning text from reasoning_content or reasoning, and tests alias-only plus precedence cases.
Collected stream reasoning flag
internal/zeroruntime/helpers.go, internal/zeroruntime/provider_test.go
Adds CollectedStream.HasReasoning and sets it when non-whitespace reasoning deltas are collected, with test coverage.
CLI reasoning output
internal/streamjson/streamjson.go, internal/cli/exec_writer.go, internal/cli/exec.go, docs/STREAM_JSON_PROTOCOL.md, internal/cli/exec_protocol_test.go
Adds reasoning emission through exec output, wires the callback, extends stream-json with a reasoning event, documents the event, and verifies the protocol output.
Reasoning counts as turn progress
internal/agent/guardrails.go, internal/agent/guardrails_test.go
Counts reasoning as visible progress for empty-turn tracking and adds a reasoning-only run test with a helper stream.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • Gitlawb/zero#345: Also updates streaming reasoning handling to surface provider reasoning output as StreamEventReasoning events.

Suggested reviewers: Vasanthdev2004

🚥 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 summarizes the main change: handling Ollama reasoning stream deltas in the OpenAI provider.
Linked Issues check ✅ Passed The PR parses reasoning as an alias, counts reasoning as liveness, and adds tests for the no-output guardrail.
Out of Scope Changes check ✅ Passed The CLI stream-json reasoning support and docs remain aligned with the same reasoning-delta fix and are not unrelated scope.
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

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.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The parsing change is correct — reasoning as an alias for reasoning_content, right precedence when both are present, emitted as a reasoning event so it's not mistaken for output. Tests are good.

But "Fixes #439" claims too much. This makes the thinking visible, which handles the "looks idle" half. The other half people hit — runs dying after 3 empty turns — comes from observeTurn in guardrails.go, which this doesn't touch: reasoning deltas go to OnReasoning and never get folded into the turn's text, so a reasoning-only turn still counts as empty, and three of them still trip maxEmptyTurns and stop with "no output". So merging this as-is would auto-close #439 while the main symptom's still there.

Either change "Fixes" to "Refs" so the issue stays open, or add the guardrail half (count a reasoning-bearing turn as live). The code itself is fine to merge — just don't let it close #439 yet.

@Ashwinhegde19 Ashwinhegde19 force-pushed the fix-ollama-reasoning-alias branch from 815f744 to 27e1d0f Compare July 4, 2026 14:07
@Ashwinhegde19

Ashwinhegde19 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Updated, thanks for catching that. This now includes the guardrail half too: streamed reasoning deltas are recorded on the collected turn as liveness, and observeTurn resets the empty-turn counter when a turn has reasoning even if it has no visible answer text or tool call. I kept reasoning out of collected.Text, so it still does not become final-answer content.

Added regression coverage for three reasoning-only turns followed by a final answer, plus a collector assertion that reasoning marks the turn as reasoning-bearing.

Validated locally:

  • go test ./internal/agent ./internal/zeroruntime ./internal/providers/openai -count=1
  • make lint
  • make test-quick

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P2] Surface reasoning deltas through headless stream-json output
    internal/cli/exec.go:506
    The linked repro for #439 uses zero exec --output-format stream-json, but this PR only converts Ollama's delta.reasoning into the runtime StreamEventReasoning. The headless exec path still wires only OnText into the writer, execEventWriter only emits text events, and the stream-json event enum has no reasoning event type, so stream-json clients will still see no live output while the model is thinking. Please add a stream-json reasoning/thinking event (and wire OnReasoning in the exec paths) or otherwise surface these deltas in headless output so the reported path stops looking idle.

  • [P2] Get the linked issue approved before merging this community PR
    CONTRIBUTING.md:19
    The contribution policy says community PRs must be tied to an existing issue that has already been reviewed and approved by the core team, with approval shown by the issue-approved label. This PR links Fixes #439, but #439 currently has no labels, so merging this would bypass the repository's issue-first gate for outside contributions. Please get #439 marked with issue-approved or have a maintainer explicitly confirm an exception before this PR is merged.

@Ashwinhegde19 Ashwinhegde19 force-pushed the fix-ollama-reasoning-alias branch from 27e1d0f to 26df701 Compare July 5, 2026 05:42
@Ashwinhegde19

Copy link
Copy Markdown
Contributor Author

Updated for the stream-json finding. The headless exec path now wires OnReasoning into execEventWriter, emits stream-json events with type reasoning and delta, and documents the event in docs/STREAM_JSON_PROTOCOL.md. The event is separate from text/final output, so reasoning stays liveness/progress only.

Added coverage in TestRunExecStreamJSONEmitsReasoningEvents to verify stream-json receives reasoning before text and that reasoning is not folded into the final answer.

Validated locally:

  • go test ./internal/cli ./internal/streamjson ./internal/agent ./internal/zeroruntime ./internal/providers/openai -count=1
  • make lint

I also started make test-quick, but it produced no output for several minutes after the early packages and I interrupted it rather than leaving a hung process. The earlier full quick suite had passed before this stream-json-only follow-up.

@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.

🧹 Nitpick comments (2)
internal/cli/exec_protocol_test.go (1)

553-566: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test doesn't assert reasoning arrives before text.

findJSONEvent just scans for the first event of a given type — it can't detect if reasoning and text were emitted out of order. Since the PR's whole premise is that reasoning deltas must be observed live (before/alongside text) to keep the turn "active," a test that only checks presence + delta value wouldn't catch an ordering regression (e.g., reasoning buffered and flushed only at the end). Consider asserting index positions in events directly.

✅ Suggested ordering assertion
 	events := decodeJSONLines(t, stdout.String())
-	reasoning := findJSONEvent(t, events, "reasoning")
+	reasoningIdx, reasoning := findJSONEventIndex(t, events, "reasoning")
 	if reasoning["delta"] != "Thinking. " {
 		t.Fatalf("unexpected reasoning event: %#v", reasoning)
 	}
-	text := findJSONEvent(t, events, "text")
+	textIdx, text := findJSONEventIndex(t, events, "text")
 	if text["delta"] != "done" {
 		t.Fatalf("unexpected text event: %#v", text)
 	}
+	if reasoningIdx >= textIdx {
+		t.Fatalf("expected reasoning event before text event, got indices %d, %d", reasoningIdx, textIdx)
+	}
 	final := findJSONEvent(t, events, "final")
🤖 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 `@internal/cli/exec_protocol_test.go` around lines 553 - 566, The exec protocol
test currently only verifies that reasoning and text events exist, but not that
reasoning is emitted before text. Update the test around
findJSONEvent/decodeJSONLines to assert the ordering directly from the events
slice, using the reasoning and text event positions to ensure reasoning is
observed first and not buffered until the end.
internal/cli/exec_writer.go (1)

83-91: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Plain-text format silently swallows reasoning deltas.

text() falls through to writer.writeStdout(delta) for the default (non-JSON, non-stream-json) format, but reasoning() has no such fallback — reasoning deltas simply vanish for plain exec output. Given the whole point of this PR is to keep long-thinking models visibly "alive," plain-format users get zero indication anything is happening until the final answer lands. Worth at least a lightweight passthrough (or an explicit comment noting this is deliberate) so the omission doesn't look like an oversight.

♻️ Optional: surface reasoning in plain mode
 func (writer *execEventWriter) reasoning(delta string) {
 	if writer.format == execOutputJSON {
 		writer.writeJSON(map[string]any{"type": "reasoning", "delta": delta})
 		return
 	}
 	if writer.format == execOutputStreamJSON {
 		writer.writeStreamJSON(streamjson.Event{Type: streamjson.EventReasoning, RunID: writer.runID, Delta: delta})
+		return
 	}
+	writer.writeStdout(delta)
 }
🤖 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 `@internal/cli/exec_writer.go` around lines 83 - 91, The
execEventWriter.reasoning path only handles execOutputJSON and
execOutputStreamJSON, so plain-text exec output drops reasoning deltas entirely.
Update reasoning() to mirror text() by adding a non-JSON fallback (for example,
forwarding delta to stdout through writeStdout) or add an explicit comment if
the omission is intentional, and keep the behavior aligned with
execEventWriter.text and execEventWriter.writeStdout.
🤖 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.

Nitpick comments:
In `@internal/cli/exec_protocol_test.go`:
- Around line 553-566: The exec protocol test currently only verifies that
reasoning and text events exist, but not that reasoning is emitted before text.
Update the test around findJSONEvent/decodeJSONLines to assert the ordering
directly from the events slice, using the reasoning and text event positions to
ensure reasoning is observed first and not buffered until the end.

In `@internal/cli/exec_writer.go`:
- Around line 83-91: The execEventWriter.reasoning path only handles
execOutputJSON and execOutputStreamJSON, so plain-text exec output drops
reasoning deltas entirely. Update reasoning() to mirror text() by adding a
non-JSON fallback (for example, forwarding delta to stdout through writeStdout)
or add an explicit comment if the omission is intentional, and keep the behavior
aligned with execEventWriter.text and execEventWriter.writeStdout.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0a506f43-b2f6-407a-8f05-9390688a7c6b

📥 Commits

Reviewing files that changed from the base of the PR and between 27e1d0f and 26df701.

📒 Files selected for processing (12)
  • docs/STREAM_JSON_PROTOCOL.md
  • internal/agent/guardrails.go
  • internal/agent/guardrails_test.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_writer.go
  • internal/providers/openai/provider.go
  • internal/providers/openai/provider_test.go
  • internal/providers/openai/types.go
  • internal/streamjson/streamjson.go
  • internal/zeroruntime/helpers.go
  • internal/zeroruntime/provider_test.go
🚧 Files skipped from review as they are similar to previous changes (7)
  • internal/agent/guardrails_test.go
  • internal/zeroruntime/provider_test.go
  • internal/providers/openai/provider.go
  • internal/agent/guardrails.go
  • internal/providers/openai/provider_test.go
  • internal/providers/openai/types.go
  • internal/zeroruntime/helpers.go

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Vasanthdev2004

Thanks for the update. I rechecked the changed paths and found one issue that still needs to be addressed before this is ready.

Findings

  • [P2] Complete the linked-issue approval before merging this community PR
    CONTRIBUTING.md:19
    The stream-json and guardrail fixes are now present, but the remaining contribution-policy blocker from the previous review is still unresolved: this PR links Fixes #439, and #439 still has no issue-approved label. The policy requires community PRs to be tied to an existing issue that has already been reviewed and approved by the core team, with approval shown by that label. Please get #439 marked with issue-approved, or have a maintainer explicitly confirm an exception, before this PR is merged.

@Ashwinhegde19

Copy link
Copy Markdown
Contributor Author

Updated in afd5d00 for CodeRabbit's ordering nitpick: TestRunExecStreamJSONEmitsReasoningEvents now asserts the reasoning event index is before the text event index, so the test catches buffering/order regressions instead of only checking presence.\n\nValidated locally:\n- go test ./internal/cli -count=1\n- git diff --check\n\nI intentionally left plain-text exec output silent for reasoning deltas. The existing behavior keeps reasoning separate from final/user-visible answer text; this PR's reported path is stream-json, and stream-json now receives live reasoning events.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving.

I flagged earlier that "Fixes #439" over-claimed — the parsing made thinking visible, but reasoning-only turns still counted as empty and tripped maxEmptyTurns, so the runs-dying-after-3-turns half was untouched. That's handled now: guardrails.go counts a reasoning-bearing turn as live, with a test that runs three thinking-only turns before a final answer and stays alive. So Fixes #439 holds up.

jatmn's stream-json point is covered too — there's a real reasoning event on the headless exec path now, emitted before text, kept out of the final answer, and documented as liveness-only.

I read the changed paths: reasoning_content precedence is preserved (the reasoning alias only wins when reasoning_content is empty), and the tests cover the alias, the precedence, the guardrail, and the exec stream-json ordering. Clean.

On the policy blocker: #439 is a real bug, so I've marked it issue-approved. The label didn't actually exist in the repo yet even though CONTRIBUTING.md references it — created it, so the gate is now satisfiable for the rest of the queue too.

Good to merge — over to kevin.

@kevincodex1 kevincodex1 merged commit f6c0606 into Gitlawb:main Jul 5, 2026
7 checks passed
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.

Ollama thinking models (qwen3): reasoning deltas are invisible, runs die with "3 turns with no output"

5 participants