Skip to content

[OpenCode V2] generate replay misses the prompt cache: the anchor is undefined after any tool step #666

Closed

Description

@null-axiom

Scope: OpenCode V2 only. It uses the V2 generate hook (used by /btw) and V2's id-less tool-result messages. Magic Context 0.47.0 (dist/context-wq4ffwyb.js). OpenCode V2.0.26; the message-building code involved is unchanged from upstream.

What was observed

A normal turn sent 186K input tokens. Two seconds later, with the same model and agent, a /btw request sent 306K tokens with 0 cache read. 306K is the size of the raw, unmanaged history. So /btw most likely did not receive the managed (trimmed, cached) request shape from the generate hook.

Most likely cause (verified in code; not captured live)

V2GenerateReplay.capture(draft, anchor) (~line 2641) is called from the context hook (~4478-4483):

const anchor = draft.messages.at(-1)?.id;   // line 4479, taken before the transform
...
generateReplay.capture(draft, anchor);      // capture() returns immediately when !anchor

In OpenCode V2, a runner request's messages are built by toLLMMessages (packages/core/src/session/runner/to-llm-message.ts). Run on [user, assistant(with a completed tool call)], it returns:

[["user","msg_u"],["assistant","msg_a"],["tool",null]]

Tool results are separate role: "tool" messages without an id (Message.tool(...), packages/ai/src/schema/messages.ts:283). Magic Context's own tool messages have no ids either (lines 907, 2542, 2591).

This has two consequences for every agent-loop step after the first, where the request ends with tool results:

  • anchor is undefined and capture() does nothing. The stored prefix stays whatever the turn's first step captured: anchored on the user message, and taken before any tool output existed.
  • The turn's last request, which is the one that wrote the provider cache, is never captured.

generate then calls apply(). It either:

  • splices the stale step-1 snapshot onto a raw tail, which can't match the cached prefix once Magic Context changed anything mid-turn (drops, tags, a fresh <session-history> render), or
  • bails out through one of its silent exits and sends the raw history.

The observed 306K / 0-read fits the second case best. Which exit fired can't be told, because none of them logs.

A turn with no tool step works, which is why this is easy to miss.

Suggested fixes

  1. Anchor on something that survives tool steps. For example, capture the id of the last message that has an id, plus the number of id-less messages after it. Or store all ids in the draft, and in apply() cut at the last captured id that the generate draft still contains. Capture on every context call, so the turn's final request is the one that gets replayed.
  2. Log why apply() returned false and why capture() skipped, at least at debug level. apply() has four silent exits: no prefix, model mismatch, agent mismatch, anchor not found.

(An alternative would be for OpenCode to give tool messages stable ids. That needs a host change, and I haven't checked whether Magic Context handles unknown ids safely.)

Repro

  1. Use a session where Magic Context manages history (a <session-history> head is present).
  2. Send a prompt that makes the model call a tool, and wait for the turn to finish.
  3. Run /btw something.
  4. Compare the generate request with the last runner request: input size and cache-read tokens.

Activity

  1. magic-alfonso commented on Oct 12, 2026

    @magic-alfonso

    Thanks for tracing this through the host's message building. We're reproducing it on OpenCode 2 now: a /btw after a turn with tool steps, comparing the replayed request against the last runner request. We'll follow up here with what we find and the fix, including a log line for each reason the replay is skipped.

  2. magic-alfonso commented on Oct 12, 2026

    @magic-alfonso

    Fixed on master; it ships in the next release. A reproduction on a real OpenCode 2.0.27 host showed a slightly different path from the one you guessed: none of the replay's early exits fired. After a tool step the capture was skipped, so /btw replayed the turn's first-step snapshot, which was missing the latest tool output. The replay now anchors on the last message that has an id plus the exact tool messages after it, and captures every managed request, so /btw reuses the last served request byte for byte. Each skip reason (generate replay capture skipped: …, generate replay apply skipped: …) is now logged once in magic-context.log.

  3. magic-alfonso commented on Oct 13, 2026

    @magic-alfonso

    Fixed in 0.49.0. On OpenCode 2, /btw now replays the last prepared request even after tool steps whose results have no message ID, so the side question reuses the cached prefix instead of missing the cache. Update the plugin and restart OpenCode.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions