Skip to content

fix(opencode): co-promote the session-URL notice with the feedback on OpenCode 2 - #1518

Merged
backnotprop merged 1 commit into
mainfrom
fix/opencode-v2-notice-co-promotion
Sep 12, 2026
Merged

backnotprop merged 1 commit into
mainfrom
fix/opencode-v2-notice-co-promotion

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Closes #1515. Follow-up to #1460 / #1459.

What the reporter sees

On OpenCode v2.0.2, /plannotator-last prints "◈ Plannotator session ready: " and the agent stays idle (correct). When Send Feedback arrives, the agent wakes but first runs a whole model turn on the URL notice, and the annotations land in the turn after it.

Why, verified against the OpenCode v2.0.2 source (ea5ae23295)

  • A plugin's session.synthetic is not a notification. It admits a pending inbox row; resume: false only declines the wake right then (packages/core/src/session/session.ts:168,304). It never exempts the row from the next promotion.
  • Delivery kinds are exactly steer | queue (packages/schema/src/session-inbox.ts:11). There is no non-promotable kind, and the plugin SessionDomain Pick has no inbox cancel (packages/plugin/src/promise/session.ts:112-129), even though the server routes one.
  • Promotion is asymmetric (packages/core/src/session/inbox.ts:496-535): pending steers publish as a batch; queued rows publish one at a time, oldest first (limit(1)), and only after the steers.
  • The feedback's own session.prompt wakes the session at an idle boundary with input scope. promote(…, "input") then takes the single oldest queued row, which is the notice fix(opencode): queue-deliver the session URL notice (#1459) #1460 pinned to queue. A promoted synthetic is a plain user-role message (runner/to-llm-message.ts:257), so it gets a full model step.

#1460's own caveat named this hole. It is not a spurious wake; it is the reviewer's feedback waking the session.

The fix

The notice rides steer delivery (the host default), and feedback that follows an admitted notice rides steer too, so the two share one promotion and enter one turn. A rejected notice, OpenCode 1 (which uses a real toast and has no inbox), and hosts without session.synthetic keep FEEDBACK_DELIVERY = "queue" and its late-arrival behavior unchanged. apps/opencode-plugin/index.ts (v1) is untouched.

Side effect, stated: while a notice of ours is pending, feedback submitted during a running turn lands at that turn's next step boundary instead of after it. That is bounded to sessions that posted a notice, where the queued ordering was already broken.

Also declares delivery on the duck-typed synthetic signature, which the code has passed since #1460 without a type.

Not in this PR

  • Removing the transcript line entirely on OpenCode 2 needs a TUI plugin entrypoint (<pkg>/tui, ctx.ui.toast.show) on hosts that expose features.tui. Worth a follow-up; it would restore v1's toast semantics.
  • Upstream ask: a render-only delivery kind, or session.inbox.cancel in the plugin SessionDomain.

Test plan

  • bun test apps/opencode-plugin: 175 pass. Flipping CO_PROMOTED_DELIVERY back to "queue" fails exactly the three new/changed assertions (notice delivery, feedback-after-notice delivery, plan-review URL delivery) and nothing else.
  • bun build apps/opencode-plugin/server.ts --target node succeeds.
  • Not reproduced at runtime: the only local OpenCode is 1.18.x and the plugin pins types only; the finding is a code trace against the v2.0.2 tag.

… OpenCode 2 (#1515)

On OpenCode 2 a plugin message is a pending inbox row; resume: false only
declines the immediate wake. SessionInbox.promote publishes pending steers
as a batch but queued rows one at a time, oldest first, so the queued
notice #1460 introduced was promoted alone by the feedback's own wake and
became its own model turn with the reviewer's annotations behind it.

The notice now rides steer delivery, and feedback that follows an
admitted notice rides steer too, so both enter one promotion and the
agent acts on the annotations. A rejected notice, OpenCode 1, and hosts
without session.synthetic keep the queue delivery unchanged.
@backnotprop
backnotprop merged commit affc22e into main Sep 12, 2026
28 checks passed
@backnotprop
backnotprop deleted the fix/opencode-v2-notice-co-promotion branch September 12, 2026 23:20
backnotprop added a commit that referenced this pull request Sep 15, 2026
…before steering feedback (#1536)

#1518 made the OpenCode 2 client post the session-URL notice as a "steer" and,
while a notice had been posted, admit the reviewer's feedback as a "steer" too,
so the two share one promotion (#1515). The flag behind that, `noticePending`,
meant "we posted a notice during this review" rather than "our notice is still
an un-promoted inbox row", and nothing ever observed promotion.

The two diverge as soon as anything else wakes the session: a user typing an
unrelated message promotes every pending steer as one batch
(`SessionInbox.promote`), our notice included, and starts a turn. Feedback
arriving minutes later was still admitted as a steer, landing in the middle of
that unrelated turn — exactly the late-arrival case `FEEDBACK_DELIVERY =
"queue"` exists to prevent.

`createNoticePendingTracker` now answers the question from the host itself. It
subscribes to `ctx.event.subscribe()` before the notice is posted and clears the
flag when the host reports that row settled, speaking both live vocabularies:
`session.inbox.delivered` / `session.inbox.cancelled` with `data.inboxID`
(v2.0.x and dev) and the pre-rename `session.input.promoted` with
`data.inputID` (the `0.0.0-next-*` host this package pins). The row id comes
from `session.synthetic`'s own response. A host that reports no id, exposes no
event domain, or whose stream never delivers degrades to exactly the previous
behavior.

The client gains `dispose()`, called in a `finally` by both call sites, so a
review that ends without delivering a prompt does not leave an event
subscription open on the host.

A notice stranded by a review closed without feedback is unchanged: the plugin
session domain exposes no way to withdraw a pending row.
backnotprop added a commit that referenced this pull request Sep 15, 2026
`stop()` aborts the controller and leaves it `undefined`, which is exactly the
state the watcher starts from, so aborting alone could not express "never
again": a `notifyUrl` that outlived `dispose()` re-opened a host event
subscription with no owner left to abort it — the leak #1536 added `dispose()`
to prevent. Both call sites dispose in a `finally` after the review returns and
`toastPlannotatorUrl` never awaits `notifyUrl`, so nothing in the type orders
the two. Unreachable on main today (the CLI child's pumps are torn down inside
`runCli`'s own `finally`), latent the moment notice delivery outlives the
command.

Add a `disposed` latch, set only by `dispose` and never by `settle`: the watcher
returns early and `admitted()` arms nothing, since after dispose no watcher is
left that could lower the flag again.

Also close the pre-existing arming window carried over from #1518. `pending` was
raised only after the un-awaited `session.synthetic` round-trip, so feedback
delivered while the notice was still posting read as not pending and queued
behind a notice that is then promoted alone as its own model turn — the #1515
symptom. `watch` becomes `posting`, which starts the watch AND arms
provisionally; the notifier wraps `synthetic` so a refusal calls the new
`rejected()` and lowers the arm again, which the existing "a rejected notice
leaves the feedback queued" test still pins.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

opencode v2: Plan annotations get queued after "Plannotator session ready:" message

1 participant