From 87f3dcd364ea5e79e5ef951017dabe7a0b59e21c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:39:18 -0700 Subject: [PATCH 1/4] fix(opencode): re-derive Working from OpenCode 2 step events 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. --- .../hook-plugin-opencode2-setup.test.ts | 54 +++++++++++++++++++ .../opencode2/status-plugin-setup-source.ts | 4 ++ 2 files changed, 58 insertions(+) diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index e9e38334d3e6..d4ac40b4173f 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -453,6 +453,60 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag await cleanup?.() }) + // A plugin reloaded mid-turn joins after execution.started; event orders captured from OpenCode 2.0.14. + it('re-derives Working from the next step after a mid-turn reload lost a pending question', async () => { + const { names, cleanup } = await runSetupBridge([ + { + type: 'form.replied', + data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } } + }, + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-0' } }, + { type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'tool-calls' } }, + { type: 'session.step.started', data: { sessionID: 'ses_root' } }, + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-1' } } + ]) + await vi.waitFor(() => { + expect(names).toEqual(['SessionBusy']) + }) + await cleanup?.() + }) + + it('keeps the pane Working after answering a question raised after a mid-turn reload', async () => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-0' } }, + { type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'tool-calls' } }, + { type: 'session.step.started', data: { sessionID: 'ses_root' } }, + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-1' } }, + { type: 'form.created', data: { form: questionForm('form-q') } }, + { + type: 'form.replied', + data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } } + }, + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-1' } } + ]) + await vi.waitFor(() => { + expect(names).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + }) + await cleanup?.() + }) + + it('keeps a question blocking while a parallel tool in the same step reports progress', async () => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, + { type: 'session.step.started', data: { sessionID: 'ses_root' } }, + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-shell' } }, + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-0' } }, + { type: 'form.created', data: { form: questionForm('form-q') } }, + { type: 'session.tool.progress', data: { sessionID: 'ses_root', id: 'tool-shell' } }, + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-shell' } } + ]) + await vi.waitFor(() => { + expect(names).toContain('AskUserQuestion') + }) + expect(names.at(-1)).toBe('AskUserQuestion') + await cleanup?.() + }) + it('keeps a live question blocker while an ignored form is raised and resolved', async () => { const { names, cleanup } = await runSetupBridge([ { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index 7b825056ecd8..c328b2a91119 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -71,6 +71,10 @@ async function setupOpenCode2Status(ctx) { properties = { ...properties, requestID: properties.id }; } else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") { type = type.replace("session.", "session.next."); + } else if (type === "session.step.started") { + // Why: re-derives Busy for a plugin reloaded mid-turn, which never saw execution.started. + // Tool events stay out: a parallel tool reports progress while a sibling form still blocks. + type = "session.next.step.started"; } await hooks.event({ event: { type, properties } }); } From a26571e1430d3cd1f54d88e366a3e36a86639477 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:09:15 -0700 Subject: [PATCH 2/4] fix(opencode): keep Working after answering a blocker raised past a plugin 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. --- .../hook-plugin-opencode2-setup.test.ts | 123 +++++++++++++++++- .../status-plugin-lifecycle-source.ts | 5 + .../status-plugin-ownership-source.ts | 7 + 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index d4ac40b4173f..2bff0fd98f41 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -281,6 +281,8 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag data: { sessionID: 'ses_root', assistantMessageID: 'msg-1', text: 'hello' } } yield { type: 'form.cancelled', data: { id: 'form-1', sessionID: 'ses_root' } } + // A tool-raised form proves a running turn, so only the turn's end settles Idle. + yield { type: 'session.execution.interrupted', data: { sessionID: 'ses_root' } } } } }) @@ -316,7 +318,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag // (v2.0.12 capture in docs/bug-reproductions/opencode2-form-created-kinds). async function runSetupBridge( events: { type: string; data: Record }[] - ): Promise<{ names: string[]; cleanup?: () => Promise }> { + ): Promise<{ names: string[]; cleanup?: () => Promise; drained: Promise }> { process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' const names: string[] = [] globalThis.fetch = vi.fn(async (_input, init) => { @@ -328,6 +330,12 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag ? _internals.getOpenCode2PluginSource() : _internals.getOpenCodePluginSource() ) + // Why: the bridge awaits each event's handling before pulling the next, so the + // generator finishing means every lifecycle post the events cause has been made. + let markDrained = (): void => {} + const drained = new Promise((resolve) => { + markDrained = resolve + }) const cleanup = await module.default?.setup?.({ session: { get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }), @@ -338,10 +346,11 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag for (const event of events) { yield event } + markDrained() } } }) - return { names, cleanup } + return { names, cleanup, drained } } function questionForm(id: string): Record { @@ -507,6 +516,116 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag await cleanup?.() }) + const toolSource = { type: 'tool', messageID: 'msg-0', id: 'tool-0' } + const blockers = { + question: { + name: 'AskUserQuestion', + asked: { type: 'form.created', data: { form: questionForm('form-q') } }, + replied: { + type: 'form.replied', + data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } } + } + }, + permission: { + name: 'PermissionRequest', + asked: { + type: 'permission.asked', + data: { + id: 'per-1', + sessionID: 'ses_root', + action: 'shell', + resources: ['rm -rf build'], + source: toolSource + } + }, + replied: { + type: 'permission.replied', + data: { sessionID: 'ses_root', requestID: 'per-1', reply: 'once' } + } + } + } + + // The reload lands after this step's step.started, so no later step start precedes the reply. + it.each(['question', 'permission'] as const)( + 'keeps Working after answering a %s raised in the step a mid-turn reload joined', + async (kind) => { + const blocker = blockers[kind] + const stepAfterReload = [ + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-shell' } }, + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-0' } }, + blocker.asked, + blocker.replied, + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-0' } }, + // A long sibling tool keeps the step open after the reply. + { type: 'session.tool.progress', data: { sessionID: 'ses_root', id: 'tool-shell' } }, + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-shell' } }, + { type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'tool-calls' } } + ] + const midTurn = await runSetupBridge(stepAfterReload) + await midTurn.drained + expect(midTurn.names).toEqual([blocker.name, 'SessionBusy']) + await midTurn.cleanup?.() + + const wholeTurn = await runSetupBridge([ + ...stepAfterReload, + { type: 'session.step.started', data: { sessionID: 'ses_root' } }, + { type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'stop' } }, + { type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } } + ]) + await wholeTurn.drained + expect(wholeTurn.names).toEqual([blocker.name, 'SessionBusy', 'SessionIdle']) + await wholeTurn.cleanup?.() + } + ) + + it('settles on Done when a question raised after a mid-turn reload is cancelled by an interrupt', async () => { + const { names, cleanup, drained } = await runSetupBridge([ + { type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-0' } }, + { type: 'form.created', data: { form: questionForm('form-q') } }, + { type: 'form.cancelled', data: { id: 'form-q', sessionID: 'ses_root' } }, + { type: 'session.tool.failed', data: { sessionID: 'ses_root', id: 'tool-0' } }, + { type: 'session.execution.interrupted', data: { sessionID: 'ses_root' } } + ]) + await drained + expect(names).toEqual(['AskUserQuestion', 'SessionBusy', 'SessionIdle']) + await cleanup?.() + }) + + // Why: OpenCode's HTTP API can park a form on an idle session; its reply starts no work. + it('returns to Done after answering a form no tool raised', async () => { + const { names, cleanup, drained } = await runSetupBridge([ + { + type: 'form.created', + data: { + form: { + id: 'form-api', + sessionID: 'ses_root', + title: 'Confirm', + fields: [{ key: 'q0', title: 'Proceed?', type: 'string', options: [] }] + } + } + }, + { + type: 'form.replied', + data: { id: 'form-api', sessionID: 'ses_root', answer: { q0: 'Yes' } } + } + ]) + await drained + expect(names).toEqual(['AskUserQuestion', 'SessionIdle']) + await cleanup?.() + }) + + // The reload lands while a question is pending, so this instance never saw its form. + it('posts nothing while a sibling tool finishes under a question pending since before a reload', async () => { + const { names, cleanup, drained } = await runSetupBridge([ + { type: 'session.tool.progress', data: { sessionID: 'ses_root', id: 'tool-shell' } }, + { type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-shell' } } + ]) + await drained + expect(names).toEqual([]) + await cleanup?.() + }) + it('keeps a live question blocker while an ignored form is raised and resolved', async () => { const { names, cleanup } = await runSetupBridge([ { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, diff --git a/src/main/opencode/status-plugin-lifecycle-source.ts b/src/main/opencode/status-plugin-lifecycle-source.ts index 305ba8fa973f..95714e8a6e2e 100644 --- a/src/main/opencode/status-plugin-lifecycle-source.ts +++ b/src/main/opencode/status-plugin-lifecycle-source.ts @@ -97,6 +97,11 @@ export function getStatusPluginLifecycleSource(): string[] { ' factoryID,', ' sourceSessionID: sessionID,', ' });', + " // Why: a factory that joined mid-turn missed this root's Busy, so without this", + " // the reply would show Done while the turn runs on. The turn's own Idle retires it.", + ' if (childState === false && isToolRaisedBlocker(properties) && !busyRootOwnerBySessionID.has(sessionID)) {', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' }', ' await publishAggregateStatus(factoryID, rootSessionID || sessionID);', ' return;', ' }', diff --git a/src/main/opencode/status-plugin-ownership-source.ts b/src/main/opencode/status-plugin-ownership-source.ts index da7470c0984a..634e097f041c 100644 --- a/src/main/opencode/status-plugin-ownership-source.ts +++ b/src/main/opencode/status-plugin-ownership-source.ts @@ -121,6 +121,13 @@ export function getStatusPluginOwnershipSource(): string[] { ' return true;', '}', '', + '// Why: OpenCode runs tools only inside a turn, so a tool-raised blocker (1.x', + '// 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");', + '}', + '', 'function latestBusyOwner() {', ' let latest = null;', ' // Why: a running descendant is reported under its ROOT id, and ranks', From 7f02790cf2524459744c43c5af9eef44aab21d91 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:31:59 -0700 Subject: [PATCH 3/4] test(opencode): pin that a tool-raised blocker never takes over another 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. --- .../hook-plugin-lifecycle-delivery.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts index 83a71533f1c3..3ad7921f8960 100644 --- a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -823,4 +823,29 @@ describe('OpenCode plugin lifecycle delivery', () => { await handler({ event: status('idle') }) expect(names().at(-1)).toBe('SessionIdle') }) + + // Why: a tool-raised blocker records Busy only when none is known; taking over the + // owner would let the second factory's disposal erase Busy for a turn still running. + it('keeps the running factory Busy when another factory saw a tool-raised blocker', async () => { + const first = await loadHooks() + const second = await loadHooks() + + await first.event({ event: status('busy') }) + await second.event({ + event: { + type: 'permission.asked', + properties: { + id: 'permission-1', + sessionID: 'root', + permission: 'bash', + patterns: ['ls'], + tool: { messageID: 'message-assistant', callID: 'call-1' } + } + } + }) + await second.dispose?.() + + expect(names()).toEqual(['SessionBusy', 'PermissionRequest', 'SessionBusy']) + await first.dispose?.() + }) }) From 7e0b915b3a3b00a4079e2c56d63ada815f92afff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:17:08 -0700 Subject: [PATCH 4/4] test(opencode): regenerate plugin digests after rebasing onto #23700 --- src/main/opencode/hook-service.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index c8941d833137..b6acc9e5cdab 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -77,11 +77,11 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - '655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7' + '38f94d7a2451969056dd0790cf5e3c7362cb8b78b94f2a3a2aee8868540107f1' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e') + ).toBe('dee76cf311a9f9a4ab51745277af8c9da38be0a830f84a5949460b38368c0160') }) it('filters child sessions via parentID lookup before forwarding events', () => {