diff --git a/docs/bug-reproductions/opencode2-form-created-kinds/README.md b/docs/bug-reproductions/opencode2-form-created-kinds/README.md index 5a1586178e8..8ac1dbfe449 100644 --- a/docs/bug-reproductions/opencode2-form-created-kinds/README.md +++ b/docs/bug-reproductions/opencode2-form-created-kinds/README.md @@ -2,25 +2,40 @@ 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. +this" blocker — including forms owned by a sentinel that is not a session. 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` +## The discriminator: `form.sessionID`, not `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: +`Form.Info` is `{ id, sessionID, title, metadata?, fields }`. Every `Form.ask` call site in the +v2.0.12 bundle (still exactly five on `v2.0.15`, in `tool/plugin/question.ts`, +`tool/plugin/websearch.ts` and `mcp/index.ts`): -| `metadata.kind` | Form title | `sessionID` | Agent-initiated question | -| -------------------- | --------------------------------------------- | ----------- | ------------------------- | +| `metadata.kind` | Form title | `sessionID` | Blocks the pane owner | +| -------------------- | --------------------------------------------- | ----------- | ------------------------ | | `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 | +| `websearch.provider` | `Web Search` / `Choose a web search provider` | the session | yes — the turn is stalled | +| `mcp-elicitation` | ` is requesting input` | `"global"` | no — see below | -`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. +`metadata.kind` looks like the discriminator but cannot be one. In `packages/schema/src/form.ts` +on `v2.0.15`, `Metadata` is `Schema.Record(Schema.String, Schema.Unknown)` and line 130 declares +`metadata: Metadata.pipe(optional)` — so `metadata` may be absent entirely and `kind` is a +convention no producer is obliged to stamp. The public `POST /api/session/:sessionID/form` +endpoint (`packages/protocol/src/groups/session.ts:809`, payload at ~147) lets any client raise a +genuinely blocking form on a real session with no metadata at all. Keying on +`metadata.kind === "question"` therefore drops real blockers silently. + +What actually differs is the owner. `mcp-elicitation` passes `GLOBAL_ELICITATION_SESSION_ID` +(`"global"`, `packages/core/src/mcp/index.ts:82`), which is not a session, so a blocker minted for +it can never be retired by that session going idle — only by an exact `form.replied` / +`form.cancelled`. `websearch.provider` passes the real `context.sessionID`, so session idle retires +it normally, and while it is pending the agent genuinely is waiting on the user. + +So Orca blocks on every session-owned form and drops only the non-session sentinel. Upstream notes +in `form.ts:122-129` that `"global"` is temporary and elicitations will get real session ids; when +that lands the exclusion stops matching and Orca starts blocking on them correctly. `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 }`, diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 2f0d3c430bf..03e712820dd 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -256,9 +256,9 @@ 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). + // Why: OpenCode 2 raises one form primitive for several producers, and its owner + // id — not its metadata — decides whether Orca can ever retire the blocker + // (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 }> { @@ -299,25 +299,22 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag } } - it.each([ - ['websearch.provider', 'ses_root'], - ['mcp-elicitation', 'global'] - ])('ignores a %s form instead of blocking the pane', async (kind, sessionID) => { + it('ignores a form owned by the non-session elicitation sentinel', async () => { 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: [] }] + 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-picker', sessionID } }, + { type: 'form.cancelled', data: { id: 'form-mcp', sessionID: 'global' } }, { type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } } ]) await vi.waitFor(() => { @@ -327,6 +324,63 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag await cleanup?.() }) + // Why: metadata is optional in OpenCode's schema and its kind is a convention, + // so any session-owned form must block rather than silently disappear. + it.each([ + ['no metadata at all', undefined], + ['metadata without a kind', { server: 'server' }], + ['an unrecognised kind', { kind: 'some.future.kind' }], + ['the web search provider picker', { kind: 'websearch.provider' }] + ])('blocks the pane on a session-owned form with %s', async (_label, metadata) => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, + { + type: 'form.created', + data: { + form: { + id: 'form-unknown', + sessionID: 'ses_root', + title: 'Choose a web search provider', + ...(metadata === undefined ? {} : { metadata }), + fields: [{ key: 'provider', title: 'Provider', type: 'string', options: [] }] + } + } + } + ]) + await vi.waitFor(() => { + expect(names).toContain('AskUserQuestion') + }) + expect(names.at(-1)).toBe('AskUserQuestion') + await cleanup?.() + }) + + it.each(['form.replied', 'form.cancelled'])( + 'retires an admitted unknown-kind blocker on %s', + async (resolution) => { + const { names, cleanup } = await runSetupBridge([ + { type: 'session.execution.started', data: { sessionID: 'ses_root' } }, + { + type: 'form.created', + data: { + form: { + id: 'form-unknown', + sessionID: 'ses_root', + title: 'Web Search', + fields: [{ key: 'choice', title: 'Allow?', type: 'string', options: [] }] + } + } + }, + { type: resolution, data: { id: 'form-unknown', sessionID: 'ses_root' } }, + { type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } } + ]) + await vi.waitFor(() => { + expect(names).toContain('AskUserQuestion') + expect(names.at(-1)).toBe('SessionIdle') + }) + 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' } }, diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index e651aace76e..46e75d2e8f8 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( - '61ea63eef727ba55903859fc4a6317f38ee1e10a4a4532e79c237f8f0e98ab52' + 'a43118afe856104629c968cc8adf43ced3ce85109977746e709bf4f391548fdf' ) 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 d486c6b1d57..56648240fbd 100644 --- a/src/main/opencode2/status-plugin-setup-source.ts +++ b/src/main/opencode2/status-plugin-setup-source.ts @@ -1,5 +1,13 @@ export function getOpenCode2SetupSource(): string[] { return String.raw` +// Why: OpenCode owns a form under a session id, and Orca retires a blocker when +// that session goes idle. An owner that is not a real session has no idle, so a +// blocker minted for it can only ever be retired by an exact reply — add an id +// here to drop forms Orca could otherwise strand. OpenCode's own schema calls +// "global" a temporary MCP-elicitation sentinel it intends to replace with real +// session ids; when it does, this set stops matching and those forms block. +const NON_SESSION_FORM_OWNERS = new Set(["global"]); + async function setupOpenCode2Status(ctx) { const controller = new AbortController(); const client = { session: { get: (input, options) => ctx.session.get(input, options) } }; @@ -25,14 +33,17 @@ async function setupOpenCode2Status(ctx) { properties = { ...properties, permission: properties.action, patterns: properties.resources }; } else if (type === "form.created") { 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; + // Why: block on every form whose owner is a real session. "metadata" is + // optional in OpenCode's schema and its "kind" is a convention no + // producer is obliged to stamp, so an unknown shape must surface a + // blocker the user can clear rather than vanish while OpenCode waits. + if (!form || NON_SESSION_FORM_OWNERS.has(form.sessionID)) continue; + // A malformed form must not throw: that would kill the subscription. + const fields = Array.isArray(form.fields) ? form.fields : []; type = "question.asked"; properties = { ...form, - questions: form.fields.map((field) => ({ + questions: fields.map((field) => ({ header: field.title || form.title, question: field.description || field.title || form.title, options: (field.options || []).map((option) => ({ label: option.label || option.value, description: option.description || "" })),