Skip to content

fix(opencode): drop truncated reasoning from replayed history - #40148

Open
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:truncation-replay
Open

fix(opencode): drop truncated reasoning from replayed history#40148
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:truncation-replay

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 2, 2026

Copy link
Copy Markdown

Issue for this PR

Fixes #40147.

Related: #40146 / #40142 cover the session loop treating truncated turns as normal completions — same incident, one layer up, independent branch. #37946 covers empty assistant messages reaching the provider, which is directly relevant here (see below).

Type of change

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

What does this PR do?

A stored reasoning part on a same-model assistant message is replayed verbatim as a native reasoning part. Correct for Anthropic — signed thinking blocks must round-trip, and the separator workaround directly above exists for that.

Wrong for a turn truncated at the output limit: its reasoning is an incomplete chain, and on a lane with no signature there is nothing to preserve by sending it back.

A subagent turn spent its whole output budget in the reasoning channel — step-start, reasoning (129,961 chars), step-finish, no text, no tool call, finish: "length". Resuming replayed that chain and the model continued it, truncating again at the same limit: 132,948 characters, 4m19s, no text, no tools. A fresh dispatch of the same task on the same model completed normally in 274s, which isolates it to the replayed history.

The change skips reasoning parts on a turn whose own finish is "length", unless the part carries an Anthropic signature:

if (msg.info.finish === "length" && part.metadata?.anthropic?.signature == null) {
  droppedUnsignedReasoning = true
  continue
}

The signature check reuses the existing discriminator from the workaround above rather than a provider allow-list, so a signature-less Anthropic-compatible proxy is handled correctly too. Scope is deliberately limited to turns that themselves truncated — a complete reasoning turn is not implicated by the evidence, and the differentModel branch (which already downgrades reasoning to text) is untouched.

The second half of the change is the part worth reviewing. Dropping reasoning parts can leave a message whose only remaining content is an empty text part, which reaches the wire as:

{"role":"assistant","content":[{"type":"text","text":""}]}

That is the failure mode in #37946. The existing guards do not catch it: parts.length > 0 counts the empty text part, and the final filter only excludes messages whose parts are all step-start. So the fix carries a substantive-content check:

const hasSubstantivePart = assistantMessage.parts.some(
  (part) => part.type !== "step-start" && (part.type !== "text" || part.text.trim().length > 0),
)
if (assistantMessage.parts.length > 0 && (!droppedUnsignedReasoning || hasSubstantivePart)) {

Two deliberate choices there. It reads converted parts rather than stored ones — a first attempt read msg.parts, which counts step-finish (written on every completed turn, never converted) as substantive and let the empty message through anyway. Reading what actually reaches the provider makes stored-part taxonomy irrelevant.

And it is gated on droppedUnsignedReasoning, so it can only affect messages this change modified. A message where nothing was dropped short-circuits and replays byte-identically to today — including the pre-existing empty-assistant-message cases from #37946, which this PR deliberately does not touch. Fixing that class generally is a separate change.

How did you verify your code works?

Four tests in packages/opencode/test/session/message-v2.test.ts, written first and confirmed failing before the production change:

  • unsigned reasoning on a length turn is dropped
  • signed reasoning on a length turn is preserved
  • reasoning on a stop turn is preserved
  • a length turn left with only empty text produces no assistant message at all

Mutation-checked in both directions. Reverting the reasoning-drop block turns two tests red; restoring returns them green.

A cross-family reviewer took three rounds and found two real defects that are worth stating rather than hiding, since both are the kind that pass a naive test:

  1. The first version emitted the empty assistant message described above. Caught by running an actual conversion probe rather than reading the code.
  2. The first fix for that read stored parts, so step-finish made the guard pass and the empty message still got through — and the regression test only passed because its fixture omitted step-finish, which is not a shape the processor actually produces. The fixture now includes it.

The reviewer also checked reachability empirically rather than assuming: querying a real session database found 102 messages with finish: "length" and reasoning, of which 10 match the case this fix covers (no error, unsigned reasoning, non-empty text). It also verified the signature shape against the write path (packages/llm/src/protocols/anthropic-messages.tspackages/opencode/src/session/processor.ts) rather than only the neighbouring read.

An over-broad mutation — dropping the finish === "length" condition — turns three tests red, including two pre-existing ones covering aborted assistant messages and OpenRouter reasoning details, each confirmed to fail individually rather than through ordering.

bun test in packages/opencode: 3232 pass / 0 fail (baseline on dev is 3228, measured on a clean checkout). bun typecheck clean in packages/opencode and packages/core.

Screenshots / recordings

Not a UI change.

Checklist

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

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.

Incomplete reasoning from a truncated turn is replayed to the provider

1 participant