feat(opencode): map question.asked to waiting state (#1406)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-04 16:09:34 -07:00 committed by GitHub
parent 9b3b2a5486
commit 85b125f6bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 49 additions and 4 deletions

View File

@ -660,6 +660,22 @@ describe('OpenCode hook normalization', () => {
expect(result?.payload.state).toBe('waiting')
})
it('AskUserQuestion maps to waiting', () => {
// Why: OpenCode emits `question.asked` when the agent uses an ask-the-user
// tool (distinct from `permission.asked`, which blocks on tool approval).
// Both leave the agent idle-but-waiting on a human, so both must render
// the same red "needs attention" indicator. Without this mapping the pane
// silently stays in `working` and the user has no visual cue that the
// agent is waiting on them.
const result = _internals.normalizeHookPayload(
'opencode',
buildBody({ hook_event_name: 'AskUserQuestion' }),
'production'
)
expect(result?.payload.state).toBe('waiting')
expect(result?.payload.agentType).toBe('opencode')
})
it('unknown event name returns null', () => {
const result = _internals.normalizeHookPayload(
'opencode',

View File

@ -844,11 +844,13 @@ function normalizeCodexEvent(
// Why: OpenCode has no declarative hook surface — it exposes in-process plugin
// events (session.status busy/idle, session.idle, permission.asked,
// message.updated, message.part.updated). The bundled plugin (see
// opencode/hook-service) pre-maps those to our stable hook_event_name
// question.asked, message.updated, message.part.updated). The bundled plugin
// (see opencode/hook-service) pre-maps those to our stable hook_event_name
// vocabulary before POSTing so this normalizer can share the same switch
// shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle =
// turn finished, PermissionRequest = blocked on user approval, MessagePart =
// turn finished, PermissionRequest = blocked on user approval, AskUserQuestion =
// blocked on user reply to an ask-the-user tool (both map to `waiting` so the
// sidebar renders the red "needs attention" indicator), MessagePart =
// incremental text from user prompt or assistant reply (stays in `working`
// because streaming chunks must not flip the row to done mid-turn).
function normalizeOpenCodeEvent(
@ -862,7 +864,7 @@ function normalizeOpenCodeEvent(
? 'working'
: eventName === 'SessionIdle'
? 'done'
: eventName === 'PermissionRequest'
: eventName === 'PermissionRequest' || eventName === 'AskUserQuestion'
? 'waiting'
: null

View File

@ -80,6 +80,21 @@ describe('OpenCode hook plugin source', () => {
expect(source).toContain('cachedEndpointValues = null;')
})
it('forwards question.asked as AskUserQuestion so the pane flips to waiting', () => {
// Why: OpenCode exposes two separate plugin events for human-in-the-loop
// moments — `permission.asked` (blocks on tool approval) and
// `question.asked` (the agent called an ask-the-user tool). The plugin
// must forward both so the server-side normalizer can map each to
// `waiting` and render the red indicator. Dropping `question.asked`
// leaves the pane stuck in `working` while the agent is actually idle,
// waiting on a human reply — exactly the bug three other OpenCode
// integrations (cmux, t3code, open-vibe-island) all handle.
const source = _internals.getOpenCodePluginSource()
expect(source).toContain('if (event.type === "question.asked")')
expect(source).toContain('await post("AskUserQuestion", event.properties || {});')
})
it('guards endpoint-file parse warnings with a process-lifetime latch', () => {
// Why: ENOENT is the normal pre-install case and must stay silent, but a
// malformed/unreadable file (EACCES, EIO, parse error) would otherwise

View File

@ -259,6 +259,18 @@ function getOpenCodePluginSource(): string {
' return;',
' }',
'',
' if (event.type === "question.asked") {',
' // Why: question.asked fires when OpenCode uses an ask-the-user tool',
' // (distinct from permission.asked, which blocks on tool approval).',
' // The agent is idle-but-waiting on a human reply, not running, so we',
' // must flip the pane to the same red "needs attention" state used for',
' // permission requests. Like permission.asked, do not touch lastStatus',
' // so the next SessionBusy/SessionIdle after the user answers still',
' // fires and restores the normal working/done flow.',
' await post("AskUserQuestion", event.properties || {});',
' return;',
' }',
'',
' if (event.type === "message.updated") {',
' // Why: role is already cached above the isChildSession await so the',
' // back-to-back message.part.updated for the same messageID is not',