fix(runtime): report an open agent question as blocked to tui-idle waits (#24533)

OpenCode's question tool (and Pi/OMP ask tools and custom modals) put the
hook row in a waiting state but paint no dialog wording the blocked-text
layer knows, so the hook lane read the wait as pending and the tui-idle
wait timed out with no blocked reason. For agents whose hooks are
authoritative, a wait the hook reports with no recognised dialog text now
blocks with the existing agent-interactive-prompt reason, unless input
reached the pane after the row (it may have answered the question before
the next hook arrived).
This commit is contained in:
Jinwoo Hong
2026-10-02 14:57:57 -04:00
committed by GitHub
parent de8bffe240
commit caa4228e0f
3 changed files with 58 additions and 4 deletions
@@ -27,6 +27,15 @@ const OPENCODE_PERMISSION_DIALOG = [
' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm',
''
].join('\r\n')
// OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows.
const OPENCODE_QUESTION = [
' ┃ Which color do you prefer?',
' ┃ 1. Red',
' ┃ 2. Blue',
' ┃ 3. Type your own answer',
' ┃ ↑↓ select enter submit esc dismiss',
''
].join('\r\n')
// Shorter than the 2 s poll, so only the synchronous verdict can settle a wait.
const WAIT_MS = 150
@@ -194,4 +203,21 @@ describe('tui-idle hook lane through the runtime', () => {
})
).toBe('ready')
})
it('blocks on an open question the hook reports, and settles once it is answered', async () => {
const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION }
expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout')
expect(
await waitOutcome({
...options,
rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })]
})
).toBe('blocked:agent-interactive-prompt')
expect(
await waitOutcome({
...options,
rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })]
})
).toBe('ready')
})
})
@@ -160,6 +160,24 @@ describe('readTuiIdleHookTurn', () => {
).toEqual(WORKING)
})
it('blocks on a wait the hook reports with no dialog text the arbiter knows (a question)', () => {
const receivedAt = Date.now() - 1000
for (const state of ['waiting', 'blocked'] as const) {
expect(readTuiIdleHookTurn({ ...base, hookRows: [row({ receivedAt, state })] })).toEqual({
state: 'permission',
blockedReason: 'agent-interactive-prompt'
})
}
// Input since the wait opened may have answered it before the next hook arrived.
expect(
readTuiIdleHookTurn({
...base,
hookRows: [row({ receivedAt, state: 'waiting' })],
lastInputAt: receivedAt + 1
})
).toEqual({ state: 'permission', blockedReason: null })
})
it('takes the newest joined row', () => {
const now = Date.now()
expect(
+14 -4
View File
@@ -8,7 +8,8 @@ type HookTurnState = 'done' | 'working' | 'permission'
/**
* The main agent's turn as the hook server's store last saw it for one pane, and the permission
* arbiter's verdict on the pane's blocked text with that turn as its explicit status.
* arbiter's verdict on the pane's blocked text with that turn as its explicit status (or, for a
* wait the hook reports with no text the arbiter knows, a generic interactive prompt).
*/
export type TuiIdleHookTurn = {
state: HookTurnState
@@ -65,10 +66,19 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn
return null
}
const state = hookLeadTurnState(row)
if (state === null || (state === 'done' && row.receivedAt < (read.lastInputAt ?? -1))) {
const predatesInput = row.receivedAt < (read.lastInputAt ?? -1)
if (state === null || (state === 'done' && predatesInput)) {
return null
}
return { state, blockedReason: read.resolveBlockedText(state, row) }
const blockedReason = read.resolveBlockedText(state, row)
// Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows,
// and these hooks report its answer. Input since may have answered it before the hook arrived.
return {
state,
blockedReason:
blockedReason ??
(state === 'permission' && !predatesInput ? 'agent-interactive-prompt' : null)
}
}
/** The tui-idle verdicts the hook lane can reach (a subset of `TuiIdleVerdict`). */
@@ -104,7 +114,7 @@ export function evaluateHookTurn(
case 'working':
return { kind: 'working' }
case 'permission':
// Why pending: the arbiter saw no prompt in the tail, so the poll's screen read decides.
// Why pending: input after the wait opened may have answered it; the next hook decides.
return { kind: 'pending', quietForeground: 'closed' }
}
}