Skip to content

fix(opencode2): map cloned tool parts back to tool-call/tool-result - #476

Open
qoole wants to merge 1 commit into
cortexkit:masterfrom
qoole:fix/opencode2-dropped-tool-parts
Open

qoole wants to merge 1 commit into
cortexkit:masterfrom
qoole:fix/opencode2-dropped-tool-parts

Conversation

@qoole

@qoole qoole commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Problem

On OpenCode 2, once a session grows large enough for Magic Context to drop or edit-mark tool calls, every later turn in that session fails before it reaches the provider. The host logs:

Failed to drain Session  Error: Schema validation failed
    at SessionModelRequest.prepare

The client shows "working" for a few seconds and then nothing. (The schema dump in that log mentions "Compaction requires either encrypted content or a summary", but that's just one member's annotation printed with the whole union. The actual issue is an AnyOf at content[0] with no sub-issues: a part whose type matches nothing.)

Cause

adaptPayload() converts each tool-call/tool-result pair into a native { type: "tool" } part for the TS pipeline. commit() converts them back by looking each part up in bridges, a Map keyed by object identity.

The drop and edit-marker paths (clampCloneInPlace) deliberately rewrite a structuredClone of the part and swap the clone into parts, to keep the host's live objects untouched. So bridges.get(part) misses the clone, and commit() passes the v1-shaped part through unchanged:

{"type":"tool","callID":"call_…","tool":"read","state":{"input":{"dropped":"[dropped §91§]"},"status":"completed","output":"[dropped §91§]"}}

OpenCode 2's request schema rejects that. On the session where I hit this, 823 of these parts were in the request.

Trailing-blank normalization in strip-content.ts has the same weakness at the message level. It replaces a message with a copy ({ ...message, parts: [...] }), so originals.get(message) misses too, and the host message (its id, and where its tool results go) is lost.

Fix

commit() now also looks up:

  • a part's bridge by (message id, callID). The copied message shares the original info, so the id survives. The key is per message, because two turns can reuse a callID. The host's id-less tool-result carriers keep an object key.
  • a message's host original by message id.

Behaviour is unchanged whenever the identity lookups succeed.

Tests

src/v2/hooks/payload.test.ts (new) runs the real createToolDropTarget().truncate() and .editMarker() on messages produced by adaptPayload, then checks that commit() emits only V2 content types:

  • truncate and edit-marker each map back to a tool-call plus a tool-result carrying the sentinel;
  • a callID reused by two turns keeps each turn's own call;
  • a message replaced by a copy before commit() keeps its id and its tool-result carrier;
  • a no-op pass round-trips the host messages exactly.

Each case fails without its part of the fix. I also checked it by mutation: taking out the id fallback for originals, or keying bridges by object only, fails the relevant tests.

  • bun test src/v2/: 42 pass.
  • Lint and typecheck pass.
  • Full bun test --parallel: 4950 pass, 1 fail. The failure is createCtxSearchTools > explains why commit search is unavailable for a non-repository project, which fails the same way on master in my environment.

Verified on a live host

I tested on OpenCode 2.0.7 with a copy of the affected store: a session of about 1.66M tokens with 823 dropped tool parts. Every turn failed with the error above. With this change (plus #477, needed for this store), the same session completes a turn at about 481k input tokens, and a context-hook probe finds no non-V2 parts.

Related: #477 (stores migrated from 1.x are refused as v1) and #475 (migration 85 relabels real OpenCode 2 rows).


Summary by cubic

Fixes OpenCode 2 sessions failing once Magic Context drops or edit-marks tool calls, so later turns reach the provider instead of dying with schema validation errors. Behavior is unchanged when identity lookups succeed.

  • adaptPayload() now maps cloned tool parts back to V2 tool-call/tool-result pairs by message ID and callID.
  • Messages replaced with copies keep their original host message and tool-result carrier.
  • Reused callIDs across turns stay scoped to their own message.
  • Adds regression tests for truncate, edit-marker, copied-message, and reused-callID cases.

Written for commit 9b2abc7. Summary will update on new commits.

Review in cubic

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with the new fallbacks preserving V2 payload shapes across the documented cloning and message-copying paths.

Summary

This PR makes the OpenCode 2 payload adapter preserve host serialization after the transformation pipeline clones tool parts or copies their owning messages.

  • Adds message-scoped tool bridge lookup by message ID and call ID.
  • Adds host-message recovery by message ID when object identity is lost.
  • Retains object-keyed lookup for id-less tool-result carriers.
  • Adds regression coverage for truncation, edit markers, reused call IDs, copied messages, and no-op round trips.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[V2 tool-call and tool-result] --> B[adaptPayload]
    B --> C[Native pipeline tool part]
    C --> D{Pipeline preserves identity?}
    D -->|Yes| E[Identity bridge lookup]
    D -->|Part or message cloned| F[Message ID and callID lookup]
    E --> G[commit]
    F --> G
    G --> H[V2 tool-call and tool-result]
Loading

Reviews (1) · Last reviewed commit: "fix(opencode2): map cloned tool parts ba..."

On OpenCode 2, adaptPayload() projects each tool-call/tool-result pair into a
native `{ type: "tool" }` part for the TS pipeline, and commit() maps them back
by looking each part up in `bridges`, a Map keyed by object identity. The drop
and edit-marker paths (clampCloneInPlace) deliberately rewrite a structuredClone
of the part and swap the clone into `parts`, so the identity lookup misses and
commit() emits the v1-shaped tool part unchanged. The OpenCode 2 request schema
rejects it ("Schema validation failed" in SessionModelRequest.prepare), and the
turn ends with no output. It starts once a session is large enough for tool
calls to be dropped, and every later turn in that session fails.

commit() now also resolves a part's bridge by (message id, callID), and a
message's host original by message id. The second covers trailing-blank
normalization, which replaces a message with a copy: that also lost the host
message (its id, and where its tool results belong). Keys are per message, so
two turns that reuse a callID keep their own call; the host's id-less
tool-result carriers keep an object key.

Tests go through the real createToolDropTarget truncate/editMarker paths on
adapted messages, including a reused callID and a message replaced by a copy
before commit(). Each fails without its part of the fix.
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.

1 participant