Skip to content

fix(provider/compatible): preserve reasoning_content for plain-text assistant turns - #6284

Merged
Audacity88 merged 4 commits into
masterfrom
fix/6233-compatible-reasoning-content-fallback
May 30, 2026
Merged

Audacity88 merged 4 commits into
masterfrom
fix/6233-compatible-reasoning-content-fallback

Conversation

@theonlyhennygod

@theonlyhennygod theonlyhennygod commented May 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Base branch: master (all contributions)
  • What changed and why:
    • OpenAiCompatibleProvider::convert_messages_for_native only extracted reasoning_content from assistant messages that carried tool_calls. Plain-text assistant turns from thinking-mode providers such as DeepSeek V4 can JSON-encode {"content": ..., "reasoning_content": ...} with no tool_calls, then lose reasoning_content on replay.
    • Without that field on replay, DeepSeek V4 thinking rejects the next request with a 400 (reasoning_content in the thinking mode must be passed back), breaking multi-turn conversations.
    • Adds a narrow no-tool parsing branch that fires only when assistant content parses as JSON, has no tool_calls, has string reasoning_content, and has absent/null/string content. That preserves the thinking-mode replay payload without treating ordinary structured JSON replies as internal envelopes.
    • Adds focused regression tests for the fix path, unrelated JSON fallback, content-only JSON fallback, and non-string reasoning_content fallback.
  • Scope boundary: Only convert_messages_for_native in crates/zeroclaw-providers/src/compatible.rs. No changes to wire format, request building, streaming, tool-call handling, or other providers. The existing tool_calls and tool branches are untouched.
  • Blast radius: Any consumer of OpenAiCompatibleProvider that round-trips assistant history through the native compatible-provider conversion path. Tool-call assistant turns and ordinary structured JSON replies keep their previous behavior; only plain assistant JSON with a string reasoning_content is treated as a thinking-mode replay envelope.
  • Linked issue(s): Closes [Bug]: chat_messages_to_native() drops reasoning_content for plain-text assistant messages #6233
  • Labels: bug, size: S, risk: high, provider, provider:compatible, provider:deepseek

Validation Evidence (required)

Local validation is the signal CI cannot replace. Run the full battery and paste literal output (tails, failures, warnings — not "all passed").

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test
  • Commands run and tail output:
cargo fmt --all -- --check
Result: clean, no diff.

cargo test -p zeroclaw-providers convert_messages_for_native -- --nocapture
Result: test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 871 filtered out.
Covered regressions include:
- compatible::tests::convert_messages_for_native_round_trips_reasoning_content_without_tool_calls ... ok
- compatible::tests::convert_messages_for_native_content_only_json_falls_through ... ok
- compatible::tests::convert_messages_for_native_non_string_reasoning_content_falls_through ... ok
- compatible::tests::convert_messages_for_native_unrelated_json_falls_through ... ok

cargo clippy -p zeroclaw-providers --all-targets -- -D warnings
Result: clean, no warnings promoted to errors.
  • Beyond CI — what did you manually verify? Walked through the branch ordering against current master: the JSON-with-tool_calls branch still wins for tool turns, the new no-tool branch only triggers for string reasoning_content, content-only JSON stays verbatim, non-string reasoning_content stays verbatim, and unrelated JSON still hits the plain-text fallback. Did not run a live request against DeepSeek V4 thinking; the regression tests cover the request-history conversion path this PR owns.
  • If any command was intentionally skipped, why: Workspace-wide cargo test was scoped to the affected provider conversion tests because the change is entirely contained in zeroclaw-providers and the focused tests exercise the changed branch plus the guarded fallbacks. Workspace-wide cargo clippy --all-targets -- -D warnings was not rerun locally after this maintainer follow-up; the focused provider clippy above covers the touched crate, and GitHub CI covers the full required matrix.

Security & Privacy Impact (required)

