diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index 312bd3b8e67..1e18c9096a1 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -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') + }) }) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index 87683f266cf..e9fe3a88587 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -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( diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index 04bfaa1bbdd..492419afb82 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -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' } } }