Skip to content

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

Closed as not planned

Description

@andreyz

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.

Activity

  1. github-actions commented on Aug 24, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is specifically about the zero-delivery from ctx.event.subscribe (no events fired at all despite the SSE endpoint streaming them), that aspect doesn't appear to have a dedicated existing report and is worth keeping open on its own.

  2. andreyz commented on Aug 24, 2026

    @andreyz
    Author

    Related opencode issues discovered in a tracker/web sweep:

    So the pattern across V2 is: event delivery to plugin subscribers and hook-mutation propagation to the dispatched request are both unreliable as of beta-18050, while the public SSE endpoint and the session store behave differently from the plugin channel (and from each other).

  3. andreyz commented on Aug 25, 2026

    @andreyz
    Author

    Follow-up: the #43731-style hypothesis (mutate an existing text part in place, the shape that report says persists into server storage) was tested and does not reach the model either on beta-18050.

    Probe: in session.hook("context"), locate the last role:"user" message, mutate its existing content[].type==="text" part by appending a token, log before/after. The mutation fired (textLen 32 -> 49, user msg id msg_03798f7f…), but:

    • the next hook fire's lastUser was a different id (msg_037996…), and
    • the model-visible projection (GET /api/session/{id}/context) shows the challenge user message as msg_037996… with the unmutated text — the mutated object never appears in any user message of the projection.

    Conclusion: the context hook's messages array is a per-dispatch working copy whose message objects have their own ids that diverge from the dispatch transcript. No mutation — appended messages, appended parts, or in-place text edits — propagates to the engine that builds the prompt. The model's prompt is assembled from a source the hook never touches.

  4. okmanideep commented on Sep 10, 2026

    @okmanideep

    Confirmed on macOS with opencode2 v0.0.0-beta-17519.

    I built a local idle-notification plugin under ~/.config/opencode/plugins/. The V2 plugin loaded successfully and appeared in GET /api/plugin, but ctx.event.subscribe() never delivered a session.status event during multiple completed agent turns.

    This documented event-subscription approach did not work:

    setup(context) {
      const controller = new AbortController()
    
      void (async () => {
        for await (const event of context.event.subscribe({ signal: controller.signal })) {
          if (event.type !== "session.status" || event.data?.status?.type !== "idle") {
            continue
          }
    
          await playSound()
        }
      })()
    
      return () => controller.abort()
    }

    The plugin’s setup() did run, and afplay /System/Library/Sounds/Glass.aiff worked from that process, so this was not a plugin-loading or macOS-audio issue. The legacy V1 event path was also ineffective in this V2 session.

    Working workaround:

    const waiting = new Set<string>()
    
    await ctx.session.hook("context", (event) => {
      if (waiting.has(event.sessionID)) return
      waiting.add(event.sessionID)
    
      void ctx.session
        .wait({ sessionID: event.sessionID })
        .then(playSound)
        .catch(() => {})
        .finally(() => waiting.delete(event.sessionID))
    })

    context fires reliably, and the detached ctx.session.wait() resolves when the complete agent turn becomes idle. This allowed the notification to work without relying on the silent event stream.

    This supports the report’s core finding: accepted V2 plugins can have a functional setup() and hooks while ctx.event.subscribe() remains a silent no-op. A stable session-settled/idle plugin hook would avoid requiring this workaround.

  5. OpenJawCode commented on Sep 11, 2026

    @OpenJawCode

    Current-version datapoint (beta-19271, Linux arm64, headless service) confirming this defect class with a minimal plugin:

    • ~15-line plugin: ctx.session.hook("context") pushes one static text part to system (no DB, no other hooks).
    • Fresh headless session asked for the injected canary string verbatim — model answered it has no such line.
    • File-tracer in the hook proves it fired exactly once for that session ID with system confirmed as an array before the push.

    So on 19271/headless: hook fires, mutation succeeds, content still absent from the effective prompt. Same fires-but-dropped shape as reported here for 18050. Happy to provide the minimal plugin source if useful.

  6. naschpitz commented on Oct 25, 2026

    @naschpitz

    Same defect on stable v2.1.4 (Linux x86-64, headless serve --service)

    Our plugin channel is provably dead — zero deliveries, not intermittent.

    Setup. @opencode/plugin 2.0.28, V2 { id, setup } plugin auto-discovered from the config plugins dir. It subscribes with for await (const ev of ctx.event.subscribe({ signal })) and the loop body writes a log line unconditionally.

    Evidence.

    • GET /api/plugin reports the plugin source: local, state: active, and the server log shows it loaded (and reloaded on edit) many times.
    • The log file the handler writes on every code path (received event, decision, error — with mkdirSync + appendFileSync) has never been created. The loop body never ran once, in 1 h 14 min of lifetime while several sessions were active.
    • Not timing: the watcher reload brackets the whole incident, and the plugin's first nudge is scheduled 90 s after idle.
    • The bus works outside the plugin: GET /api/event on the same server streamed 17 distinct event types in a 25 s sample (session.step.started, session.reasoning.*, session.step.streamed/ended, session.tool.called/success/progress, session.usage.updated, shell.created/exited/deleted, …). 56 idle transitions across 15 sessions in that window produced zero notifications.
    • Vocabulary note: session.idle is declared in the shipped schema (session-status-event, durability: "ephemeral") but never appears on the SSE stream either — worth stating which names plugins are guaranteed to receive.

    Impact. The plugin exists to catch a parent agent that ends its turn after announcing a dispatch without making the tool call. It never fired, so the session stayed idle for 2 min 02 s until a human typed at it. We have not worked around it locally — the guardrail is simply missing.

    Not covered here. We never reached the ctx.session.synthetic path (the handler never ran), so no evidence either way on injection.

  7. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed probe. I couldn't reproduce this on the latest release, v2.2.0. I couldn't test 0.0.0-beta-18050 because that build isn't published on npm. I ran a probe plugin like yours against a simulated model and checked what was actually sent to the provider:

    • session.hook("context"): the message pushed into messages and the part pushed into system both showed up in every model request after the hook ran. system going back to its original length at the next dispatch is by design: it's rebuilt for every request, so the hook has to add its part each time.
    • ctx.session.synthetic: the message was in the model request from the next dispatch on, including the next turn. It isn't in the request whose hook created it, because that request's messages are already built; push the text into messages too if you need it right away. Calling synthetic without resume: false also wakes the session again by default. That likely explains the repeated dispatches and the growing message count you saw.
    • ctx.event.subscribe: it takes options and returns an async iterable rather than taking a handler. subscribe(type, handler) is silently ignored. This delivered every event:
      for await (const ev of ctx.event.subscribe({ types: ["session.inbox.enqueued"] })) { /* ... */ }
    • Event names: v2 uses session.inbox.*, session.step.*, session.text.* and so on. session.next.* and the @opencode-ai/sdk 1.18.21 types are from v1, so they won't match a v2 server.

    If it still fails on v2.2.0 or a current beta with the iterable form of subscribe, a reply with your current version and probe code would help. A request body captured through a session.hook("http.request") hook that is missing your token would help most.

  8. rekram1-node commented on Oct 28, 2026

    @rekram1-node
    Collaborator

    make sure you are using v2 plugins w/ v2 opencode

  9. opencodejr commented on Oct 31, 2026

    @opencodejr
    Contributor

    I'm closing this for now because we haven't heard back. On v2.2.0 the plugin API delivered everything we tested:

    • Changes made in session.hook("context") reached the model on every request after the hook ran.
    • A message from ctx.session.synthetic reached the model from the next request on.
    • ctx.event.subscribe({ types }) delivered every event once its result was looped over with for await.

    If you still see this on v2.2.0 or a newer beta, please reopen or comment. Your version and probe code would help, and most of all a request body captured with session.hook("http.request") that is missing your token. Thanks for the detailed report.

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions