anomalyco / anomalyco/opencode
Context hook: mutations of existing message parts are persisted back into session storage (by part id)
@kitlangton is already working on this.
Since Aug 20, 2026.
- 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-v2plugin (acp_ocv2), a context-compression
adapter that rewrites dispatch text via the v2 plugincontexthook.
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 likebili_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
}
}
})
},
}
- Install the plugin, start opencode2, send one message and get one reply.
- Inspect the stored part content (SQLite: message/part tables in
opencode.db, or the TUI). - 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. - Contrast: appending a whole new
role:"user"message inside the same hook is NOT persisted.
Expected behavior
One of:
- Dispatch-only contract: post-hook
event.messagesis 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. - 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. - 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 onacp-kernel - Verified against SQLite storage (
opencode.db); observed across multiple sessions/models
(deepseek-v4-flashviaopencode-go,gemini-3-flash-previewviagoogle)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.