mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user