From 8ddad49ae582d05e007e98f0218e45dd2c12956d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:45:50 -0700 Subject: [PATCH] fix(opencode): retire a subagent's blocker when the root turn ends (#22604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A descendant's question is stored under its own sourceSessionID but displayed on the root session it rolls up to. clearAttentionForSession matches only the source, so the root's own idle could never retire it: the blocker outlived the turn that raised it and pinned the pane on "needs input" with nothing left to answer. The lead agent truthfully reports no pending question, because the blocker belongs to a child it does not know about (#22371). Add clearAttentionForTurnEnd, matching the source id or the rolled-up root, and use it only from the idle branch. Scoped to turn end deliberately: a live blocker must still outrank the root going Busy, since a subagent can be waiting on the user while the root keeps working — widening the existing clear broke exactly that test, which is what surfaced the right scope. Shared with the OpenCode family, so mimo-code gets the same fix; both pinned plugin digests move. New test fails without the change: after the root turn ends the last post is still AskUserQuestion instead of SessionIdle. --- ...k-plugin-opencode2-session-lineage.test.ts | 22 +++++++++++++++++++ src/main/opencode/hook-service.test.ts | 4 ++-- .../status-plugin-lifecycle-source.ts | 2 +- .../status-plugin-ownership-source.ts | 20 +++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts b/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts index 36d2119c56a..2e3c4c51d1e 100644 --- a/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts @@ -192,6 +192,28 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage' await cleanup?.() }) + // Why this case: it is the shape reported in #22371 — a subagent asks, the lead + // agent truthfully reports no pending question, and the pane stays "needs input" + // with nothing able to clear it. With lineage unresolved the blocker keys to the + // child's own session id, so the root's own idle never matches it. + it('retires a child raised blocker when the root turn goes idle', async () => { + const { posts, cleanup } = await runSetupBridge([ + created(ROOT), + created(CHILD), + { type: 'session.execution.started', data: { sessionID: ROOT } }, + { type: 'form.created', data: { form: questionForm('form-child', CHILD) } }, + { type: 'session.execution.succeeded', data: { sessionID: ROOT } } + ]) + await vi.waitFor(() => { + expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion') + }) + await vi.waitFor(() => { + expect(posts.at(-1)?.hook_event_name).toBe('SessionIdle') + }) + expect(posts.at(-1)).toEqual(expect.objectContaining({ sessionID: ROOT })) + await cleanup?.() + }) + it('still blocks the pane on the root session own question', async () => { const { posts, cleanup } = await runSetupBridge([ created(ROOT), diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 7a42a5c7573..920c0b5e392 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -93,11 +93,11 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - '1854bd3dbe80c8793041f3515a83ff71dc75739d8a94a33d168ff0cdb2c2e888' + 'f2c469ff2d360ed94955d715705b9d4dd633dd334887906fbb534c1925ec81c9' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('4c9c27af603a9e85e3e33a30c439d9dfb6785936dea0be76fdd64cf7dc2f7174') + ).toBe('2267e2ab6e854e71c9bae12afed97e464f93b2b14f3e25dca133ca6666c98752') }) 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 7f6bd094864..305ba8fa973 100644 --- a/src/main/opencode/status-plugin-lifecycle-source.ts +++ b/src/main/opencode/status-plugin-lifecycle-source.ts @@ -114,7 +114,7 @@ export function getStatusPluginLifecycleSource(): string[] { ' // Why: flush the coalesced final reply snapshot before the idle', ' // transition so the done-state preview shows the completed message.', ' await flushPendingAssistantPart(true);', - ' clearAttentionForSession(sessionID, factoryID);', + ' clearAttentionForTurnEnd(sessionID, factoryID);', ' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {', ' busyRootOwnerBySessionID.delete(sessionID);', ' }', diff --git a/src/main/opencode/status-plugin-ownership-source.ts b/src/main/opencode/status-plugin-ownership-source.ts index 056e12fa0c6..da7470c0984 100644 --- a/src/main/opencode/status-plugin-ownership-source.ts +++ b/src/main/opencode/status-plugin-ownership-source.ts @@ -27,6 +27,26 @@ export function getStatusPluginOwnershipSource(): string[] { ' return rootSessionID;', '}', '', + '// Why a second, wider clear: a descendant blocker is stored under its own', + '// sourceSessionID but displayed on the root it rolled up to, so the root turn', + '// ending can never retire it through the source match alone — a subagent question', + '// outlives the turn that raised it and pins the pane (#22371). Scoped to turn end', + '// on purpose: a live blocker must still outrank the root going Busy, because a', + '// subagent can be waiting on the user while the root keeps working.', + 'function clearAttentionForTurnEnd(sessionID, factoryID) {', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' const ownsAttention =', + ' attention.sourceSessionID === sessionID ||', + ' attention.properties?.sessionID === sessionID;', + ' if (ownsAttention && attention.factoryID === factoryID) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', 'function clearQuestionForToolPart(part, sessionID, factoryID) {', ' if (', ' part?.type !== "tool" ||',