Skip to content

fix(provider): preserve assistant message content when reasoning blocks present#21370

Open
edevil wants to merge 2 commits intoanomalyco:devfrom
edevil:fix/preserve-thinking-block-signatures
Open

fix(provider): preserve assistant message content when reasoning blocks present#21370
edevil wants to merge 2 commits intoanomalyco:devfrom
edevil:fix/preserve-thinking-block-signatures

Conversation

@edevil
Copy link
Copy Markdown
Contributor

@edevil edevil commented Apr 7, 2026

Issue for this PR

Closes #16748

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Anthropic adaptive thinking (Opus 4.6+, Sonnet 4.6) emits empty text parts between thinking blocks:

[reasoning(sig1), text(""), reasoning(sig2), text("answer"), tool_use]

The empty text part originates from Anthropic itself — adaptive thinking with "omitted" mode (default on Opus 4.7+) emits content_block_start{type:"text"} immediately followed by content_block_stop with no deltas, as a structural separator between thinking blocks.

On replay, three separate gates reject an empty-string text part:

  1. opencode normalizeMessages (transform.ts) — drops text === ""
  2. AI SDK convertToLanguageModelPrompt — drops text === "" unless providerOptions != null
  3. Anthropic server — rejects with "text content blocks must be non-empty"

And dropping the part entirely shifts reasoning-block positions, invalidating signatures with:

thinking or redacted_thinking blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response.

Once either rejection fires, the session is permanently broken — the corrupted history replays from storage on every retry.

The fix

Substitute a single space for empty text parts in assistant messages that contain signed Anthropic reasoning blocks. The space preserves the structural arrangement (the reasoning block positions) without triggering any of the three rejections. Signatures are on the reasoning block content, not on the text between them, so a non-empty placeholder keeps the arrangement valid.

const hasSignedReasoning = msg.parts.some(
  (p) => p.type === "reasoning" && (p.metadata as any)?.anthropic?.signature != null,
)
const text = part.text === "" && hasSignedReasoning ? " " : part.text

The gate self-identifies as Anthropic via signature presence (also covers Bedrock/Vertex Anthropic). Other providers' reasoning (OpenAI Responses, Gemini thinking, Copilot reasoningOpaque) don't position-validate, so their empty text continues to be filtered normally.

This is a single 3-line change in message-v2.ts where UIMessage parts are built from database parts. By the time the message reaches normalizeMessages() or the AI SDK's internal filters, the text is already " " (non-empty), so it passes all downstream checks naturally.

Historical context

  • The normalizeMessages empty-text filter was added on Jan 5 (c285304a) before adaptive thinking existed (Feb 13, 0d90a22f9), so it was correct at the time
  • Commit 33819932e (Apr 10) removed trimEnd() calls in processor.ts as a partial mitigation, but only helps when text is whitespace — the model can still emit truly empty text parts that trigger the bug
  • Reproduced on v1.14.19 with claude-opus-4-7; no commits between v1.4.6 and current origin/dev fix it

How did you verify your code works?

  • 3 new message-v2 tests: space substitution with signed reasoning; unsigned reasoning leaves empty text alone; no-reasoning messages leave empty text alone
  • All existing tests pass (135 transform tests, 27 message-v2 tests)
  • Typecheck passes across all 13 packages
  • Binary built from this branch running in production with no thinking-block errors despite the trigger pattern appearing in sessions

Screenshots / recordings

N/A — backend logic change, no UI impact.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 7, 2026

The following comment was made by an LLM, it may be inaccurate:

Potential Duplicate Found:

This PR appears to be directly related or a potential duplicate. It addresses the same issue (#16748) with the same root cause (empty-text filtering in normalizeMessages affecting assistant messages with reasoning blocks). Both PRs target the same problem with Anthropic's adaptive thinking signatures.

@edevil
Copy link
Copy Markdown
Contributor Author

edevil commented Apr 7, 2026

It seems this error message predates adaptive thinking so it must be something else.

@edevil edevil closed this Apr 7, 2026
@edevil edevil reopened this Apr 7, 2026
@edevil
Copy link
Copy Markdown
Contributor Author

edevil commented Apr 8, 2026

I managed to confirm with a past session that this does indeed fix the issue in question.

@github-actions github-actions Bot mentioned this pull request Apr 8, 2026
6 tasks
fairyhunter13 added a commit to fairyhunter13/opencode that referenced this pull request Apr 10, 2026
Port anomalyco/sst upstream dev's proven sync architecture to permanently
fix the flaky "blank subagent view" bug where ctrl+x down into a running
subagent showed an empty content area until completion.

sync.tsx:
- Remove `force` parameter from session.sync()
- Always mark fullSyncedSessions after fetch (drop time.completed gate)
- Remove redundant session.created handler (session.updated upserts)
- Match upstream/dev byte-for-byte in sync-relevant sections

routes/session/index.tsx:
- Task component: createEffect -> onMount with empty-messages guard
- Route-level sync: drop force=true argument

Preserved defensively (ahead of upstream):
- worker.ts + server/routes/event.ts still use GlobalBus.on("event", ...)
  as defense-in-depth on top of InstanceState shared PubSub
- worker.ts WASM stdout/stderr capture to suppress Aborted() TUI corruption
- worker.ts per-directory filter in GlobalBus.on handler

provider.ts:
- Raise FLOOR_CEILINGS.critical from 4 to 10 for non-Anthropic providers
- Expand isAnthropicParent to also match parentModelID containing "claude"
  (covers claude-via-proxy cases)

transform.ts:
- Preserve assistant messages with reasoning blocks verbatim to avoid
  Anthropic 400 "thinking blocks cannot be modified" (ref upstream PR anomalyco#21370)
- Enable 4-BP cache strategy for GitHub Copilot Claude models

llm.ts:
- Use 5m TTL for short-lived subagent BP2 cache breakpoint; 1h for long-lived

processor.ts:
- Remove duplicate SessionSummary.summarize() call that triggered full-history
  DB hydration on every LLM step in multi-step tool loops (ref upstream anomalyco#21762)

Tests:
- task-escalation.test.ts: update FLOOR_CEILINGS critical assertions to
  match new ceiling (critical > opus, toBe(10)); expand coverage
- cache-max-efficiency-e2e.test.ts: new E2E test validating Anthropic prompt
  cache hit rate across multi-turn conversations

Skill:
- Add opencode-prompt-cache-maximization skill (CLAUDE.md)

Submodule:
- Bump cmd/opencode-search-engine to include idle CPU perf improvements

Root cause: our prior fullSyncedSessions + time.completed guard raced
with Task.createEffect's fire-and-forget sync(id, force=false) -- first
fetch at T=0 returned 0 messages, session was never marked synced,
and no re-sync trigger existed, leaving the view blank until the
subagent finished.
@chan1103
Copy link
Copy Markdown

I hit another wrinkle while tracing the same bug locally.

Even after I stopped normalizeMessages from dropping the empty text, I still had empty assistant text getting removed later in the AI SDK request path when providerOptions wasn't set. In the broken sessions I was looking at, that was enough to shift the later reasoning blocks and Anthropic rejected the history.

I opened #21860 with a repro for that path too, in case it's useful.

@LuisDuarte1
Copy link
Copy Markdown

LuisDuarte1 commented Apr 22, 2026

Verified the fix works and is at the correct layer. Sharing research findings and a small scoping refinement for your consideration.

Filter chain investigation: The empty text parts originate from Anthropic itself — adaptive thinking with "omitted" mode (default on Opus 4.7+, see anthropic-messages-options.ts:86-89) emits content_block_start{type:"text"} immediately followed by content_block_stop as a structural separator. The SDK faithfully passes this through as text-starttext-end with no deltas. On replay, three separate gates reject an empty-string text part:

  1. opencode normalizeMessages (transform.ts:63-68) — drops text === ""
  2. AI SDK convertToLanguageModelPrompt — drops text === "" unless providerOptions != null
  3. Anthropic server — rejects with text content blocks must be non-empty

And dropping the part entirely shifts reasoning-block positions → thinking blocks in the latest assistant message cannot be modified.

Scoping refinement suggestion: the current condition hasReasoning triggers for any provider with reasoning parts. The only reason position-preservation matters is Anthropic's cryptographic signatures — other providers' reasoning (OpenAI Responses, Gemini thinking, Copilot reasoningOpaque) don't position-validate. Suggest narrowing to:

const hasSignedReasoning = msg.parts.some(
  (p) => p.type === "reasoning" && p.metadata?.anthropic?.signature != null,
)
const text = part.text === "" && hasSignedReasoning ? " " : part.text

This self-identifies as Anthropic via signature presence (also covers Bedrock/Vertex Anthropic), avoids threading provider info into toModelMessagesEffect, and lets the normal empty-text filter handle the remainder.

Tests that would pair nicely:

  • Negative test: reasoning present without metadata.anthropic.signature → empty text stays empty and gets filtered as usual.
  • End-to-end: drive the processor with the actual Anthropic stream shape (reasoning-start/delta/signature_delta/endtext-start/text-end with no deltas → reasoningtext-delta) and assert stored text: "" and converted text: " ".

Out of scope for this PR: the redundant empty-text branch in normalizeMessages could be cleaned up in a follow-up since AI SDK Filter 2 covers assistant text anyway, but that's orthogonal.

@edevil edevil force-pushed the fix/preserve-thinking-block-signatures branch from d2e78ad to 517afe4 Compare April 22, 2026 18:03
omer-koren added a commit to omer-koren/opencode that referenced this pull request Apr 30, 2026
PR anomalyco#21370 only checks metadata.anthropic.signature, but Anthropic Claude hosted on AWS Bedrock (amazon-bedrock/anthropic.*) stores signatures under metadata.bedrock.signature, and on GCP Vertex AI under metadata.vertex.signature. Without this extension, the hasSignedReasoning gate never fires for Bedrock/Vertex users, and the empty-text-between-thinking-blocks bug still triggers 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified' on compaction.
@omer-koren
Copy link
Copy Markdown

Ran into a gap while verifying this fix against AWS Bedrock Claude Opus 4.7 — the hasSignedReasoning gate only matches metadata.anthropic.signature, but Bedrock and Vertex store the signature under their own provider namespaces:

  • metadata.bedrock.signature — AWS Bedrock
  • metadata.vertex.signature — GCP Vertex AI

Result: on Bedrock/Vertex the gate never fires, the empty-text substitution is skipped, and the cannot be modified error still triggers on compact.

Confirmed by inspecting a real session's reasoning part metadata in SQLite:

{ "type": "reasoning", "text": "...", "metadata": { "bedrock": { "signature": "Es4EClk..." } } }

Opened a small follow-up against @edevil's branch with a 3-namespace check + Bedrock/Vertex test coverage: edevil#1

The rest of the PR works great — verified on a production Bedrock session that was reliably reproducing the issue on manual compact; no longer triggers after applying both fixes.

edevil and others added 2 commits May 1, 2026 13:03
…ng blocks

Anthropic adaptive thinking (Opus 4.6+) emits empty text parts between
thinking blocks. Dropping them invalidates thinking block signatures
('thinking blocks cannot be modified'), but sending them as '' fails
validation ('text content blocks must be non-empty'). Substitute a
single space so the part survives all filters and Anthropic accepts it.

Gated on reasoning having an Anthropic signature (metadata.anthropic.
signature != null) so other providers' reasoning — which don't
position-validate — continue to have empty text filtered normally.

Closes anomalyco#16748
The hasSignedReasoning gate introduced in this PR only matches Anthropic's

direct-API signature path (metadata.anthropic.signature). When Claude is

hosted on AWS Bedrock or GCP Vertex AI, the reasoning part metadata stores

the signature under metadata.bedrock.signature / metadata.vertex.signature

respectively. The gate never fires for those providers, so empty text parts

between signed reasoning blocks are not substituted with a space, and

Anthropic/Bedrock/Vertex still reject the compacted message with:

  messages.N.content.M: 'thinking' or 'redacted_thinking' blocks in the

  latest assistant message cannot be modified

Extend the check to match signatures under any of the three provider

namespaces. Adds two tests covering the Bedrock and Vertex paths.
@edevil edevil force-pushed the fix/preserve-thinking-block-signatures branch from 517afe4 to 876c79f Compare May 1, 2026 12:06
@edevil
Copy link
Copy Markdown
Contributor Author

edevil commented May 1, 2026

Thanks @omer-koren for catching the Bedrock/Vertex gap and the clean follow-up.

Rebased on current dev and cherry-picked the fix from edevil#1 (authorship preserved). The hasSignedReasoning gate now matches anthropic, bedrock, and vertex signature namespaces, with paired tests for the Bedrock and Vertex paths. Tests + typecheck green across all packages.

edevil#1 can be closed.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

normalizeMessages() removes empty text parts between reasoning blocks, invalidating Anthropic thinking block signatures

4 participants