From 85b125f6bf40cd035cf48f8b5d2200ec28fc191b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 4 May 2026 16:09:34 -0700 Subject: [PATCH] feat(opencode): map question.asked to waiting state (#1406) Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 16 ++++++++++++++++ src/main/agent-hooks/server.ts | 10 ++++++---- src/main/opencode/hook-service.test.ts | 15 +++++++++++++++ src/main/opencode/hook-service.ts | 12 ++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index d12c73754..ba321dc17 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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', diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index aee9c83e7..92db2823b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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 diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index b1ed29622..21fb9229a 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -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 diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 5990fa667..b789b2146 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -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',