Skip to content

fix(opencode): re-derive Working from OpenCode 2 step events - #23717

Closed
brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/opencode2-step-events
Closed

brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/opencode2-step-events

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​202 $\color{#cf222e}{\Huge{\mathbf{−}}}$​4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​198
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​16 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​16

What this fixes

After OpenCode 2 reloads its plugins in the middle of a turn, answering the agent's question made the Orca pane show Done (or stay on Needs input) while the agent kept working.

  • Who hits it: OpenCode 2 terminal panes only, and only when OpenCode reloads plugins mid-turn. That happens when a plugin file changes (Orca's own per-spawn rewrites, fixed separately in fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700, or any other plugin) or on /reload.
  • Not affected: turns with no reload, OpenCode 1.x, mimo-code, and every other agent. Their behaviour is unchanged; the OpenCode and mimo-code plugin files are rewritten once on update because the shared status engine changed.
  • Follow-up to Linear STA-8629 (OpenCode 2 shows Done while still working) and fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700. Not a regression from a specific PR: the step-event name was never mapped for OpenCode 2.

ELI5

OpenCode 2 can throw away Orca's status plugin and load a fresh copy while the agent is still working. The fresh copy never saw "the agent started working", so when you answered the agent's question it concluded the agent was done. Now the fresh copy re-learns "working" from things that can only happen while the agent works: the start of each model step, and a question or permission prompt raised by one of the agent's tools.

What Changed

Before (user): after a mid-turn plugin reload, answering the agent's question showed Done for the rest of the turn (about 25 s in a capture), or left the pane on Needs input until the turn ended (about 27 s), while the agent kept working.

After (user):

  • After a mid-turn reload, the pane returns to Working the next time the agent starts a model step.
  • If the reload lands in the step where the main agent asks you a question or for permission, answering returns the pane to Working right away, even while another tool in that step (for example a long shell command) is still running.
  • The pane shows Done only when the turn actually ends.

Mechanism:

  • Step events. The plugin translates OpenCode 2's events into the ones its shared status engine understands. The engine already treats session.next.step.started as "working", but OpenCode 2 names that event session.step.started, so the rule never fired. The plugin now forwards the OpenCode 2 name. OpenCode 2 publishes a step event only inside a running turn, and never in a session while that session's question is open, so the new Working cannot outlive the turn or hide a pending question.
  • Tool-raised blockers. A question or permission raised by a tool call proves the main agent's turn is running, because OpenCode runs tools only inside a turn. When the engine records such a blocker for the main session and has no record of the turn running, it records one. Nothing extra is posted (the blocker outranks Working), but answering now shows Working. The turn's own end, or the plugin being unloaded, clears that record exactly like a normal Working state. It never takes over a running turn another plugin instance already owns.
  • The engine recognises tool-raised blockers by the markers OpenCode puts on them (metadata.tool on the question tool's form, source.type: "tool" on tool permissions, tool on OpenCode 1.x requests). Blockers without a marker keep the old behaviour.
  • Tool events stay unmapped. A parallel tool reports progress while a question in the same step is still waiting, so treating tool activity as Working would hide that question.

Performance: one more event type per model step reaches the engine, and the session lookup it needs is cached. Working is re-posted at most once per model step, and only when an assistant-text preview was posted since the last status post. The text-streaming path is unchanged.

Why

  • A freshly loaded plugin cannot ask OpenCode which sessions are running or which questions are open: the OpenCode 2 plugin API exposes neither, and its event stream has no replay. So the plugin re-derives "working" from the next live event that proves it.
  • Rejected: map tool events to Working. It would clear a pending question while the agent is still waiting on the user, trading a false Done for a false Working. A test pins this.
  • Rejected: treat every question or permission as proof of a running turn. OpenCode's HTTP API can create a form or permission on an idle session; answering it would then latch Working with no turn end to clear it. A test pins this.
  • Rejected: apply the blocker rule to subagent sessions. Orca deliberately retires a subagent's question when the main turn ends but keeps a busy subagent until that subagent's own end, so a Working derived from the question could outlive the question that justified it.
  • Rejected: map session.retry.scheduled. The step that follows a retry already restores Working, and a retry could not be produced live.
  • Deferred: hand the unloaded copy's state to its replacement. This would also fix a question that is already pending at reload. But it only works when OpenCode reuses the loaded module (/reload and Orca plugin updates load a fresh copy, where this PR's step rule is still the only recovery), and it changes the unload path fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700 owns, so it belongs with fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700 or a follow-up.
  • Known weaknesses:
    • Reload while a question or permission is already on screen: the new copy never saw it. After you answer, the pane keeps its last state until the agent's next model step (typically a few seconds).
    • Reload inside the step where a subagent asks the question: answering shows Done until that subagent's next model step.
    • Reload during the agent's final answer with no tools: no further step starts, so a Done shown by the old copy is not corrected before the turn ends.
    • A question raised inside a tool without a tool marker (the web-search provider picker): answering shows Done until the next step.
    • Until fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700 lands, unloading the old copy still posts Done, so the pane shows Done from the reload until the next step starts.

Linked Issue

Follow-up to Linear STA-8629 and #23700. Both PRs change the pinned plugin digests in hook-service.test.ts (OpenCode and mimo-code), so whichever merges second needs both regenerated.

Visual Proof

Real OpenCode 2.0.18 in a hidden Orca build on a remote Mac. Both builds include #23700; the only difference between them is this PR. Prompt: "Ask me one multiple-choice question with your question tool, then run: sleep 30, then reply FINISHED." Each screenshot is taken during sleep 30, after the question was answered. Sidebar icons: yellow spinner = Working, orange question mark = Needs input, green check = Done.

Plugin reload before the question. Before: Done for 33.5 s while the agent worked. After: Working until FINISHED.

main arm, reload before question: Done while sleep 30 runs

PR arm, reload before question: Working while sleep 30 runs

Plugin reload inside the step that asks the question. Before: Done for 36.6 s. After: Working, with no Done flash (sampled every 100 ms).

main arm, reload inside the question's step: Done while sleep 30 runs

PR arm, reload inside the question's step: Working while sleep 30 runs

Plugin reload while the question is on screen. Before: Needs input for 32.3 s after the answer, until the turn ended. After: Needs input for 1.1 s after the answer (until the next model step), then Working.

main arm, reload while question pending: Needs input while sleep 30 runs

PR arm, reload while question pending: Working while sleep 30 runs

No reload (control). Identical on both: Working during the sleep, Done at FINISHED.

main arm, no reload: Working while sleep 30 runs

PR arm, no reload: Working while sleep 30 runs

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • hook-plugin-opencode2-setup.test.ts runs each case against both the opencode and opencode2 plugin variants, with event orders taken from OpenCode 2.0.14: reload while a question is pending; question raised after a reload; question or permission raised in the step the reload joined while a sibling tool runs; cancel followed by interrupt; a form no tool raised; a pending question that keeps blocking while a sibling tool reports progress.

  • hook-plugin-lifecycle-delivery.test.ts: a tool-raised blocker seen by a second plugin instance never takes over the first instance's running turn.

  • An existing test now ends its cancelled tool-raised form with the turn's end, which is the order OpenCode 2 produces.

  • Ablations, each with the fix reverted and then restored: without the step mapping, 4 failures; without the blocker rule, 6; without the tool-marker check, 2; with the subagent exclusion removed, 6 existing subagent tests fail; without the no-takeover check, the new instance test fails with a false Done; mapping session.tool.progress to Working fails the pending-question tests.

  • src/main/opencode, src/main/opencode2, src/main/mimo: 285 passed, 1 skipped. pnpm tc:node, oxlint and the changed-code quality gate pass.

  • Live QA on a remote Mac with real OpenCode 2.0.18 (see Visual Proof): all three reload cases now end on the expected status, the no-reload turn is unchanged, and a /reload typed mid-turn stayed Working until the turn ended.

  • Not run: Windows, Linux, SSH. The change is generated plugin JavaScript with no platform-specific code, and SSH and WSL hosts receive the same source.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: no new inputs.
  • Backwards compatibility: the older session.next.* names are unchanged, and there is no wire or schema change.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for human review (head 0a5fbe1)

Verdict: the fix does what the issue asks. After OpenCode 2 reloads its plugins mid-turn, answering the agent's question no longer shows Done (or stays on Needs input) while the agent keeps working. Four review loops ran; the last returned clean. CI is green and pnpm tc:node passes at the final head.

Commits added during review:

  • 761e576ac6b: a review found that a reload landing inside the step that asks the question still showed Done after the answer until the agent's next step. Fix: a question or permission raised by one of the main agent's tools now counts as proof its turn is running, so answering returns the pane to Working immediately. It is gated on OpenCode's tool markers, because OpenCode's HTTP API can raise a question on an idle session, and it is limited to the main session, because a subagent's question is retired with the main turn.

  • 0a5fbe1d87f: test only. It pins that this rule never takes over a running turn another plugin instance already owns; without that guard the pane would show a false Done.

Architecture check: after repeated reload variants, a separate review challenged the approach. Verdict: keep this PR's approach. The OpenCode 2 plugin API cannot list running sessions or open questions, so a fresh plugin must re-derive "working" from the next live event. Handing the old plugin copy's state to its replacement would also fix a question already on screen at reload, but it only helps when OpenCode reuses the loaded module and it changes the unload path #23700 owns, so it belongs with #23700 or a follow-up.

Live QA on a remote Mac, real OpenCode 2.0.18, hidden Orca builds that both include #23700 and differ only by this PR. Prompt: ask one question, then sleep 30, then reply FINISHED.

  • Reload before the question: main shows Done for 33.5 s while the agent works; this PR shows Working until FINISHED.

  • Reload inside the step that asks the question: main shows Done for 36.6 s; this PR shows Working, with no Done flash at 100 ms sampling.

main arm, reload inside the question's step: Done while sleep 30 runs

PR arm, reload inside the question's step: Working while sleep 30 runs

  • Reload while the question is on screen: main stays on Needs input for 32.3 s after the answer; this PR returns to Working 1.1 s after the answer, at the next model step.

main arm, reload while question pending: Needs input while sleep 30 runs

PR arm, reload while question pending: Working while sleep 30 runs

  • No reload: identical on both arms. A /reload typed mid-turn on this PR stayed Working until the turn ended. All screenshots are in the PR description.

Known weaknesses (documented in the description, not fixable from the plugin in OpenCode 2's current API): a question already on screen at reload keeps its last status until the next model step (1.1 s live); a subagent's question raised after a reload shows Done after the answer until that subagent's next step.

Open, not caused by this PR:

  • fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700 and this PR both change the pinned OpenCode and mimo-code plugin digests in hook-service.test.ts. Whichever merges second must regenerate both.

  • fix(opencode): keep OpenCode 2 panes Working across plugin reloads #23700 (from reading the code, not run): OpenCode's plugin event stream has no replay. If a turn ends between the old plugin copy unsubscribing and the new one subscribing, nothing posts Done, and the pane stays on Working until the next turn.

  • Seen once in live QA, unattributed: on the first turn in a fresh OpenCode window, the PR build's sidebar showed Done for 131 ms at the very start of the turn. Every path this PR adds only records or posts Working, so the change cannot emit that Done, but main's first turn was not captured with the same watcher.

Not run: Windows, Linux, SSH. The change is generated plugin JavaScript with no platform code, and SSH and WSL hosts receive the same source. Marked ready for review; the PR has not been merged, and auto-merge is not enabled.

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Adjusts session state tracking for concurrent tool execution.

The PR appears safe to merge; no outstanding or newly introduced actionable failure was established.

Summary

The PR translates OpenCode 2 step starts into shared lifecycle events and uses tool-raised blockers to restore Working after a mid-turn plugin reload. It adds reload and factory-ownership tests and updates the generated-plugin digests.

  • Since the previous review, the PR’s changed files only lost a public-export assertion; no new production behavior changed.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[OpenCode 2 step started] --> B[Shared lifecycle: Working]
  C[Tool-raised question or permission] --> D[Record running-turn ownership]
  D --> E[Show blocker]
  E --> F[Answer or resolve]
  F --> B
  B --> G[Turn ends: Done]
Loading

Reviews (3) · Last reviewed commit: "test(opencode): regenerate plugin digest..."

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e63afbd7-8bcd-40e7-bc6e-f7bb866d61db

📥 Commits

Reviewing files that changed from the base of the PR and between 49f9e9f and 7e0b915.

📒 Files selected for processing (1)
  • src/main/opencode/hook-service.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The lifecycle handler now records a factory as the busy owner for a root session when a tool-raised blocker has no existing owner. OpenCode2 setup translates session.step.started to session.next.step.started. Tests add coverage for blocker ownership and status delivery across plugin reloads, form replies, tool activity, execution completion, and interruption.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 7e0b9

The changes target stale OpenCode 2 status reporting and add lifecycle regression coverage. No actionable merge-blocking risk is established in the supplied context.

Architecture Summary

Architecture risk: 🔵 Low · up to 7e0b9

The change affects 1 system.

Changed systems: src

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — src (service) was modified; 6 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in src/main/opencode/hook-plugin-lifecycle-delivery.test.ts: Adds a test in which one factory reports Busy and a second receives a permission.asked event containing tool-call details, then is disposed. The expected deliveries are SessionBusy, PermissionRequest, and a reasserted SessionBusy; the first factory is disposed afterward.
  • observed — Modified behavior in src/main/opencode/hook-plugin-opencode2-setup.test.ts: The event fixture now cancels the question form and then emits session.execution.interrupted; a comment states that a tool-raised form keeps the turn active until its end.
  • observed — Modified behavior in src/main/opencode/hook-plugin-opencode2-setup.test.ts: runSetupBridge now returns a drained: Promise<void> alongside event names and optional cleanup.
  • observed — Modified behavior in src/main/opencode/hook-plugin-opencode2-setup.test.ts: The helper creates a promise and resolver for signaling when its event generator has finished processing the supplied events.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: re-deriving the Working status from OpenCode 2 step events. It is concise and directly related to the changeset.
Description check ✅ Passed The description is comprehensive and covers the required explanation, changes, rationale, linked issues, visual proof, testing, compatibility notes, and checklist. The Review section is empty and the …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one low-severity robustness note inline.

Reviewed changes

  • Step-event remap — status-plugin-setup-source.ts maps OpenCode 2's session.step.started to session.next.step.started, which normalizes to a busy session.status, re-deriving Working for a plugin reloaded after execution.started.
  • Tool-raised blocker ownership — status-plugin-lifecycle-source.ts records the factory as busy root owner when a root-session permission/question carries a tool marker and no owner exists, so answering returns the pane to Working until the turn's Idle.
  • Marker predicate — isToolRaisedBlocker recognises properties.tool, metadata.tool, and source.type === "tool"; child sessions and unknown-lineage sessions are excluded.
  • Tests — three test files cover reload-before/inside/after-question, cancel+interrupt, API-created forms, and cross-factory no-takeover; digests re-pinned for the opencode and mimo variants. All 285 tests in src/main/{opencode,opencode2,mimo} pass.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

'// tool, 2.x form metadata.tool or permission source) proves that turn is running;',
'// an API-created blocker can sit on an idle session and proves nothing.',
'function isToolRaisedBlocker(properties) {',
' return Boolean(properties?.tool || properties?.metadata?.tool || properties?.source?.type === "tool");',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule this predicate guards leans on the tool marker as proof the blocker came from a running tool, but properties.metadata is a freeform record the public POST /api/session/:sessionID/form accepts from any client, so a form can carry a client-set metadata.tool while owning an idle session. In that case the new rule latches busyRootOwnerBySessionID with no turn to emit Idle, and the pane stays Working after the reply until the next turn ends or the plugin is unloaded. The repo's own reproduction doc (docs/bug-reproductions/opencode2-form-created-kinds/README.md:22-27) notes metadata is optional and unvalidated, and the API-created-form test at hook-plugin-opencode2-setup.test.ts:594 uses a form with no metadata, so it does not cover a marker-carrying one.

Technical details
# Marker is client-spoofable, so it is not proof of a running turn

## Affected sites
- `src/main/opencode/status-plugin-ownership-source.ts:127-128` — `isToolRaisedBlocker` accepts any `metadata.tool`.
- `src/main/opencode/status-plugin-lifecycle-source.ts:102-104` — the rule sets `busyRootOwnerBySessionID` with no turn-liveness corroboration.
- `src/main/opencode2/status-plugin-setup-source.ts:47-65` — `form.created` spreads the client-supplied `form` (including metadata) into `question.asked` for any real session.

## Required outcome
- A blocker that is not backed by a running turn must not leave a busy owner that outlives the reply. Either corroborate the turn (e.g. only set the owner when a busy/step signal for the session is otherwise in flight) or bound the record so a reply with no turn clears it.

## Open questions for the human
- Is `metadata.tool` reachable in practice from anything other than the `question` tool, given `metadata` is `Record(String, Unknown)` and the form API is public? If yes, this is the same latch the PR rejected for "treat every question/permission as proof", just requiring a forged marker.

@brennanb2025
brennanb2025 force-pushed the brennanb2025/opencode2-step-events branch from 0a5fbe1 to 49f9e9f Compare September 29, 2026 04:19
OpenCode 2 emits session.step.started, but the plugin only mapped the
older session.next.step.started name, so per-step Busy never fired. A
plugin instance re-created mid-turn (OpenCode reloads every plugin when
the plugins dir changes) never sees execution.started, so answering a
question left the pane on Needs input, or published Idle, until the turn
ended.

Forward session.step.started to the existing step mapping. Tool events
stay unmapped: a parallel tool reports progress while a sibling question
in the same session is still pending, and Busy would clear that blocker.
…lugin reload

When OpenCode 2 reloads plugins inside the step that raises a question or
permission, the fresh plugin instance never saw that turn start. Answering
cleared its only record, so the pane showed Done until the next step began,
which waits for every sibling tool in the step to finish.

A blocker raised by a tool call proves its turn is running, since OpenCode
runs tools only inside a turn. When such a blocker belongs to a known root
session with no recorded Busy owner, record one. Nothing new is posted,
because the blocker outranks Busy. The reply then shows Working, and the
turn's own end or plugin disposal retires the record like any Busy.

A blocker created through OpenCode's HTTP API can sit on an idle session, so
blockers with no tool origin are left out. Subagent and unknown-lineage
blockers are unchanged, and no tool event is mapped, so a sibling tool can
never hide a pending blocker.
…er factory's Busy

The blocker rule records Busy only when no owner is known. Removing that
guard let a second factory claim the owner, so its disposal erased Busy for a
turn the first factory was still running and the pane fell to Done. Only the
pinned digest caught that ablation before this test.
@brennanb2025
brennanb2025 force-pushed the brennanb2025/opencode2-step-events branch from 49f9e9f to 7e0b915 Compare September 29, 2026 23:33
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing in favor of #23722, which merged as 3727100.

This PR fixed a symptom of how the old design tracked status: after OpenCode 2 reloaded Orca's status plugin mid-turn, the fresh copy started with no memory of the running turn and had to re-learn "working" from later events. #23722 removes that cause. Each pane's own OpenCode window now reports its status, read straight from OpenCode's session data, so a plugin reload can no longer lose it. For OpenCode 2 panes, the shared server's copy of the plugin no longer reports at all.

On main, the only path this PR still touches is the shared-server fallback, which runs only on an SSH host that hasn't received the per-pane plugin yet, and Orca now refreshes that plugin on connect. Reopen if a case turns up where that fallback matters.

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.

1 participant