From b0ae7d18a0dd5258acc61007e4f5a154e31d6e91 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:08:01 -0700 Subject: [PATCH] fix(opencode2): resolve subagent session lineage so child work stops taking over the pane (#22444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode 2's plugin adapter unwraps a single-property `{ data }` success schema, so `ctx.session.get` resolves to the bare session record. The shared lineage lookup only accepts `result?.data?.id === sessionID`, and OpenCode 2 has no `session.list` fallback, so `resolveRootSessionID` returned null for every session and `childState` was permanently null. With unknown lineage `canFailOpen` is true for attention events, so a subagent's `permission.asked`/`question.asked` fell through and pinned an un-evictable blocker keyed to the child's own session id — publishing a subagent as if it were a root. Observed in hook posts: SessionBusy for a child session id whose `session_v2` row carries a parent. Envelope the result in the OC2 client shim so the shared lineage module works unchanged; OpenCode 1 already receives enveloped results and is untouched. Also adds `opencode2` to the double-Escape interrupt list, extracted into one shared helper so the server inference and renderer gate cannot drift. A single Escape was inferring an interrupt, and Escape is how the Subagents dock closes. 7 of 11 new lineage tests fail without the shim. --- .../opencode2-session-lineage/README.md | 72 ++++++ .../hook-posts-after.jsonl | 5 + .../hook-posts-before.jsonl | 7 + .../server-interrupt-inference-guards.test.ts | 4 +- .../server/server-status-inference.ts | 7 +- ...k-plugin-opencode2-session-lineage.test.ts | 239 ++++++++++++++++++ src/main/opencode/hook-service.test.ts | 2 +- .../opencode2/status-plugin-setup-source.ts | 7 +- .../agent-interrupt-inference.test.ts | 4 +- .../agent-interrupt-inference.ts | 12 +- src/shared/agent-interrupt-intent.ts | 22 ++ 11 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 docs/bug-reproductions/opencode2-session-lineage/README.md create mode 100644 docs/bug-reproductions/opencode2-session-lineage/hook-posts-after.jsonl create mode 100644 docs/bug-reproductions/opencode2-session-lineage/hook-posts-before.jsonl create mode 100644 src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts diff --git a/docs/bug-reproductions/opencode2-session-lineage/README.md b/docs/bug-reproductions/opencode2-session-lineage/README.md new file mode 100644 index 00000000000..eda8aafcd2d --- /dev/null +++ b/docs/bug-reproductions/opencode2-session-lineage/README.md @@ -0,0 +1,72 @@ +# Orca could not resolve OpenCode 2 session lineage (#22371 follow-up) + +Orca's OpenCode plugin resolves a session's ancestry so a subagent's work rolls up to the pane +without taking it over. Against OpenCode 2 that resolution returned `null` for every session, so +every child session was published as if it were a root — and a subagent's question minted an +un-evictable "needs input" blocker the lead agent had never asked for. + +Captured against the shipped `opencode v2.0.12` binary on macOS: Orca's real generated plugin in +`~/.config/opencode/plugins/orca-opencode2-status.js`, real `ORCA_PANE_KEY` / hook-token env, a +logging hook server on a loopback port, and `opencode2 --standalone` driven in a real PTY. + +## The SDK shape Orca was given + +`src/main/opencode2/status-plugin-setup-source.ts` handed the shared lineage walk a client built +from the plugin `setup()` context. Probing that context live inside the running plugin: + +| call | result | +| -------------------------------------------- | --------------------------------------------------- | +| `ctx.session.get({ sessionID }, { signal })` | the session record **directly** — no `data` wrapper | +| `ctx.session.get({ path: { id } })` | throws `Missing key at ["sessionID"]` | +| `ctx.session.list` | `undefined` | +| `ctx.session.get.length` | `1` | + +`lookupSessionList` only accepts a result when `result?.data?.id === sessionID`, so every lookup +was discarded, and the `session.list` fallback it then tried does not exist. `resolveRootSessionID` +therefore returned `null` for every session and `childState` stayed permanently `null`. + +## The capture + +One prompt, run twice, telling the agent to delegate `echo hello-from-subagent` to exactly one +subagent. `hook-posts-before.jsonl` and `hook-posts-after.jsonl` are the verbatim hook POSTs. + +Before — the subagent publishes as a root: + +``` +{"hook_event_name": "SessionStart", "sessionID": "ses_f33016571ffenJDTLmPcLATavv"} +{"hook_event_name": "SessionBusy", "sessionID": "ses_f33016571ffenJDTLmPcLATavv"} +{"hook_event_name": "SessionBusy", "sessionID": "ses_f330152d1ffer9a1SCLERqCLv8"} <- the child +{"hook_event_name": "SessionBusy", "sessionID": "ses_f33016571ffenJDTLmPcLATavv"} +``` + +`ses_f330152d1ffer9a1SCLERqCLv8` never received a `SessionStart` because the plugin skips +`session.created` events that carry a `parentID`. OpenCode's own `session_v2` row confirms it: +`parent_id = ses_f33016571ffenJDTLmPcLATavv`. + +After — the child is recognised and rolls up; only the root's id is ever published: + +``` +{"hook_event_name": "SessionStart", "sessionID": "ses_f32f26424ffe3xdiePHBUnsdAY"} +{"hook_event_name": "SessionBusy", "sessionID": "ses_f32f26424ffe3xdiePHBUnsdAY"} +{"hook_event_name": "SessionIdle", "sessionID": "ses_f32f26424ffe3xdiePHBUnsdAY"} +``` + +The subagent still ran in the "after" capture (`ses_f32f2519dffeNSA1jG2Z8JVJyv`, +`parent_id = ses_f32f26424ffe3xdiePHBUnsdAY`); its id simply never appears in a POST. + +## Reproduce + +```sh +# Log POSTs on a free loopback port, then: +ORCA_PANE_KEY=tab:leaf ORCA_OPENCODE_AGENT=opencode2 \ +ORCA_AGENT_HOOK_PORT= ORCA_AGENT_HOOK_TOKEN= \ + opencode2 --standalone --prompt '' +``` + +Unset `ORCA_AGENT_HOOK_ENDPOINT` so the plugin reads the port/token from env instead of a live +Orca's endpoint file. + +One trap: OpenCode 2 loads both `orca-opencode-status.js` and `orca-opencode2-status.js` from +`~/.config/opencode/plugins/`, and both declare the same plugin `id` (`orca-opencode-status`). +Only one survives, and if it is the v1 file its agent gate silently returns no hooks, so nothing +posts at all. Move the v1 file aside while reproducing. diff --git a/docs/bug-reproductions/opencode2-session-lineage/hook-posts-after.jsonl b/docs/bug-reproductions/opencode2-session-lineage/hook-posts-after.jsonl new file mode 100644 index 00000000000..2b07af18ef8 --- /dev/null +++ b/docs/bug-reproductions/opencode2-session-lineage/hook-posts-after.jsonl @@ -0,0 +1,5 @@ +{"t":1790146616295,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionStart","sessionID":"ses_f32f26424ffe3xdiePHBUnsdAY"}} +{"t":1790146616310,"path":"/hook/opencode2","payload":{"hook_event_name":"MessagePart","role":"user","text":"Use your task tool to spawn exactly one subagent. Give that subagent this instruction: run the shell command 'echo hello-from-subagent' and report its output. Do not run the command yourself; you must delegate it. When the subagent returns, reply with just its output.","messageID":"msg_0cd0d9bdc001wjukqO5OqJP2IX","sessionID":"ses_f32f26424ffe3xdiePHBUnsdAY"}} +{"t":1790146616329,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionBusy","sessionID":"ses_f32f26424ffe3xdiePHBUnsdAY"}} +{"t":1790146624607,"path":"/hook/opencode2","payload":{"hook_event_name":"MessagePart","role":"assistant","text":"hello-from-subagent","messageID":"msg_0cd0db795001FsTcwuFUDpJTrI","sessionID":"ses_f32f26424ffe3xdiePHBUnsdAY"}} +{"t":1790146624629,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionIdle","sessionID":"ses_f32f26424ffe3xdiePHBUnsdAY"}} diff --git a/docs/bug-reproductions/opencode2-session-lineage/hook-posts-before.jsonl b/docs/bug-reproductions/opencode2-session-lineage/hook-posts-before.jsonl new file mode 100644 index 00000000000..b3e71b46502 --- /dev/null +++ b/docs/bug-reproductions/opencode2-session-lineage/hook-posts-before.jsonl @@ -0,0 +1,7 @@ +{"t":1790145632961,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionStart","sessionID":"ses_f33016571ffenJDTLmPcLATavv"}} +{"t":1790145632978,"path":"/hook/opencode2","payload":{"hook_event_name":"MessagePart","role":"user","text":"Use your task tool to spawn exactly one subagent. Give that subagent this instruction: run the shell command 'echo hello-from-subagent' and report its output. Do not run the command yourself; you must delegate it. When the subagent returns, reply with just its output.","messageID":"msg_0ccfe9a8f001QOK3S43JBuKODf","sessionID":"ses_f33016571ffenJDTLmPcLATavv"}} +{"t":1790145633014,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionBusy","sessionID":"ses_f33016571ffenJDTLmPcLATavv"}} +{"t":1790145637796,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionBusy","sessionID":"ses_f330152d1ffer9a1SCLERqCLv8"}} +{"t":1790145640105,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionBusy","sessionID":"ses_f33016571ffenJDTLmPcLATavv"}} +{"t":1790145641557,"path":"/hook/opencode2","payload":{"hook_event_name":"MessagePart","role":"assistant","text":"hello-from-subagent","messageID":"msg_0ccfeb695001yjXsR63opaMap9","sessionID":"ses_f33016571ffenJDTLmPcLATavv"}} +{"t":1790145641574,"path":"/hook/opencode2","payload":{"hook_event_name":"SessionIdle","sessionID":"ses_f33016571ffenJDTLmPcLATavv"}} diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index fcdec35c8e6..9033f182bed 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -525,7 +525,7 @@ describe('AgentHookServer listener replay', () => { } }) - it.each(['opencode', 'copilot'] as const)( + it.each(['opencode', 'opencode2', 'copilot'] as const)( 'rejects single plain Escape inference for %s', (agentType) => { vi.useFakeTimers() @@ -567,7 +567,7 @@ describe('AgentHookServer listener replay', () => { } ) - it.each(['opencode', 'copilot'] as const)( + it.each(['opencode', 'opencode2', 'copilot'] as const)( 'accepts double plain Escape inference for %s', (agentType) => { vi.useFakeTimers() diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 02b61595832..65459e8953a 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -6,6 +6,7 @@ import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listene import { isAgentInterruptInputIntent, isNavigationEscapeIntent, + requiresDoubleEscapeInterrupt, type AgentInterruptInferenceRequest } from '../../../shared/agent-interrupt-intent' import { @@ -45,11 +46,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO return false } // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. - if ( - (agentType === 'opencode' || agentType === 'copilot') && - request.intent === 'plain-escape' && - request.inputCount !== 2 - ) { + if (requiresDoubleEscapeInterrupt(agentType, request.intent) && request.inputCount !== 2) { return false } const dismissesClaudeQuestion = diff --git a/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts b/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts new file mode 100644 index 00000000000..36d2119c56a --- /dev/null +++ b/src/main/opencode/hook-plugin-opencode2-session-lineage.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) + +import { _internals } from './hook-service' + +// Why: live probe of opencode v2.0.12 — session.get resolves the session record +// itself (no { data } envelope), rejects the legacy { path: { id } } argument, +// and session.list does not exist. Anything less faithful hides the defect. +type LiveSession = { id: string; parentID?: string } + +const ROOT = 'ses_root' +const CHILD = 'ses_child' +const LIVE_SESSIONS: Record = { + [ROOT]: { id: ROOT }, + [CHILD]: { id: CHILD, parentID: ROOT } +} + +type Post = { hook_event_name: string; sessionID?: string } + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_OPENCODE_AGENT', + 'ORCA_AGENT_HOOK_ENDPOINT', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN' +] as const + +describe.each(['opencode', 'opencode2'] as const)('%s plugin OpenCode 2 lineage', (agent) => { + let tempDir: string + let savedFetch: typeof globalThis.fetch + let savedEnv: Record + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode2-lineage-')) + savedFetch = globalThis.fetch + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_OPENCODE_AGENT = agent + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + process.env.ORCA_AGENT_HOOK_PORT = '59999' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + }) + + afterEach(() => { + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function loadPlugin(): Promise<{ + default?: { + server?: (ctx: unknown) => Promise<{ event: (input: { event: unknown }) => Promise }> + setup?: (ctx: unknown) => Promise<() => Promise> + } + }> { + const source = + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + const pluginPath = join(tempDir, `plugin-${Math.random().toString(36).slice(2)}.mjs`) + writeFileSync(pluginPath, source) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the local fixture module is written from the generated plugin source above. + return (await import(pathToFileURL(pluginPath).href)) as Awaited> + } + + async function runSetupBridge( + events: { type: string; data: Record }[] + ): Promise<{ + posts: Post[] + lookups: string[] + cleanup?: () => Promise + }> { + const posts: Post[] = [] + const lookups: string[] = [] + globalThis.fetch = vi.fn(async (_input, init) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the plugin always posts a JSON body carrying `payload`. + const body = JSON.parse(String(init?.body)) as { payload: Post } + posts.push(body.payload) + return new Response('{}', { status: 200 }) + }) + const module = await loadPlugin() + const cleanup = await module.default?.setup?.({ + session: { + // Why: one declared parameter, exactly like the shipped SDK method. + get: async ({ sessionID }: { sessionID?: string }) => { + if (!sessionID) { + throw new Error('Missing key at ["sessionID"]') + } + lookups.push(sessionID) + const session = LIVE_SESSIONS[sessionID] + if (!session) { + throw new Error('unknown session') + } + return session + }, + hook: async () => ({ dispose: vi.fn() }) + }, + event: { + subscribe: async function* () { + for (const event of events) { + yield event + } + } + } + }) + return { posts, lookups, cleanup } + } + + function created(sessionID: string): { type: string; data: Record } { + const { parentID } = LIVE_SESSIONS[sessionID] + return { type: 'session.created', data: { sessionID, ...(parentID ? { parentID } : {}) } } + } + + function questionForm(id: string, sessionID: string): Record { + return { + id, + sessionID, + title: 'Questions', + metadata: { kind: 'question', tool: { messageID: 'msg-0', id: 'tool-0' } }, + fields: [{ key: 'q0', title: 'Proceed?', type: 'string', options: [] }] + } + } + + it('resolves child lineage from the flat OpenCode 2 session shape', async () => { + const { posts, lookups, cleanup } = await runSetupBridge([ + created(ROOT), + created(CHILD), + { type: 'session.execution.started', data: { sessionID: ROOT } }, + { type: 'session.execution.started', data: { sessionID: CHILD } } + ]) + await vi.waitFor(() => { + expect(posts.filter((post) => post.hook_event_name === 'SessionBusy').length).toBeGreaterThan( + 0 + ) + }) + // The child rolls up under its root; it never publishes as a root itself. + expect(posts.map((post) => post.sessionID)).not.toContain(CHILD) + expect(lookups).toContain(CHILD) + await cleanup?.() + }) + + it('attributes a child question to the root instead of minting a child blocker', async () => { + const { posts, cleanup } = await runSetupBridge([ + created(ROOT), + created(CHILD), + { type: 'session.execution.started', data: { sessionID: ROOT } }, + { type: 'form.created', data: { form: questionForm('form-child', CHILD) } } + ]) + await vi.waitFor(() => { + expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion') + }) + expect(posts.filter((post) => post.hook_event_name === 'AskUserQuestion')).toEqual([ + expect.objectContaining({ sessionID: ROOT }) + ]) + await cleanup?.() + }) + + it('retires a child raised blocker when that child goes idle', async () => { + const { posts, cleanup } = await runSetupBridge([ + created(ROOT), + created(CHILD), + { type: 'session.execution.started', data: { sessionID: ROOT } }, + { type: 'form.created', data: { form: questionForm('form-child', CHILD) } }, + { type: 'session.execution.succeeded', data: { sessionID: CHILD } } + ]) + await vi.waitFor(() => { + expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion') + }) + // The root turn is still running, so the pane goes back to busy rather than staying blocked. + await vi.waitFor(() => { + expect(posts.at(-1)?.hook_event_name).toBe('SessionBusy') + }) + expect(posts.at(-1)).toEqual(expect.objectContaining({ sessionID: ROOT })) + await cleanup?.() + }) + + it('still blocks the pane on the root session own question', async () => { + const { posts, cleanup } = await runSetupBridge([ + created(ROOT), + { type: 'session.execution.started', data: { sessionID: ROOT } }, + { type: 'form.created', data: { form: questionForm('form-root', ROOT) } } + ]) + await vi.waitFor(() => { + expect(posts.map((post) => post.hook_event_name)).toContain('AskUserQuestion') + }) + expect(posts.at(-1)).toEqual(expect.objectContaining({ sessionID: ROOT })) + await cleanup?.() + }) + + it('caches resolved ancestry instead of re-probing per event', async () => { + const { posts, lookups, cleanup } = await runSetupBridge([ + created(ROOT), + created(CHILD), + { type: 'session.execution.started', data: { sessionID: CHILD } }, + { type: 'session.execution.succeeded', data: { sessionID: CHILD } }, + { type: 'session.execution.started', data: { sessionID: CHILD } }, + { type: 'session.execution.succeeded', data: { sessionID: CHILD } } + ]) + await vi.waitFor(() => { + expect(posts.length).toBeGreaterThan(0) + }) + // The root arrived via session.created, so only the child needs one lookup. + expect(lookups).toEqual([CHILD]) + await cleanup?.() + }) +}) + +describe('OpenCode 2 session client shim', () => { + it('keeps the enveloped SDK result untouched for the OpenCode 1 server path', () => { + const source = _internals.getOpenCodePluginSource() + + // The shim only wraps a bare session record; server() still hands the raw SDK client through. + expect(source).toContain( + 'return result && typeof result.id === "string" ? { data: result } : result;' + ) + expect(source).toContain( + 'handleLifecycleEvent(client, normalizeNextLifecycleEvent(event), factoryID)' + ) + expect(source).toContain('const client = _ctx?.client;') + }) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 277fcccf52b..7a42a5c7573 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -93,7 +93,7 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - '609ae8b1fdf648e8023a1a55f2fb2038a44ca3a0d561021d2204814d48d1bb0b' + '1854bd3dbe80c8793041f3515a83ff71dc75739d8a94a33d168ff0cdb2c2e888' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index b2576e7d294..c4bf8d0ea72 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -17,7 +17,12 @@ async function setupOpenCode2Status(ctx) { try { if (!ctx || typeof ctx.session?.hook !== "function" || typeof ctx.event?.subscribe !== "function") return noop; const controller = new AbortController(); - const client = { session: { get: (input, options) => ctx.session.get(input, options) } }; + // Why the envelope: OpenCode 2's plugin adapter unwraps a single-property + // { data } success schema, so ctx.session.get resolves to the bare record — + // but the shared lineage lookup only accepts result?.data?.id === sessionID. + // Without it, resolveRootSessionID returns null for every session and a + // subagent's work publishes as if it were the root's. + const client = { session: { get: async (input, options) => { const result = await ctx.session.get(input, options); return result && typeof result.id === "string" ? { data: result } : result; } } }; const hooks = await OrcaOpenCodeStatusPlugin({ client }); if (!hooks || typeof hooks.event !== "function") return noop; const promptRegistration = await ctx.session.hook("prompt", async (properties) => { diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 0a5efcb4b43..920b9f55405 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -216,7 +216,7 @@ describe('agent interrupt inference', () => { entry = undefined }) - it.each(['opencode', 'copilot'] as const)( + it.each(['opencode', 'opencode2', 'copilot'] as const)( 'infers immediately on double Escape for %s', (agentType) => { vi.useFakeTimers() @@ -269,7 +269,7 @@ describe('agent interrupt inference', () => { entry = undefined }) - it.each(['opencode', 'copilot'] as const)( + it.each(['opencode', 'opencode2', 'copilot'] as const)( 'does not count a %s Escape after the double-Escape window expires', (agentType) => { vi.useFakeTimers() diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts index 38668b36a52..0b39565b35c 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts @@ -5,6 +5,7 @@ import { import { AGENT_INTERRUPT_SETTLE_MS, isNavigationEscapeIntent, + requiresDoubleEscapeInterrupt, type AgentInterruptInferenceRequest, type AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent' @@ -39,18 +40,11 @@ type CapturedInterruptBaseline = { inputCount?: number } -function requiresDoubleEscapeForAgent( - agentType: AgentStatusEntry['agentType'], - intent: AgentInterruptInputIntent -): boolean { - return (agentType === 'opencode' || agentType === 'copilot') && intent === 'plain-escape' -} - function shouldFlushInterruptImmediately( baseline: Pick ): boolean { return ( - requiresDoubleEscapeForAgent(baseline.agentType, baseline.intent) || + requiresDoubleEscapeInterrupt(baseline.agentType, baseline.intent) || baseline.agentType === 'gemini' || (baseline.agentType === 'codex' && baseline.intent === 'plain-escape') ) @@ -254,7 +248,7 @@ export function createAgentInterruptInference({ if (isIgnorableNavigationEscape(baseline.agentType, intent, entry.state)) { return } - if (requiresDoubleEscapeForAgent(baseline.agentType, intent)) { + if (requiresDoubleEscapeInterrupt(baseline.agentType, intent)) { const isSecondEscape = doubleEscapeBaseline !== null && isSameTurnBaseline(doubleEscapeBaseline, baseline) doubleEscapeBaseline = baseline diff --git a/src/shared/agent-interrupt-intent.ts b/src/shared/agent-interrupt-intent.ts index 2aaf61ec1b8..24f6f179e73 100644 --- a/src/shared/agent-interrupt-intent.ts +++ b/src/shared/agent-interrupt-intent.ts @@ -40,3 +40,25 @@ export function isNavigationEscapeIntent( ESCAPE_ALSO_NAVIGATES_AGENT_TYPES.has(agentType) ) } + +// Why: these TUIs spend the first Escape on a cancel that can leave the turn running — +// opencode2 also dismisses its Subagents dock with it — so only the second Escape on the +// same turn is evidence of an interrupt. Shared so the renderer gate and the server +// re-check cannot drift apart. +const DOUBLE_ESCAPE_INTERRUPT_AGENT_TYPES: ReadonlySet = new Set([ + 'opencode', + 'opencode2', + 'copilot' +]) + +/** True when this agent only yields an interrupt on a second same-turn Escape. */ +export function requiresDoubleEscapeInterrupt( + agentType: AgentType | undefined, + intent: AgentInterruptInputIntent +): boolean { + return ( + intent === 'plain-escape' && + agentType !== undefined && + DOUBLE_ESCAPE_INTERRUPT_AGENT_TYPES.has(agentType) + ) +}