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?.() + }) }) diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index e9e38334d3e6..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 { @@ -453,6 +462,170 @@ 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?.() + }) + + 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/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', () => { 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', 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 } }); }