anomalyco / anomalyco/opencode

plugins: event.subscribe delivers no events; context-hook and synthetic injections never reach the model prompt (beta 18050)

Open
#44,788 5 comments 1 reaction 1 assignee View on GitHub

@rekram1-node is already working on this.

Since Aug 24, 2026.

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

Description

Summary

On beta channel server 0.0.0-beta-18050, the V2 plugin API cannot deliver context into a session's model prompt by any documented mechanism: ctx.event.subscribe registers cleanly but delivers zero events (all three call forms), ctx.session.hook("context") fires on every dispatch but mutations to messages/system never reach the model, and ctx.session.synthetic creates a message that appears in /session/{id}/context yet is excluded from the actual model prompt. Plugin authors following the V2 plugin docs get silent no-ops.

Environment

  • opencode version: opencode 1.18.21; server reports 0.0.0-beta-18050 (GET /api/health) — installed via ~/.opencode/bin/opencode2 serve --service
  • OS: Darwin 25.5.0 (arm64), kernel xnu-12377.121.10
  • Terminal: tmux 3.x, TERM=tmux-256color, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Install/channel: beta (version string 0.0.0-beta-18050, channel=beta)
  • Active plugins (auto-discovered under ~/.config/opencode/plugins/): vault-heartbeat.ts, vault-surface.ts (both V2 { id, setup } shape, load cleanly), moshi-hooks.ts (V1 shape — correctly rejected by the loader with SchemaError(Missing key at ["default"]["setup"])), plus a headroom package entrypoint (also rejected, V1). The rejection path itself works as expected; the problem is entirely in the accepted V2 plugins' runtime behavior.

Reproduction

Drop this probe plugin at ~/.config/opencode/plugins/probe.ts (auto-discovered and reloaded on change):

import { appendFileSync } from "node:fs"
const LOG = "/tmp/opencode-probe.log"
const log = (s: string) => { try { appendFileSync(LOG, new Date().toISOString() + " " + s + "\n") } catch {} }
export default {
  id: "probe",
  setup: async (ctx: any) => {
    const h = (label: string) => (ev: any) => log(`EV[${label}] ${ev?.type ?? "?"}`)
    for (const arg of ["session.next.prompted", "*", h("catchall")]) {
      try { ctx.event.subscribe(arg as any, h(String(arg))); log("SUB OK " + String(arg)) } catch (e: any) { log("SUB FAIL " + String(arg) + " " + (e?.message ?? "")) }
    }
    await ctx.session.hook("context", async (ev: any) => {
      log(`HOOK sess=${ev?.sessionID} msgs=${Array.isArray(ev?.messages) ? ev.messages.length : "?"} sysLen=${Array.isArray(ev?.system) ? ev.system.length : "?"}`)
      try { ev.messages.push({ role: "user", content: [{ type: "text", text: "PROBE-TOKEN-A" }] }); log("HOOK messages push OK") } catch (e: any) { log("HOOK push FAIL " + (e?.message ?? "")) }
      try { ev.system.push({ type: "text", text: " PROBE-TOKEN-B" }); log("HOOK system push OK") } catch (e: any) { log("HOOK sys FAIL " + (e?.message ?? "")) }
      try { await ctx.session.synthetic?.({ sessionID: ev.sessionID, text: "PROBE-TOKEN-C" }); log("HOOK synthetic OK") } catch (e: any) { log("HOOK synth FAIL " + (e?.message ?? "")) }
    })
  },
}
  1. Start opencode beta, open a session, send any non-empty prompt (e.g. "Repeat any token you can see.").
  2. Read /tmp/opencode-probe.log after the turn.
  3. Check GET /api/session/{sessionID}/context for the synthetic message, and note what the model actually saw.

Observed on 2026-08-24:

  • EV[...] lines: none ever — across ~20 model dispatches and multiple keystrokes, all three subscription forms registered OK and delivered nothing. Meanwhile an authenticated GET /api/event (SSE) from outside the plugin does stream events — but with a vocabulary absent from the shipped types: session.inbox.enqueued, session.text.delta, session.step.started, session.tool.called, session.reasoning.*, session.execution.*; no session.next.* event ever appears, and session.inbox.enqueued is not in @opencode-ai/sdk 1.18.21's generated types at all.
  • HOOK fires on every dispatch with payload { sessionID, agent, model, system, messages, tools }. messages are OpenAI-shaped { id, role, content: [{ type: "text", text }] }; system is an array of { type: "text", text } parts.
  • Mutations: pushing into messages persists in the array between dispatches (151 → 152 → 153 … 220 → 222) but the pushed messages never appear in the model's prompt; pushing into system is reverted between dispatches (len 2 → 3, next fire reports 2).
  • ctx.session.synthetic returns a created message, and GET /api/session/{id}/context includes it — yet the token never appears in the model's prompt on the same turn or the next turn (canary-token tests were consistently negative across six probe rounds while the model could see every other part of its context).

Expected Behavior

Per the V2 plugin docs (opencode.ai/v2/docs/build/plugins): ctx.event should "subscribe to the current public server event stream" and receive events; ctx.session.hook("context") "mutates system, messages, and the tools record immediately before model dispatch" — so those mutations should be visible to the dispatched model request; and the runtime session.next.* vocabulary should match the installed SDK types / docs.

Actual Behavior

All three documented injection paths are silent no-ops on this build:

  1. ctx.event.subscribe delivers no events to plugins (registered handlers never invoked), despite the public SSE endpoint streaming events with a different, undocumented vocabulary (session.inbox.enqueued for an incoming user prompt).
  2. ctx.session.hook("context") runs on every dispatch, but mutations to messages never reach the model and mutations to system are reverted before the next dispatch — the payload appears to be a per-dispatch working copy that the provider layer ignores/rebuilds.
  3. ctx.session.synthetic (and POST /api/session/{id}/synthetic) persists a message that shows in /session/{id}/context but is filtered out of the model's actual prompt, same-turn and next-turn.

Combined impact: a plugin cannot inject context into the model's prompt on this build. Because plugin authors are told to fail silent rather than break the prompt, every one of these paths fails invisibly — the "registration succeeded, delivery dead" shape is precisely what makes it undetectable except by a canary-token experiment.

Additional Context

  • Frequency: consistent — reproduced every time across six probe rounds and ~20 dispatches on 2026-08-24. Not intermittent.
  • Event/type drift appears bidirectional: server emits names absent from the shipped SDK types (session.inbox.enqueued) and never emits documented names (session.next.*, session.created in the plugin stream). This makes typed subscriptions untrustworthy even setting aside the zero-delivery issue.
  • The plugin loader, V2 { id, setup } acceptance, file-watch reload, and the schema rejection of V1 shapes all work as documented — only the runtime delivery/mutation paths are broken.
  • Workarounds that do not help: wildcard and catchall subscription forms; awaiting the synthetic call inside the hook (same-turn timing not the issue — the message is simply excluded from the prompt); mutating messages with parts-array content; appending to system (reverted).
  • Working fallback in our case: pull-based context via the session's own MCP tooling (works fine); the blocker is specifically the push/injection direction.

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.