mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
fix(opencode2): block the pane on every session-owned form (#22548)
#22399 admitted an OpenCode 2 form.created as a pane blocker only when metadata.kind === "question". On v2.0.15 that is an allow-list on a field with no contract: packages/schema/src/form.ts declares Metadata as an open Schema.Record and metadata itself as optional, and the public POST /api/session/:sessionID/form endpoint lets any client raise a real blocking form on a real session with no metadata. Orca dropped those, so the pane painted no blocker while OpenCode waited forever. Invert the default. Every form whose owner is a real session blocks; only a form owned by the "global" MCP-elicitation sentinel is dropped, because that owner is not a session and never goes idle, so its blocker could not be retired. That also restores websearch.provider as a blocker: it carries the real context.sessionID, session idle retires it, and while it is pending the agent is genuinely stalled on the user. Resolution is unchanged: clearAttentionForResolution keys on the exact form id plus source session, so a resolution for a dropped form matches nothing and cannot retire a live blocker.
This commit is contained in:
@@ -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<string, unknown> }[]
|
||||
): Promise<{ names: string[]; cleanup?: () => Promise<void> }> {
|
||||
@@ -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' } },
|
||||
|
||||
Reference in New Issue
Block a user