anomalyco / anomalyco/opencode

Context hook: mutations of existing message parts are persisted back into session storage (by part id)

Open
#43,731 0 comments 0 reactions 1 assignee View on GitHub

@kitlangton is already working on this.

Since Aug 20, 2026.

2.0
Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Bug report: session.hook("context") mutations of existing message parts are persisted back into session storage

Discovered while building the billion-context-opencode-v2 plugin (acp_ocv2), a context-compression
adapter that rewrites dispatch text via the v2 plugin context hook.

Description

The v2 plugin API exposes session.hook("context", cb) whose event carries { system, messages, tools }
and documents that the hook may mutate event.messages in place before model dispatch (opencode's own
plugins do exactly this — e.g. the plan-mode reminder splices a synthetic user message into
t.messages). The natural reading is: the mutated array is the dispatch payload.

However, opencode2 additionally persists mutations of pre-existing parts back into the session store,
matched by their original part id
. Concretely:

Hook mutation Persisted?
Rewrite text of an existing part (keeps its part id) ✅ yes — overwrites stored part text
Append a brand-new message (new/no message id, id-less parts) ❌ no — never written to storage

So any hook that rewrites existing text for dispatch-only purposes (ref tagging, redaction,
compression summaries-in-place, cache hints, token annotations…) permanently pollutes session history —
and because the TUI renders stored parts, the rewrite becomes permanently visible to the user on
every affected message.

Evidence (production data)

After running our adapter (which prefixes each user/assistant text with a <acp tokens="…" type="…">mNNNNN</acp>
ref tag for the model), inspection of ~/.local/share/opencode/opencode.db showed:

  • 1,134 stored message parts whose JSON begins "text":"<acp tokens=\"…\" …>mNNNNN</acp>\n…",
    i.e. the dispatch-only tag was written into the persisted part content. Multi-part assistant
    messages show stacked tags (<acp …>m01039</acp>\n<acp …>m01038</acp>\n<actual text>).
  • Zero rows for the synthetic user messages the same plugin appends via the same hook
    (nudge messages with ids like bili_nudge_<tier>_<ts>, compression-summary messages) — appended
    unknown messages/parts are not persisted.

That asymmetry (updates-by-id persist, inserts don't) is what makes the behavior look accidental rather
than a documented "hook output is the new truth" contract — under a full-persist contract the injected
messages would appear in storage too.

Impact

  • Session history and TUI are polluted with model-facing-only markup; users see raw tags on every message.
  • Stored history grows (tag overhead per part) and re-enters every future dispatch as input.
  • Any plugin using the hook for transient transformation (redaction of secrets before dispatch, PII
    stripping, context-window trimming) silently writes transformed content into durable storage — a
    potential data-integrity/privacy surprise.

Steps to reproduce

Minimal plugin (TypeScript, v2 plugin API):

export default {
  id: "repro-context-persist",
  setup: async (ctx) => {
    await ctx.session.hook("context", async (event) => {
      for (const msg of event.messages) {
        for (const part of msg.content ?? []) {
          if (part.type === "text" && typeof part.text === "string")
            part.text = "[dispatch-only]\n" + part.text
        }
      }
    })
  },
}
  1. Install the plugin, start opencode2, send one message and get one reply.
  2. Inspect the stored part content (SQLite: message/part tables in opencode.db, or the TUI).
  3. Observed: stored texts now begin with [dispatch-only]. The prefix appears in the TUI forever and
    is re-sent as user/assistant content in all subsequent turns.
  4. Contrast: appending a whole new role:"user" message inside the same hook is NOT persisted.

Expected behavior

One of:

  1. Dispatch-only contract: post-hook event.messages is used only for the provider call; storage is
    never updated from it (appends included — appends that should be visible should go through a message
    API / dedicated hook). This matches the "transform before dispatch" mental model.
  2. Explicit transiency: keep current persist-by-id behavior but let hooks mark mutations as
    non-persistent (e.g. part.transient = true, or return a separate { dispatchMessages } from the
    hook), so adapters can tag/redact for the model without touching history.
  3. Documented contract: if "hook output is persisted by part id, inserts are ignored" is intended,
    document it prominently — currently it silently corrupts histories of well-behaved transformer
    plugins.

Option 2 is the least disruptive: opencode's own plugins rely on splicing visible reminders into
event.messages, and third-party context adapters need transient rewrites.

Environment

  • opencode2 v0.0.0-beta-17728 (@opencode-ai/cli, Windows exe)
  • Windows 11, PowerShell host
  • Plugin: billion-context-opencode-v2 (acp_ocv2) built on acp-kernel
  • Verified against SQLite storage (opencode.db); observed across multiple sessions/models
    (deepseek-v4-flash via opencode-go, gemini-3-flash-preview via google)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.