Yes/No for each. Answer any Yes with a 1–2 sentence explanation.

  • New permissions, capabilities, or file system access scope? (No)
  • New external network calls? (No)
  • Secrets / tokens / credentials handling changed? (No)
  • PII, real identities, or personal data in diff, tests, fixtures, or docs? (No)
  • If any Yes, describe the risk and mitigation: N/A.

Compatibility (required)

  • Backward compatible? (Yes) — messages that previously matched the tool_calls branch or fell through unchanged still take the same path. The new behavior is limited to the previously broken plain-assistant replay shape that carries string reasoning_content.
  • Config / env / CLI surface changed? (No)
  • If No or Yes to either: exact upgrade steps for existing users: None — transparent fix on rebuild.

Rollback (required for risk: medium and risk: high)

  • Fast rollback command/path: After merge, revert the eventual squash commit. Before squash merge, revert the branch's PR commits with git revert c2d00908c 12169079a 99b77011d and rebuild. Reverting restores the prior behavior, including the DeepSeek/Kimi replay failure for plain assistant turns with reasoning_content.
  • Feature flags or config toggles: None. The behavior is limited to native compatible-provider history conversion for assistant JSON payloads that carry string reasoning_content.
  • Observable failure symptoms: DeepSeek V4 or compatible thinking-mode chats fail on a later turn with HTTP 400 and the upstream message reasoning_content in the thinking mode must be passed back. In logs, look for 400 responses from compatible-provider requests after a multi-turn assistant reply.

Maintainer note

Refs #6059.

#6059 is being addressed across three PRs covering the layers reasoning_content round-trips through:

This PR is the plain-text-assistant piece. It can land independently of #6285. The issue stays open until both open PRs ship and a current DeepSeek/Kimi thinking-mode smoke passes.

— @singlerider, 2026-05-28; updated by @Audacity88 after resolving the structured-output fallback review blocker and refreshing the branch against current master.

…ssistant turns

Closes #6233.

`convert_messages_for_native` had two parsing branches: assistant
messages with `tool_calls` (extracts reasoning_content from JSON),
and tool-role messages. Plain-text assistant messages whose content
is JSON-encoded with `reasoning_content` and no `tool_calls` (the
shape DeepSeek V4 thinking-mode produces for non-tool turns) fell
through to the generic fallback, which dropped reasoning_content
to None and caused a 400 on the next request.

Adds a third branch that fires when assistant content parses as
JSON without `tool_calls` and carries at least one of `content` or
`reasoning_content` keys. Unrelated JSON (no recognized keys) still
falls through to the verbatim text path so a stray `{"foo":"bar"}`
survives to the wire instead of collapsing to empty.

