diff --git a/.gitignore b/.gitignore index 56ecc5854b6..6799b25607b 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,8 @@ docs/** !docs/audits/plugin-uninstall-log-retirement/** !docs/readme/ !docs/readme/** +!docs/bug-reproductions/ +!docs/bug-reproductions/** !docs/STYLEGUIDE.md !docs/audits/ !docs/audits/crashpad-read-limit/ diff --git a/docs/bug-reproductions/opencode2-form-created-kinds/README.md b/docs/bug-reproductions/opencode2-form-created-kinds/README.md new file mode 100644 index 00000000000..5a1586178e8 --- /dev/null +++ b/docs/bug-reproductions/opencode2-form-created-kinds/README.md @@ -0,0 +1,55 @@ +# OpenCode 2 `form.created` is not always a question (#22371) + +OpenCode 2 has one form primitive and several producers. Orca's setup bridge mapped every +`form.created` to `question.asked`, which is Orca's un-evictable "the pane owner must answer +this" blocker. Only one producer is an agent-initiated question. + +Captured against the shipped `opencode v2.0.12` binary on macOS, driving the real TUI in a PTY +against a real `opencode serve` instance and reading the server's `/api/event` SSE stream. + +## The discriminator: `form.metadata.kind` + +`Form.Info` is `{ id, sessionID, title, metadata?, fields }`; `metadata` is an open record that +each producer stamps. Every `Form.ask` call site in the v2.0.12 bundle: + +| `metadata.kind` | Form title | `sessionID` | Agent-initiated question | +| -------------------- | --------------------------------------------- | ----------- | ------------------------- | +| `question` | `Questions` | the session | yes — the `question` tool | +| `websearch.provider` | `Web Search` / `Choose a web search provider` | the session | no — a provider picker | +| `mcp-elicitation` | ` is requesting input` | `"global"` | no — an MCP server prompt | + +`mcp-elicitation` is the worst shape for Orca: `"global"` is not a session, so the blocker it +mints can never be retired by that session going idle — only by an exact `form.replied` / +`form.cancelled` for the same form id. + +`form-created-question.json` and `form-replied-question.json` are the live capture of the +`question` tool's form being raised and answered. Note `metadata.tool` is `{ messageID, id }`, +not the `{ messageID, callID }` that Orca's `clearQuestionForToolPart` matches on, and OpenCode 2 +never emits `message.part.updated` at all — so that retirement path is dead for OpenCode 2 and +`form.replied` / `form.cancelled` is the only reply-side retirement it has. + +## The reported menu does not raise a form + +`subagent-panel-screen.txt` is the rendered PTY screen from the reported surface — the +`Subagents / Shell / Terminals` activity dock, opened over a session with three subagents. +The whole time that dock was opened, paged and dismissed, the server's `/api/event` stream +carried nothing but `server.connected`, heartbeats, and unrelated `skill.updated` filewatcher +noise from another checkout. Same result for the `shift+tab` agent picker and the `ctrl+p` +command palette. The TUI's pickers are local Solid components; they never call `Form.ask`, so +they produce no server event of any kind and cannot be the thing Orca saw. + +So this capture proves the mapping was wrong and which field fixes it; it does not reproduce +the exact frame in the issue screenshot. + +## Reproduce + +```sh +opencode serve --hostname 127.0.0.1 --port 47391 # OPENCODE_SERVER_PASSWORD= +curl -s -u opencode: -N http://127.0.0.1:47391/api/event # tee this +opencode --server http://127.0.0.1:47391 --session # drive in a PTY +``` + +Ask the agent to call its `question` tool for the `question` shape. For the picker and MCP +shapes, `POST /api/session//form` with the `metadata.kind` from the table — that is the +same publish path the internal producers use, and it is what the plugin-level check was +verified against. diff --git a/docs/bug-reproductions/opencode2-form-created-kinds/form-created-question.json b/docs/bug-reproductions/opencode2-form-created-kinds/form-created-question.json new file mode 100644 index 00000000000..e9ffc15a21d --- /dev/null +++ b/docs/bug-reproductions/opencode2-form-created-kinds/form-created-question.json @@ -0,0 +1,43 @@ +{ + "id": "evt_0cc7238ae002BOODuEn2h3Ro0N", + "created": 1790136432814, + "type": "form.created", + "location": { + "directory": "/private/tmp/oc2-evidence" + }, + "data": { + "form": { + "id": "frm_0cc7238ae001iZeN97tf8v3xTs", + "sessionID": "ses_f33905de7ffeBFKvVC2HupLfJq", + "title": "Questions", + "metadata": { + "kind": "question", + "tool": { + "messageID": "msg_0cc723557001aL7j9XWmdbIFh1", + "id": "tool_1611fbde530a4443b5f11c67a4cd3d91" + } + }, + "fields": [ + { + "key": "q0", + "title": "Color Preference", + "description": "Which color do you prefer, red or blue?", + "type": "string", + "options": [ + { + "value": "Red", + "label": "Red", + "description": "The color red" + }, + { + "value": "Blue", + "label": "Blue", + "description": "The color blue" + } + ], + "custom": true + } + ] + } + } +} diff --git a/docs/bug-reproductions/opencode2-form-created-kinds/form-replied-question.json b/docs/bug-reproductions/opencode2-form-created-kinds/form-replied-question.json new file mode 100644 index 00000000000..31322866074 --- /dev/null +++ b/docs/bug-reproductions/opencode2-form-created-kinds/form-replied-question.json @@ -0,0 +1,15 @@ +{ + "id": "evt_0cc73181e001bHisuGqUktfd0F", + "created": 1790136490014, + "type": "form.replied", + "location": { + "directory": "/private/tmp/oc2-evidence" + }, + "data": { + "id": "frm_0cc7238ae001iZeN97tf8v3xTs", + "sessionID": "ses_f33905de7ffeBFKvVC2HupLfJq", + "answer": { + "q0": "Red" + } + } +} diff --git a/docs/bug-reproductions/opencode2-form-created-kinds/subagent-panel-screen.txt b/docs/bug-reproductions/opencode2-form-created-kinds/subagent-panel-screen.txt new file mode 100644 index 00000000000..dcf5a1685fc --- /dev/null +++ b/docs/bug-reproductions/opencode2-form-created-kinds/subagent-panel-screen.txt @@ -0,0 +1,48 @@ + • Affirmative response + + ┃ Use the task tool three times in one go to launch three subagents in parallel. Give each one this exact + ┃ instruction: run the bash command `sleep 240` and then report done. Do not wait for them to finish before Affirmative response + ┃ replying to me. + ┃ Context + 8,907 tokens + ✓ General Subagent — Sleep 240 seconds Background 1% used + $0.04 spent + ✓ General Subagent — Sleep 240 seconds Background + + ✓ General Subagent — Sleep 240 seconds Background + + I have launched three subagents, each running sleep 240 in the background. You will be notified when they + complete. + + Build · Gemini 2.5 Flash · 2.2s · 117.5 tok/s + + ↳ General finished · Sleep 240 seconds + + One of the subagents has reported "Done." I'm still waiting for the other two subagents to complete their sleep + 240 command. + + Build · Gemini 2.5 Flash · 1.1s · 58.7 tok/s + + ↳ General finished · Sleep 240 seconds + + Another subagent has finished. One subagent is still running its sleep 240 command. + + Build · Gemini 2.5 Flash · 1.9s · 59.3 tok/s + + ↳ General finished · Sleep 240 seconds + + All three subagents have now completed their tasks. + + Build · Gemini 2.5 Flash · 478ms · 21.2 tok/s + + ┃ + ┃ Subagents Shell Terminals esc + ┃ + ┃ No active subagents + ┃ + ┃ + ┃ + ┃ + ┃ + ┃ show inactive ctrl+a tabs ←/→ + ┃ /private/tmp/oc2-evidence + diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 5763687d315..2f0d3c430bf 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -201,6 +201,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag id: 'form-1', sessionID: 'ses_root', title: 'Pick', + metadata: { kind: 'question', tool: { messageID: 'msg-0', id: 'tool-0' } }, fields: [ { title: 'Color', @@ -255,6 +256,119 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag await cleanup?.() }) + // Why: OpenCode 2 raises the same form primitive for its pickers and for MCP + // elicitations; only the question tool stamps metadata.kind "question" (v2.0.12 + // capture in docs/bug-reproductions/opencode2-form-created-kinds). + async function runSetupBridge( + events: { type: string; data: Record }[] + ): Promise<{ names: string[]; cleanup?: () => Promise }> { + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + const names: string[] = [] + globalThis.fetch = vi.fn(async (_input, init) => { + names.push(String(payload(record(JSON.parse(String(init?.body))) ?? {}).hook_event_name)) + return new Response('{}', { status: 200 }) + }) + const module = await loadPluginModule( + agent === 'opencode2' + ? _internals.getOpenCode2PluginSource() + : _internals.getOpenCodePluginSource() + ) + const cleanup = await module.default?.setup?.({ + session: { + get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }), + hook: async () => ({ dispose: vi.fn() }) + }, + event: { + subscribe: async function* () { + for (const event of events) { + yield event + } + } + } + }) + return { names, cleanup } + } + + function questionForm(id: string): Record { + return { + id, + sessionID: 'ses_root', + title: 'Questions', + metadata: { kind: 'question', tool: { messageID: 'msg-0', id: 'tool-0' } }, + fields: [{ key: 'q0', title: 'Proceed?', type: 'string', options: [] }] + } + } + + it.each([ + ['websearch.provider', 'ses_root'], + ['mcp-elicitation', 'global'] + ])('ignores a %s form instead of blocking the pane', async (kind, sessionID) => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, + { + type: 'form.created', + data: { + form: { + id: 'form-picker', + sessionID, + title: 'Choose a web search provider', + metadata: { kind }, + fields: [{ key: 'provider', title: 'Provider', type: 'string', options: [] }] + } + } + }, + { type: 'form.cancelled', data: { id: 'form-picker', sessionID } }, + { type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } } + ]) + await vi.waitFor(() => { + expect(names.at(-1)).toBe('SessionIdle') + }) + expect(names).not.toContain('AskUserQuestion') + await cleanup?.() + }) + + it('still blocks on a real question form and retires it on reply', async () => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, + { type: 'form.created', data: { form: questionForm('form-q') } }, + { + type: 'form.replied', + data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } } + }, + { type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } } + ]) + await vi.waitFor(() => { + expect(names).toContain('AskUserQuestion') + expect(names.at(-1)).toBe('SessionIdle') + }) + await cleanup?.() + }) + + it('keeps a live question blocker while an ignored form is raised and resolved', async () => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, + { type: 'form.created', data: { form: questionForm('form-q') } }, + { + type: 'form.created', + data: { + form: { + id: 'form-mcp', + sessionID: 'global', + title: 'server is requesting input', + metadata: { kind: 'mcp-elicitation', server: 'server' }, + fields: [{ key: 'elicitation', title: 'Input', type: 'string', options: [] }] + } + } + }, + { type: 'form.cancelled', data: { id: 'form-mcp', sessionID: 'global' } } + ]) + await vi.waitFor(() => { + expect(names).toContain('AskUserQuestion') + }) + expect(names.at(-1)).toBe('AskUserQuestion') + await cleanup?.() + }) + it.each(['waiting', 'idle', 'disposed'])( 'drops an admitted prompt overtaken by %s', async (transition) => { diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 7b49d746535..e651aace76e 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -92,7 +92,7 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - '51ae4f9fbf7a85e3e33db961d0d83b89395ed026335502569e4f3179927aa629' + '61ea63eef727ba55903859fc4a6317f38ee1e10a4a4532e79c237f8f0e98ab52' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) diff --git a/src/main/opencode/opencode-client-sweep.test.ts b/src/main/opencode/opencode-client-sweep.test.ts index d5444eac128..72f5106c8f6 100644 --- a/src/main/opencode/opencode-client-sweep.test.ts +++ b/src/main/opencode/opencode-client-sweep.test.ts @@ -150,9 +150,9 @@ describe('isOpenCodeClientProcess', () => { }) describe('sweepProcessIdentities', () => { - function psRunner(outputs: Record<'args' | 'comm', string | Error>): ( - spec: ProcessSpec - ) => Promise { + function psRunner( + outputs: Record<'args' | 'comm', string | Error> + ): (spec: ProcessSpec) => Promise { return async (spec: ProcessSpec): Promise => { const kind = spec.args?.some((arg) => arg.includes('comm=')) ? 'comm' : 'args' const output = outputs[kind] diff --git a/src/main/opencode2/status-plugin-setup-source.ts b/src/main/opencode2/status-plugin-setup-source.ts index cd2a428d101..d486c6b1d57 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -24,8 +24,12 @@ async function setupOpenCode2Status(ctx) { } else if (type === "permission.asked") { properties = { ...properties, permission: properties.action, patterns: properties.resources }; } else if (type === "form.created") { - type = "question.asked"; const form = properties.form; + // Why: OpenCode 2 raises the same form for its own pickers and for MCP + // elicitations; only its question tool stamps kind "question", and only + // that form is a question the pane owner was actually asked. + if (!form || !form.metadata || form.metadata.kind !== "question") continue; + type = "question.asked"; properties = { ...form, questions: form.fields.map((field) => ({ @@ -36,6 +40,8 @@ async function setupOpenCode2Status(ctx) { })), }; } else if (type === "form.replied" || type === "form.cancelled") { + // A resolution for an ignored form is inert: the blocker key carries the + // form id, so it simply matches nothing. type = type === "form.replied" ? "question.replied" : "question.rejected"; properties = { ...properties, requestID: properties.id }; } else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") {