Files
orca/src/main/agent-hooks/server-opencode-lifecycle.test.ts
T
Jinjing 1da1bdc01c Preserve OpenCode session across command completion, control SessionStart emission (#14866)
* Preserve OpenCode session across command completion

- Add session start events and launch token tracking to establish session boundaries
- Defer retiring launch authority until OpenCode process actually exits, not just when a command finishes
- Fence previous tokens after restarts to prevent status updates from stale sessions
- Maps SessionStart as a session boundary for proper turn/state management

* Emit SessionStart only from OpenCode, not mimo-code

Restrict SessionStart lifecycle events to OpenCode exclusively. Mimo-code no longer emits SessionStart, as it should rely on OpenCode for session boundary signals. This prevents duplicate lifecycle events that could interfere with pane authority tracking and session state management. Also tighten foreground process result validation to reject stale results after title observation changes, fixing a race where a delayed foreground read from a previous cycle would incorrectly retire authority.
2026-08-16 10:01:27 -07:00

160 lines
5.1 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
) => 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) =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
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('replaces a 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')
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' }
})
])
})
})