Files
orca/src/main/agent-hooks/server-opencode-lifecycle.test.ts
T
Brennan Benson ab3b1d07cd reland(opencode): session continuity without the command-finished deferral (STA-4557) (#15350)
* reland(opencode): session continuity without the command-finished deferral (STA-4557)

Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which
is what caused the revert.

## Why the original runtime change was wrong

`retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority
retirement behind an async foreground read, on the premise that OpenCode emits
`command-finished` while still in the foreground. Raw PTY capture disproves it:
OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one
`133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being
deferred past only ever fires at exit, which is exactly when authority should be
retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`.

## Why the deferral was unsafe

`confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but
its early return means "don't record an exit" — conservative. The deferral copied
that shape into a site where the early return means "don't revoke a secret". Same
code, inverted consequence: every guard failed open, so a stale or racing read
silently kept a finished session's authority alive, and the pane's persisted
`launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority
after an app restart.

## Why the deferral's guards could not have worked

`ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started
in that shell inherits it — both sessions in a reused pane post the same token. A
pane-lifetime bearer secret cannot be a session identity baseline, by
construction, and `incarnationId` tracks the PTY, not the agent. The only field
that separates sessions is the provider `sessionID`.

## What lands

- Status/session-boundary work from #14866: opencode emits `SessionStart` for
  root sessions (mimo-code does not), launch-token fencing, and `SessionStart`
  as an opencode turn boundary.
- The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a
  tokened `SessionStart`, and restore mimo-code's explicit-prompt restart
  boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes).
  #14941's re-poll hunk is dropped along with the code it patched.
- Five regression tests in `opencode-finished-session-authority.test.ts`. They
  pass here and all five go red if the deferral is re-added.

* chore: drop incidental reformatting of files unrelated to this PR
2026-08-23 15:24:25 -07:00

262 lines
9.5 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111')
const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222')
describe('AgentHookServer OpenCode lifecycle', () => {
const servers: AgentHookServer[] = []
afterEach(() => {
for (const server of servers) {
server.stop()
}
servers.length = 0
})
async function setup(): Promise<{
server: AgentHookServer
post: (
payload: Record<string, unknown>,
launchToken: string,
paneKey?: string,
source?: 'opencode' | 'mimo-code'
) => Promise<Response>
}> {
const server = new AgentHookServer()
servers.push(server)
await server.start({ env: 'production' })
const env = server.buildPtyEnv()
return {
server,
post: (payload, launchToken, paneKey = PANE, source = 'opencode') =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey,
launchToken,
tabId: 'tab-opencode',
worktreeId: 'wt-opencode',
env: 'production',
payload
})
})
}
}
it('accepts Busy after a retired pane receives a root SessionStart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'done',
sessionBoundary: true,
providerSession: { key: 'session_id', id: 'fresh' }
})
])
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ paneKey: PANE, state: 'working', agentType: 'opencode' })
])
})
it('accepts a resumed fresh user MessagePart but not arbitrary Busy', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionBusy', sessionID: 'resumed' }, 'resume-token')
expect(server.getStatusSnapshot()).toEqual([])
await post(
{
hook_event_name: 'MessagePart',
role: 'user',
text: 'continue the task',
messageID: 'message-resumed',
sessionID: 'resumed'
},
'resume-token'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
])
})
it('maps question.asked attention to Waiting after restart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
await post(
{ hook_event_name: 'AskUserQuestion', id: 'question-1', sessionID: 'fresh' },
'fresh-token'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'waiting', agentType: 'opencode' })
])
})
it('suppresses stale old-token Busy after a fresh restart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale prompt' },
'old-token'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: '' })
])
})
it('clears the destination token fence when pane authority transfers', async () => {
const { server, post } = await setup()
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'target-old' },
'target-old-token',
TARGET_PANE
)
server.retirePaneAuthority(TARGET_PANE)
await post(
{ hook_event_name: 'SessionStart', sessionID: 'target-fresh' },
'target-fresh-token',
TARGET_PANE
)
await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token')
// The destination fence rejects the source token until the transfer removes it.
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'source-before-transfer' },
'source-token',
TARGET_PANE
)
expect(
server.getStatusSnapshot().find((entry) => entry.paneKey === TARGET_PANE)?.providerSession
).toEqual({ key: 'session_id', id: 'target-fresh' })
server.transferPaneAuthority(PANE, TARGET_PANE, 'pty-opencode')
await post(
{ hook_event_name: 'SessionBusy', sessionID: 'source-after-transfer' },
'source-token',
TARGET_PANE
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: TARGET_PANE,
providerSession: { key: 'session_id', id: 'source-after-transfer' }
})
])
})
it('re-fences on a new SessionStart while the pane stays authorized', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
// The stale old-token follow-up stays fenced out.
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
])
// Why: the runtime defers retirement while an agent stays in the foreground, so a
// genuinely new process can start in a still-authorized pane. Its SessionStart must
// replace the stale fence — not be swallowed as a stale event (rowless reuse).
await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token')
await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ providerSession: { key: 'session_id', id: 'fresh' } })
])
})
it('keeps the fence when a live pane sees a tokenless SessionStart', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
// Why: an untokened boundary cannot prove which process it belongs to, so dropping
// the fence for it would reopen the pane to every stale token.
await post({ hook_event_name: 'SessionStart', sessionID: 'tokenless' }, '')
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } })
])
})
it('does not let a stale explicit prompt re-fence a live pane', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
server.retirePaneAuthority(PANE)
await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token')
await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token')
// Why: prompts recur mid-session, so honoring one as a process boundary would hand
// the pane back to any still-live stale process.
await post(
{
hook_event_name: 'MessagePart',
role: 'user',
text: 'stale prompt',
messageID: 'message-stale',
sessionID: 'old'
},
'old-token'
)
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token')
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' }, prompt: '' })
])
})
it('restarts a retired mimo-code pane on an explicit user prompt', async () => {
const { server, post } = await setup()
await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token', PANE, 'mimo-code')
server.retirePaneAuthority(PANE)
// Why: mimo-code emits no SessionStart, so the explicit prompt is its only restart
// boundary — excluding it would strand every retired mimo-code pane.
await post(
{
hook_event_name: 'MessagePart',
role: 'user',
text: 'continue the task',
messageID: 'message-resumed',
sessionID: 'resumed'
},
'resume-token',
PANE,
'mimo-code'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: 'continue the task' })
])
})
})