Mirrors the existing `convert_messages_for_native_round_trips_reasoning_content`
test for the no-tool-calls shape, plus an unrelated-JSON regression
test to lock the fall-through behavior.
@theonlyhennygod theonlyhennygod added bug Something isn't working provider Auto scope: src/providers/** changed. labels May 2, 2026
@theonlyhennygod theonlyhennygod self-assigned this May 2, 2026
@theonlyhennygod theonlyhennygod added provider:compatible Auto module: provider/compatible changed. provider:deepseek labels May 2, 2026
@singlerider singlerider added size: S runtime Auto scope: src/runtime/** changed. agent Auto scope: src/agent/** changed. and removed agent Auto scope: src/agent/** changed. runtime Auto scope: src/runtime/** changed. labels May 4, 2026

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

Checked PR #6284 at head 99b7701, the full single-file diff, #6233, #6059, #6269, the top-level/inline/formal review threads, CI status, and adjacent source in crates/zeroclaw-providers/src/compatible.rs and the runtime history builders. There are no prior comments or active review blocks, and CI is green. I did not run local cargo; this pass relies on CI, the author's stated validation, and source inspection.

🔴 Blocking — Plain JSON answers with a content key are no longer replayed verbatim

The new branch accepts any assistant message JSON that has either content or reasoning_content and no tool_calls. That fixes the DeepSeek replay envelope when reasoning_content is present, but it also changes ordinary assistant text that happens to be JSON with a content key.

ChatMessage::content is an opaque string, so a model can legitimately answer with structured JSON. For example, a structured-output assistant answer like {"content":"raw"} used to fall through to the plain-text path, so the next request replayed the original JSON string. With this PR, the new branch treats it as a ZeroClaw replay envelope and sends only raw. If the JSON has a non-string content, the branch can return a native assistant message with no content at all. Both cases change conversation history that is not part of the reasoning_content fix.

The narrow fix is to require an actual parsed reasoning_content field, or another explicit replay-envelope signal, before taking this no-tool branch. Please also add a regression test where assistant content is JSON with a content key but no reasoning_content, and assert that it still falls through verbatim. If content-only JSON is intended to become an internal replay envelope too, that is a broader history-format change and needs to be documented and tested separately from this DeepSeek preservation fix.

🟡 Warning — PR body references a stale gate name

The rollback section says this behavior is gated on an existing support_reasoning_content argument, but the current convert_messages_for_native argument is allow_user_image_parts. Please update that text so the risk and rollback notes match the code being reviewed.

🟢 What looks good — the intended #6233 slice is correctly isolated

The PR is otherwise scoped to the right layer for the original #6233 replay bug. The added positive-path test covers the DeepSeek no-tool-calls envelope, and the unrelated-JSON fallback test is the right kind of guardrail; it just needs the content-key structured-output case as well.

@Audacity88 Audacity88 removed the needs-author-action Author response needed before review or merge can continue; not a stale warning. label May 30, 2026
@Audacity88
Audacity88 self-requested a review May 30, 2026 00:09
@Audacity88

Copy link
Copy Markdown
Collaborator

I pushed a narrow maintainer follow-up for my earlier review blocker, then refreshed the branch against current master after CI exposed a test-helper rename.

The branch now only treats a no-tool assistant JSON message as a thinking-mode replay envelope when reasoning_content is actually a string and content is absent/null/string. That keeps the intended DeepSeek/Kimi replay fix, while preserving ordinary structured JSON replies such as {"content":"raw"} or {"content":"raw","reasoning_content":null} verbatim.

Local validation after the follow-up and current-master refresh:

cargo fmt --all -- --check
cargo test -p zeroclaw-providers convert_messages_for_native -- --nocapture
cargo clippy -p zeroclaw-providers --all-targets -- -D warnings

All passed locally. I also refreshed the PR body and removed the stale needs-author-action label. I think this resolves my previous blocker, but I am not submitting an approval review on a head that now includes my maintainer commit.

@WareWolf-MoonWall, could you take a look when you have a chance?

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

First review of head c2d0090. I read the PR body, the full diff (single-file, crates/zeroclaw-providers/src/compatible.rs), Audacity88's CHANGES_REQUESTED, Audacity88's follow-up comment, and all four regression tests. CI is 13/13 green.

Audacity88's CHANGES_REQUESTED is still formally active on GitHub. Audacity88 explicitly states in their latest comment: "I think this resolves my previous blocker, but I am not submitting an approval review on a head that now includes my maintainer commit." Because their formal review has not been dismissed, I cannot post an approval here per protocol — but I can confirm what the current code does and clear the path to merge.

✅ Resolved — Audacity88's 🔴 block: plain JSON with content key falls through verbatim

The narrowed guard in the current diff requires reasoning_content to be present and to be a string (.and_then(serde_json::Value::as_str)). An assistant message like {"content": "raw"} has no reasoning_content key; as_str() returns None; the let Some(reasoning_content) = ... binding fails; the branch does not fire; the message falls through to the plain-text path and the original JSON string is preserved verbatim on replay. ✓

The four regression tests pin this correctly:

  • convert_messages_for_native_round_trips_reasoning_content_without_tool_calls — DeepSeek-style envelope with both fields fires the branch ✓
  • convert_messages_for_native_content_only_json_falls_through — {"content": "raw"} (no reasoning_content) falls through verbatim ✓
  • convert_messages_for_native_non_string_reasoning_content_falls_through — reasoning_content: null falls through verbatim ✓
  • convert_messages_for_native_unrelated_json_falls_through — {"foo": "bar"} falls through verbatim ✓

✅ Resolved — Audacity88's 🟡 warning: stale gate name removed from PR body

The rollback section no longer references a support_reasoning_content argument. It describes the behavior correctly: "limited to native compatible-provider history conversion for assistant JSON payloads that carry string reasoning_content." ✓

🟢 What looks good — branch ordering is correct

The new no-tool branch sits after the existing tool_calls branch (which handles JSON with tool_calls) and before the tool role branch and plain-text fallback. Tool-call assistant turns still win at the earlier branch and are unaffected. The ordering is correct and the code comment at the new branch explains the DeepSeek V4 replay requirement clearly.

Path to merge

@Audacity88 — your formal CHANGES_REQUESTED at the prior head is the only remaining gate. If you're satisfied that the current code resolves your block, dismissing your review or posting a new approval at this head would clear the path. Alternatively, another maintainer with review-dismiss permission can dismiss the stale block. Everything I've read on this head is ready.

@WareWolf-MoonWall WareWolf-MoonWall added this to the v0.8.1 milestone May 30, 2026
@Audacity88
Audacity88 dismissed their stale review May 30, 2026 02:27

Resolved by follow-up commits on current head c2d0090; current head reviewed by WareWolf and CI is green.

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

Approved. I rechecked the current head after the follow-up commits and the stale blocking review was dismissed. The Compatible plain-text replay path now preserves reasoning_content for assistant turns while keeping the existing content fallback behavior, and the regression coverage is aligned with the provider helper behavior.

CI is green on the current head, and the previous blocker is resolved.

@Audacity88
Audacity88 merged commit e3a3228 into master May 30, 2026
14 checks passed
github-actions Bot pushed a commit that referenced this pull request May 30, 2026
…n-text assistant turns (#6284)

- 99b7701 fix(provider/compatible): preserve reasoning_content for plain-text assistant turns
- 1216907 fix(provider/compatible): preserve structured JSON replies
- 3a210d1 Merge remote-tracking branch 'origin/master' into fix/6233-compatible-reasoning-content-fallback
- c2d0090 test(provider/compatible): align reasoning replay tests with provider helper e3a3228
github-actions Bot pushed a commit to FTDGRT/zeroclaw that referenced this pull request May 30, 2026
…n-text assistant turns (zeroclaw-labs#6284)

- 99b7701 fix(provider/compatible): preserve reasoning_content for plain-text assistant turns
- 1216907 fix(provider/compatible): preserve structured JSON replies
- 3a210d1 Merge remote-tracking branch 'origin/master' into fix/6233-compatible-reasoning-content-fallback
- c2d0090 test(provider/compatible): align reasoning replay tests with provider helper e3a3228
@singlerider
singlerider deleted the fix/6233-compatible-reasoning-content-fallback branch June 13, 2026 04:14
belumume pushed a commit to belumume/zeroclaw that referenced this pull request Oct 9, 2026
…ssistant turns (zeroclaw-labs#6284)

- 99b7701 fix(provider/compatible): preserve reasoning_content for plain-text assistant turns
- 1216907 fix(provider/compatible): preserve structured JSON replies
- 3a210d1 Merge remote-tracking branch 'origin/master' into fix/6233-compatible-reasoning-content-fallback
- c2d0090 test(provider/compatible): align reasoning replay tests with provider helper
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working provider:compatible Auto module: provider/compatible changed. provider Auto scope: src/providers/** changed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: chat_messages_to_native() drops reasoning_content for plain-text assistant messages

5 participants