Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/main/opencode/hook-plugin-lifecycle-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,4 +823,29 @@ describe('OpenCode plugin lifecycle delivery', () => {
await handler({ event: status('idle') })
expect(names().at(-1)).toBe('SessionIdle')
})

// Why: a tool-raised blocker records Busy only when none is known; taking over the
// owner would let the second factory's disposal erase Busy for a turn still running.
it('keeps the running factory Busy when another factory saw a tool-raised blocker', async () => {
const first = await loadHooks()
const second = await loadHooks()

await first.event({ event: status('busy') })
await second.event({
event: {
type: 'permission.asked',
properties: {
id: 'permission-1',
sessionID: 'root',
permission: 'bash',
patterns: ['ls'],
tool: { messageID: 'message-assistant', callID: 'call-1' }
}
}
})
await second.dispose?.()

expect(names()).toEqual(['SessionBusy', 'PermissionRequest', 'SessionBusy'])
await first.dispose?.()
})
})
177 changes: 175 additions & 2 deletions src/main/opencode/hook-plugin-opencode2-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
data: { sessionID: 'ses_root', assistantMessageID: 'msg-1', text: 'hello' }
}
yield { type: 'form.cancelled', data: { id: 'form-1', sessionID: 'ses_root' } }
// A tool-raised form proves a running turn, so only the turn's end settles Idle.
yield { type: 'session.execution.interrupted', data: { sessionID: 'ses_root' } }
}
}
})
Expand Down Expand Up @@ -316,7 +318,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
// (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> }> {
): Promise<{ names: string[]; cleanup?: () => Promise<void>; drained: Promise<void> }> {
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
const names: string[] = []
globalThis.fetch = vi.fn(async (_input, init) => {
Expand All @@ -328,6 +330,12 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
? _internals.getOpenCode2PluginSource()
: _internals.getOpenCodePluginSource()
)
// Why: the bridge awaits each event's handling before pulling the next, so the
// generator finishing means every lifecycle post the events cause has been made.
let markDrained = (): void => {}
const drained = new Promise<void>((resolve) => {
markDrained = resolve
})
const cleanup = await module.default?.setup?.({
session: {
get: async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID } }),
Expand All @@ -338,10 +346,11 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
for (const event of events) {
yield event
}
markDrained()
}
}
})
return { names, cleanup }
return { names, cleanup, drained }
}

function questionForm(id: string): Record<string, unknown> {
Expand Down Expand Up @@ -453,6 +462,170 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
await cleanup?.()
})

// A plugin reloaded mid-turn joins after execution.started; event orders captured from OpenCode 2.0.14.
it('re-derives Working from the next step after a mid-turn reload lost a pending question', async () => {
const { names, cleanup } = await runSetupBridge([
{
type: 'form.replied',
data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } }
},
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-0' } },
{ type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'tool-calls' } },
{ type: 'session.step.started', data: { sessionID: 'ses_root' } },
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-1' } }
])
await vi.waitFor(() => {
expect(names).toEqual(['SessionBusy'])
})
await cleanup?.()
})

it('keeps the pane Working after answering a question raised after a mid-turn reload', async () => {
const { names, cleanup } = await runSetupBridge([
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-0' } },
{ type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'tool-calls' } },
{ type: 'session.step.started', data: { sessionID: 'ses_root' } },
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-1' } },
{ type: 'form.created', data: { form: questionForm('form-q') } },
{
type: 'form.replied',
data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } }
},
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-1' } }
])
await vi.waitFor(() => {
expect(names).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy'])
})
await cleanup?.()
})

it('keeps a question blocking while a parallel tool in the same step reports progress', async () => {
const { names, cleanup } = await runSetupBridge([
{ type: 'session.execution.started', data: { sessionID: 'ses_root' } },
{ type: 'session.step.started', data: { sessionID: 'ses_root' } },
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-shell' } },
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-0' } },
{ type: 'form.created', data: { form: questionForm('form-q') } },
{ type: 'session.tool.progress', data: { sessionID: 'ses_root', id: 'tool-shell' } },
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-shell' } }
])
await vi.waitFor(() => {
expect(names).toContain('AskUserQuestion')
})
expect(names.at(-1)).toBe('AskUserQuestion')
await cleanup?.()
})

