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" ||',