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
4 changes: 2 additions & 2 deletions src/main/orcad/orcad-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,8 @@ async function startOrcadRuntime(
readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject)
},
checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) =>
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys),
reconcileAgentStatusForEndedProcess: (paneKeys, options) =>
agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options),
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {},
// Why the dedupe here and not in the instance: `apply` closes and reconstructs
Expand Down
6 changes: 3 additions & 3 deletions src/main/runtime/agent-status-store-wiring.test-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function makeAgentStatusStoreWiring(): {
paneKey: string
) => ReturnType<AgentHookServer['getStatusSnapshotForPane']>
reconcileAgentStatusForEndedProcess: (
paneKeys: Parameters<AgentHookServer['reconcileEndedProcessForPaneKeys']>[0]
...args: Parameters<AgentHookServer['reconcileEndedProcessForPaneKeys']>
) => void
}
/** Call once the runtime exists; returns the republish teardown. */
Expand All @@ -42,8 +42,8 @@ export function makeAgentStatusStoreWiring(): {
getAgentProviderSessionSnapshot: () => statusStore.getStatusSnapshot(),
getAgentProviderSessionRowsForPane: (paneKey) =>
statusStore.getStatusSnapshotForPane(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) => {
statusStore.reconcileEndedProcessForPaneKeys(paneKeys)
reconcileAgentStatusForEndedProcess: (...args) => {
statusStore.reconcileEndedProcessForPaneKeys(...args)
}
},
attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime)
Expand Down
247 changes: 212 additions & 35 deletions src/main/runtime/opencode-run-lifetime-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ vi.mock('./local-pty-foreground-command-line', () => ({
import { OrcaRuntimeService } from './orca-runtime'
import { FOREGROUND_COMMAND_READS } from '../../shared/foreground-command-settle'
import type { RuntimeTerminalAgentStatusEvent } from './runtime-terminal-contracts'
import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts'
import { makeAgentStatusStoreWiring } from './agent-status-store-wiring.test-fixture'

const WORKTREE_ID = 'repo::/worktree'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
Expand All @@ -18,16 +20,34 @@ function createRuntime(): {
runtime: OrcaRuntimeService
statuses: RuntimeTerminalAgentStatusEvent[]
channelOrder: string[]
endedRunFacts: { paneKey?: string; fact: TerminalSideEffectFact }[]
statusStore: ReturnType<typeof makeAgentStatusStoreWiring>['statusStore']
} {
const statuses: RuntimeTerminalAgentStatusEvent[] = []
const channelOrder: string[] = []
const endedRunFacts: { paneKey?: string; fact: TerminalSideEffectFact }[] = []
// Why the real store: it is what the sidebar, `worktree ps` and mobile all read.
const wiring = makeAgentStatusStoreWiring()
wiring.statusStore.subscribePaneStatusClear((clear) =>
channelOrder.push(`clear:${'paneKey' in clear ? clear.paneKey : ''}`)
)
const runtime = new OrcaRuntimeService(undefined, undefined, {
...wiring.deps,
retireAgentHookCompatibilityAuthority: (paneKey) =>
wiring.statusStore.retirePaneAuthority(paneKey),
onTerminalAgentStatus: (event) => {
statuses.push(event)
channelOrder.push(`status:${event.payload.state}`)
wiring.deps.onTerminalAgentStatus(event)
},
onTerminalSideEffects: (batch) =>
onTerminalSideEffects: (batch) => {
channelOrder.push(...batch.facts.map((fact) => `fact:${fact.kind}`))
for (const fact of batch.facts) {
if (fact.kind === 'agent-run-ended') {
endedRunFacts.push({ paneKey: batch.paneKey, fact })
}
}
}
})
runtime.setPtyController({
spawn: vi.fn(),
Expand All @@ -50,15 +70,51 @@ function createRuntime(): {
{ tabId: 'tab-1', worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 1, ptyId: 'pty-1' }
]
})
return { runtime, statuses, channelOrder }
return { runtime, statuses, channelOrder, endedRunFacts, statusStore: wiring.statusStore }
}

const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] =>
statuses.map(
(event) =>
`${event.paneKey}:${event.payload.state}${event.payload.interrupted ? ':interrupted' : ''}:${event.origin}`
const hostRows = (statusStore: ReturnType<typeof createRuntime>['statusStore']): string[] =>
statusStore
.getStatusSnapshot()
.filter((row) => row.paneKey === PANE_KEY && row.providerSessionOnly !== true)
.map((row) => row.state)

const resumeRemnants = (statusStore: ReturnType<typeof createRuntime>['statusStore']) =>
statusStore
.getStatusSnapshot()
.filter((row) => row.paneKey === PANE_KEY && row.providerSessionOnly === true)
.map((row) => row.providerSession?.id)

// The OpenCode 1 plugin reporting its own `run` over the hook server.
const pluginReport = (
statusStore: ReturnType<typeof createRuntime>['statusStore'],
state: 'working' | 'done'
): void =>
statusStore.ingestRemote(
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: WORKTREE_ID,
source: 'opencode',
hookEventName: state === 'working' ? 'session.status' : 'session.idle',
providerSession: { key: 'session_id', id: 'ses_resume' },
payload: { state, prompt: 'fix the bug', agentType: 'opencode' }
},
null
)

const endedRunFact = (exitCode: number | null) => ({
paneKey: PANE_KEY,
fact: {
kind: 'agent-run-ended',
agentType: 'opencode',
...(exitCode === 130 ? { interrupted: true } : {})
}
})

const summary = (statuses: RuntimeTerminalAgentStatusEvent[]): string[] =>
statuses.map((event) => `${event.paneKey}:${event.payload.state}:${event.origin}`)

// `opencode run` typed in a pane: the pane's own command boundaries and foreground drive its row.
describe('OpenCode run process lifetime in the runtime', () => {
beforeEach(() => {
Expand All @@ -71,41 +127,15 @@ describe('OpenCode run process lifetime in the runtime', () => {
vi.useRealTimers()
})

it('posts Working after the command starts and Done when it finishes, on its own pane', async () => {
it('posts Working after the command starts, on its own pane, and no status when it finishes', async () => {
const { runtime, statuses } = createRuntime()

runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100)
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)
runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101)

expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`])
expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`])
expect(readCommandLineMock).toHaveBeenCalledWith('pty-1', 'opencode')
expect(statuses[0]?.yieldsToHookSince).toBe(statuses[1]?.yieldsToHookSince)
})

// Why: the renderer drops an exited agent's row on command-finished unless it changed after.
it("publishes the run's Done after the command-finished fact of the same chunk", async () => {
const { runtime, channelOrder } = createRuntime()

runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100)
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)
runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101)

expect(channelOrder).toEqual(['status:working', 'fact:command-finished', 'status:done'])
})

it('posts Done from the daemon fact when the pane finished while hidden', async () => {
const { runtime, statuses, channelOrder } = createRuntime()

runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100)
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)
runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 130 })

expect(channelOrder.slice(-2)).toEqual(['fact:command-finished', 'status:done'])
expect(summary(statuses)).toEqual([
`${PANE_KEY}:working:process`,
`${PANE_KEY}:done:interrupted:process`
])
})

it('stays silent for an SSH pane', async () => {
Expand All @@ -131,11 +161,158 @@ describe('OpenCode run process lifetime in the runtime', () => {
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)
runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101)

expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`, `${PANE_KEY}:done:process`])
expect(summary(statuses)).toEqual([`${PANE_KEY}:working:process`])
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
})

// Why: Done means a live TUI finished its turn. Once `opencode run` exits nothing runs in the pane,
// so its row must leave the host store every reader subscribes to, as any exited agent's does.
describe('OpenCode run exit in the host status store', () => {
beforeEach(() => {
vi.useFakeTimers()
readCommandLineMock.mockReset()
readCommandLineMock.mockResolvedValue('opencode run fix the bug')
})

afterEach(() => {
vi.useRealTimers()
})

async function startRun(): Promise<ReturnType<typeof createRuntime>> {
const wired = createRuntime()
wired.runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100)
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)
expect(hostRows(wired.statusStore)).toEqual(['working'])
return wired
}

it.each([
['exits', 0],
['is interrupted with Ctrl+C', 130]
])('clears the row of a visible pane whose run %s', async (_label, exitCode) => {
const { runtime, statusStore, channelOrder, endedRunFacts } = await startRun()

runtime.onPtyData('pty-1', `done\x1b]133;D;${exitCode}\x07`, 101)

expect(hostRows(statusStore)).toEqual([])
expect(channelOrder).not.toContain('status:done')
expect(channelOrder.slice(-3)).toEqual([
`clear:${PANE_KEY}`,
'fact:agent-run-ended',
'fact:command-finished'
])
expect(endedRunFacts).toEqual([endedRunFact(exitCode)])
})

it.each([
['exits', 0],
['is interrupted with Ctrl+C', 130]
])('clears the row of a hidden pane whose run %s', async (_label, exitCode) => {
const { runtime, statusStore, channelOrder, endedRunFacts } = await startRun()

runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode })

expect(hostRows(statusStore)).toEqual([])
expect(channelOrder).not.toContain('status:done')
expect(channelOrder.slice(-3)).toEqual([
`clear:${PANE_KEY}`,
'fact:agent-run-ended',
'fact:command-finished'
])
expect(endedRunFacts).toEqual([endedRunFact(exitCode)])
})

// Why: a terminal-list refresh marks live panes as restored launch authority, and retiring that
// authority on command-finished deletes the pane's rows without telling the renderer.
it.each([
['visible', 'chunk'],
['hidden', 'daemon fact']
])('clears the %s pane on the renderer too when a refresh had marked it', async (_l, path) => {
const { runtime, statusStore, channelOrder } = await startRun()
runtime['restoredOrchestrationAuthorityByPtyId'].set('pty-1', {
ptyId: 'pty-1',
worktreeId: WORKTREE_ID,
terminalHandle: 'term-1',
paneKey: PANE_KEY,
processIncarnation: 'pty-1:1',
hostScope: { kind: 'local', hostId: 'local' }
})

if (path === 'chunk') {
runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101)
} else {
runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 })
}

expect(hostRows(statusStore)).toEqual([])
expect(channelOrder).toContain(`clear:${PANE_KEY}`)
})

it('clears the row when the next command starts without the run’s command-finished', async () => {
const { runtime, statusStore, channelOrder, endedRunFacts } = await startRun()
readCommandLineMock.mockResolvedValue('ls')

runtime.onPtyData('pty-1', '\x1b]133;C\x07', 101)

expect(hostRows(statusStore)).toEqual([])
expect(channelOrder).toContain(`clear:${PANE_KEY}`)
expect(endedRunFacts).toEqual([endedRunFact(null)])
})

it('ends the pane’s prompt lifecycle with the run', async () => {
const { runtime } = await startRun()
const lifecycle = () => runtime['agentPromptLifecycleByPtyId'].get('pty-1')?.status
expect(lifecycle()).toBe('working')

runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 })

expect(lifecycle()).toBeNull()
})

// OpenCode 1 `run` loads its plugin, so a hook owns the row; it still ends with the process.
it.each([
['visible', 'chunk'],
['hidden', 'daemon fact']
])('clears a %s hook-owned run on exit and keeps its resume identity', async (_l, path) => {
const { runtime, statusStore, channelOrder } = createRuntime()
runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100)
await vi.advanceTimersByTimeAsync(1)
pluginReport(statusStore, 'working')
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)
pluginReport(statusStore, 'done')
expect(hostRows(statusStore)).toEqual(['done'])

if (path === 'chunk') {
runtime.onPtyData('pty-1', 'done\x1b]133;D;0\x07', 101)
} else {
runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 })
}

expect(hostRows(statusStore)).toEqual([])
expect(channelOrder).toContain(`clear:${PANE_KEY}`)
// Why: the shell outlived the run, so the pane can still resume that session.
expect(resumeRemnants(statusStore)).toEqual(['ses_resume'])
})

it('leaves the row of a pane whose foreground was never `opencode run` to its own agent', async () => {
readCommandLineMock.mockResolvedValue('opencode')
const { runtime, statusStore, endedRunFacts } = createRuntime()
runtime.onPtyData(
'pty-1',
'\x1b]9999;{"state":"done","prompt":"ship it","agentType":"opencode"}\x07',
99
)
runtime.onPtyData('pty-1', '\x1b]133;C\x07', 100)
await vi.advanceTimersByTimeAsync(FOREGROUND_COMMAND_READS.settleMs)

runtime.emitDaemonPtyTransientFact('pty-1', { kind: 'command-finished', exitCode: 0 })

expect(hostRows(statusStore)).toEqual(['done'])
expect(endedRunFacts).toEqual([])
})
})
Loading
Loading