const toolSource = { type: 'tool', messageID: 'msg-0', id: 'tool-0' }
const blockers = {
question: {
name: 'AskUserQuestion',
asked: { type: 'form.created', data: { form: questionForm('form-q') } },
replied: {
type: 'form.replied',
data: { id: 'form-q', sessionID: 'ses_root', answer: { q0: 'Yes' } }
}
},
permission: {
name: 'PermissionRequest',
asked: {
type: 'permission.asked',
data: {
id: 'per-1',
sessionID: 'ses_root',
action: 'shell',
resources: ['rm -rf build'],
source: toolSource
}
},
replied: {
type: 'permission.replied',
data: { sessionID: 'ses_root', requestID: 'per-1', reply: 'once' }
}
}
}

// The reload lands after this step's step.started, so no later step start precedes the reply.
it.each(['question', 'permission'] as const)(
'keeps Working after answering a %s raised in the step a mid-turn reload joined',
async (kind) => {
const blocker = blockers[kind]
const stepAfterReload = [
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-shell' } },
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-0' } },
blocker.asked,
blocker.replied,
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-0' } },
// A long sibling tool keeps the step open after the reply.
{ type: 'session.tool.progress', data: { sessionID: 'ses_root', id: 'tool-shell' } },
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-shell' } },
{ type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'tool-calls' } }
]
const midTurn = await runSetupBridge(stepAfterReload)
await midTurn.drained
expect(midTurn.names).toEqual([blocker.name, 'SessionBusy'])
await midTurn.cleanup?.()

const wholeTurn = await runSetupBridge([
...stepAfterReload,
{ type: 'session.step.started', data: { sessionID: 'ses_root' } },
{ type: 'session.step.ended', data: { sessionID: 'ses_root', finish: 'stop' } },
{ type: 'session.execution.succeeded', data: { sessionID: 'ses_root' } }
])
await wholeTurn.drained
expect(wholeTurn.names).toEqual([blocker.name, 'SessionBusy', 'SessionIdle'])
await wholeTurn.cleanup?.()
}
)

it('settles on Done when a question raised after a mid-turn reload is cancelled by an interrupt', async () => {
const { names, cleanup, drained } = await runSetupBridge([
{ type: 'session.tool.called', data: { sessionID: 'ses_root', id: 'tool-0' } },
{ type: 'form.created', data: { form: questionForm('form-q') } },
{ type: 'form.cancelled', data: { id: 'form-q', sessionID: 'ses_root' } },
{ type: 'session.tool.failed', data: { sessionID: 'ses_root', id: 'tool-0' } },
{ type: 'session.execution.interrupted', data: { sessionID: 'ses_root' } }
])
await drained
expect(names).toEqual(['AskUserQuestion', 'SessionBusy', 'SessionIdle'])
await cleanup?.()
})

// Why: OpenCode's HTTP API can park a form on an idle session; its reply starts no work.
it('returns to Done after answering a form no tool raised', async () => {
const { names, cleanup, drained } = await runSetupBridge([
{
type: 'form.created',
data: {
form: {
id: 'form-api',
sessionID: 'ses_root',
title: 'Confirm',
fields: [{ key: 'q0', title: 'Proceed?', type: 'string', options: [] }]
}
}
},
{
type: 'form.replied',
data: { id: 'form-api', sessionID: 'ses_root', answer: { q0: 'Yes' } }
}
])
await drained
expect(names).toEqual(['AskUserQuestion', 'SessionIdle'])
await cleanup?.()
})

// The reload lands while a question is pending, so this instance never saw its form.
it('posts nothing while a sibling tool finishes under a question pending since before a reload', async () => {
const { names, cleanup, drained } = await runSetupBridge([
{ type: 'session.tool.progress', data: { sessionID: 'ses_root', id: 'tool-shell' } },
{ type: 'session.tool.success', data: { sessionID: 'ses_root', id: 'tool-shell' } }
])
await drained
expect(names).toEqual([])
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' } },
Expand Down
4 changes: 2 additions & 2 deletions src/main/opencode/hook-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ describe('OpenCode hook plugin source', () => {
const digest = (source: string): string => createHash('sha256').update(source).digest('hex')

expect(digest(getOpenCodePluginSource())).toBe(
'655d603f31c3e4462b4c600861833cbdba9dd541f693e11a349e7a57b8c059d7'
'38f94d7a2451969056dd0790cf5e3c7362cb8b78b94f2a3a2aee8868540107f1'
)
expect(
digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }))
).toBe('d36d869823cc3dd506565e0512beb278fe4fd6533bd12be30bd98db140ca9f3e')
).toBe('dee76cf311a9f9a4ab51745277af8c9da38be0a830f84a5949460b38368c0160')
})

it('filters child sessions via parentID lookup before forwarding events', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/main/opencode/status-plugin-lifecycle-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export function getStatusPluginLifecycleSource(): string[] {
' factoryID,',
' sourceSessionID: sessionID,',
' });',
" // Why: a factory that joined mid-turn missed this root's Busy, so without this",
" // the reply would show Done while the turn runs on. The turn's own Idle retires it.",
' if (childState === false && isToolRaisedBlocker(properties) && !busyRootOwnerBySessionID.has(sessionID)) {',
' busyRootOwnerBySessionID.set(sessionID, factoryID);',
' }',
' await publishAggregateStatus(factoryID, rootSessionID || sessionID);',
' return;',
' }',
Expand Down
7 changes: 7 additions & 0 deletions src/main/opencode/status-plugin-ownership-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ export function getStatusPluginOwnershipSource(): string[] {
' return true;',
'}',
'',
'// Why: OpenCode runs tools only inside a turn, so a tool-raised blocker (1.x',
'// tool, 2.x form metadata.tool or permission source) proves that turn is running;',
'// an API-created blocker can sit on an idle session and proves nothing.',
'function isToolRaisedBlocker(properties) {',
' return Boolean(properties?.tool || properties?.metadata?.tool || properties?.source?.type === "tool");',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule this predicate guards leans on the tool marker as proof the blocker came from a running tool, but properties.metadata is a freeform record the public POST /api/session/:sessionID/form accepts from any client, so a form can carry a client-set metadata.tool while owning an idle session. In that case the new rule latches busyRootOwnerBySessionID with no turn to emit Idle, and the pane stays Working after the reply until the next turn ends or the plugin is unloaded. The repo's own reproduction doc (docs/bug-reproductions/opencode2-form-created-kinds/README.md:22-27) notes metadata is optional and unvalidated, and the API-created-form test at hook-plugin-opencode2-setup.test.ts:594 uses a form with no metadata, so it does not cover a marker-carrying one.

Technical details
# Marker is client-spoofable, so it is not proof of a running turn

## Affected sites
- `src/main/opencode/status-plugin-ownership-source.ts:127-128` — `isToolRaisedBlocker` accepts any `metadata.tool`.
- `src/main/opencode/status-plugin-lifecycle-source.ts:102-104` — the rule sets `busyRootOwnerBySessionID` with no turn-liveness corroboration.
- `src/main/opencode2/status-plugin-setup-source.ts:47-65` — `form.created` spreads the client-supplied `form` (including metadata) into `question.asked` for any real session.

## Required outcome
- A blocker that is not backed by a running turn must not leave a busy owner that outlives the reply. Either corroborate the turn (e.g. only set the owner when a busy/step signal for the session is otherwise in flight) or bound the record so a reply with no turn clears it.

## Open questions for the human
- Is `metadata.tool` reachable in practice from anything other than the `question` tool, given `metadata` is `Record(String, Unknown)` and the form API is public? If yes, this is the same latch the PR rejected for "treat every question/permission as proof", just requiring a forged marker.

'}',
'',
'function latestBusyOwner() {',
' let latest = null;',
' // Why: a running descendant is reported under its ROOT id, and ranks',
Expand Down
4 changes: 4 additions & 0 deletions src/main/opencode2/status-plugin-setup-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ async function setupOpenCode2Status(ctx) {
properties = { ...properties, requestID: properties.id };
} else if (type === "session.text.started" || type === "session.text.delta" || type === "session.text.ended") {
type = type.replace("session.", "session.next.");
} else if (type === "session.step.started") {
// Why: re-derives Busy for a plugin reloaded mid-turn, which never saw execution.started.
// Tool events stay out: a parallel tool reports progress while a sibling form still blocks.
type = "session.next.step.started";
}
await hooks.event({ event: { type, properties } });
}
Expand Down
Loading