Skip to content

One-off prompts (goal reminder, keep-alive, compaction prompt) take the cache breakpoint and break the cached prefix #62554

Open

Description

@swf-cmd

Description

Some prompts are only sent with one request: the goal reminder after a compaction, the reconciled Plan mode reminder, the session.generate prompts (keep-alive, goal review, /btw), and the compaction summary prompt with its template reminder. The conversation-tail cache breakpoint lands on them, so each of those requests writes a cache entry that no later request can read.

The goal and Plan reminders are also placed where the next request has other content: before the user's prompt, or after the latest tool result. The next request diverges from the cached prefix there and writes the rest of the conversation again. The goal reminder is added until a continuation restates the objective, so with an active goal after a compaction this happens on every step.

Separately, when the history no longer fits, compaction sends it as a text transcript with the usual breakpoints, which writes the whole transcript to the cache although no other request starts with it.

The fix is ready: dev...swf-cmd:opencode-upstream:request-local-cache. I'm at the repository's open-PR limit, so I'll open the PR when one of my open PRs closes.

Plugins

None

OpenCode version

dev (21bf6b4)

Steps to reproduce

  1. Use an Anthropic model, set a goal, and compact the session so the objective is no longer in the visible history.
  2. Send a prompt that makes several tool calls.
  3. Each step's request ends with the reminder, and the tail breakpoint is on it. The next step has a tool call and its result there instead, so it can't read that entry and writes everything after the last entry it can still read.

Screenshot

No response

Operating System

macOS

Terminal

Desktop app

Activity

  1. opencodejr commented on Nov 7, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced the goal reminder part on dev at 21bf6b4, which is your commit and also the latest dev.

    The script below builds Anthropic request bodies through the normal request path with the default cache policy. It adds the goal reminder the same way the context hook in packages/core/src/plugin/goal.ts does, across consecutive agent steps after a compaction:

    • Step 1: [summary, ok, REMINDER, prompt*]. The reminder sits before the prompt (* marks the tail breakpoint).
    • Step 2: [summary, ok, prompt, tool_use, tool_result, REMINDER*]. It no longer matches step 1 from the third message, and the tail breakpoint is on the reminder.
    • Step 3: shares only the first five messages with step 2, so step 2's tail cache entry is never read.

    The hook's own comment says this placement is meant to keep the cached prefix warm, so this is a bug, not intended behaviour. It's a code-level reproduction, not a run against Anthropic: there were no credentials here. I also didn't separately check the Plan reminder, the session.generate prompts or the compaction transcript, but they go through the same tail-breakpoint logic.

    Related: #61775, #54107, #56422.

    Reproduction script (repro-62554.test.ts)
    // Reproduction for anomalyco/opencode#62554: the goal plugin's request-only reminder takes the
    // conversation-tail cache breakpoint, and moves between steps, so no later request reads that entry.
    // Copy into packages/ai/test/ of a dev checkout, then from packages/ai: bun test test/repro-62554.test.ts
    import { expect, test } from "bun:test"
    import { Effect } from "effect"
    import { ai, Message, ToolCallPart, ToolResultPart } from "../src/effect.js"
    import { Auth } from "../src/route.js"
    import { compileRequest } from "../src/route/client.js"
    import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
    
    const model = AnthropicMessages.route
      .with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
      .model({ id: "claude-sonnet-4-5" })
    
    const REMINDER = "<system-reminder>\nA goal is active for this session. Keep working toward it.\n...</system-reminder>"
    
    // Same placement as the `context` hook in packages/core/src/plugin/goal.ts
    const withGoalReminder = (messages: Message[]) => {
      const out = messages.slice()
      const at = out.at(-1)?.role === "user" ? out.length - 1 : out.length
      out.splice(at, 0, Message.user(REMINDER))
      return out
    }
    
    const compile = (messages: Message[]) =>
      Effect.runPromise(compileRequest(ai.request({ model, system: "sys", messages })).pipe(Effect.map((p) => p.body as any)))
    
    const summary = (body: any) =>
      body.messages.map((m: any, i: number) => {
        const marked = m.content.some((c: any) => c.cache_control)
        const text = m.content.map((c: any) => c.type + ":" + JSON.stringify(c.text ?? c.content ?? c.name ?? "").slice(0, 30)).join(",")
        return `${i} ${m.role} ${marked ? "[BREAKPOINT] " : ""}${text}`
      })
    
    test("goal reminder takes the tail breakpoint and the next step diverges before it", async () => {
      const history = [Message.user("<compaction summary>"), Message.assistant("ok")]
      const prompt = Message.user("run the tests")
      const call = (id: string) => Message.assistant([ToolCallPart.make({ id, name: "bash", input: { cmd: "ls" } })])
      const result = (id: string) => Message.tool(ToolResultPart.make({ id, name: "bash", result: "out " + id }))
    
      const step1 = await compile(withGoalReminder([...history, prompt]))
      const step2 = await compile(withGoalReminder([...history, prompt, call("c1"), result("c1")]))
      const step3 = await compile(withGoalReminder([...history, prompt, call("c1"), result("c1"), call("c2"), result("c2")]))
      for (const [n, b] of [["step1", step1], ["step2", step2], ["step3", step3]] as const)
        console.log(n + "\n  " + summary(b).join("\n  "))
    
      // Where the tail breakpoint lands on each step
      const tail = (b: any) => b.messages.findLastIndex((m: any) => m.content.some((c: any) => c.cache_control))
      const isReminder = (b: any, i: number) => JSON.stringify(b.messages[i]).includes("A goal is active")
      console.log("tail breakpoint on reminder:", isReminder(step2, tail(step2)), isReminder(step3, tail(step3)))
    
      // Longest common message prefix between step N's cached prefix (up to its tail breakpoint) and step N+1
      const strip = (m: any) => JSON.stringify({ ...m, content: m.content.map(({ cache_control, ...c }: any) => c) })
      const common = (a: any, b: any) => {
        let i = 0
        while (i < a.messages.length && i < b.messages.length && strip(a.messages[i]) === strip(b.messages[i])) i++
        return i
      }
      console.log("step2 cached up to msg", tail(step2), "; step3 matches step2 only for msgs [0,", common(step2, step3), ")")
      expect(isReminder(step2, tail(step2))).toBe(true)
      expect(common(step2, step3)).toBeLessThanOrEqual(tail(step2)) // step2's tail entry can never be read by step3
    })

    Run it: bun repro-62554.test.ts

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions