fix(opencode): retire a subagent's blocker when the root turn ends (#22604)

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.
This commit is contained in:
Neil
2026-09-23 23:45:50 -07:00
committed by GitHub
parent b2940a18ff
commit 8ddad49ae5
4 changed files with 45 additions and 3 deletions
@@ -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),