diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 6c0e9bbad227..06e8f1112ca6 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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 diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts index 1f399e0464f0..7f4f2c05fa98 100644 --- a/src/main/runtime/agent-status-store-wiring.test-fixture.ts +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -26,7 +26,7 @@ export function makeAgentStatusStoreWiring(): { paneKey: string ) => ReturnType reconcileAgentStatusForEndedProcess: ( - paneKeys: Parameters[0] + ...args: Parameters ) => void } /** Call once the runtime exists; returns the republish teardown. */ @@ -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) diff --git a/src/main/runtime/opencode-run-lifetime-runtime.test.ts b/src/main/runtime/opencode-run-lifetime-runtime.test.ts index 4475abdb49fc..1baac0c843a9 100644 --- a/src/main/runtime/opencode-run-lifetime-runtime.test.ts +++ b/src/main/runtime/opencode-run-lifetime-runtime.test.ts @@ -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' @@ -18,16 +20,34 @@ function createRuntime(): { runtime: OrcaRuntimeService statuses: RuntimeTerminalAgentStatusEvent[] channelOrder: string[] + endedRunFacts: { paneKey?: string; fact: TerminalSideEffectFact }[] + statusStore: ReturnType['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(), @@ -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['statusStore']): string[] => + statusStore + .getStatusSnapshot() + .filter((row) => row.paneKey === PANE_KEY && row.providerSessionOnly !== true) + .map((row) => row.state) + +const resumeRemnants = (statusStore: ReturnType['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['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(() => { @@ -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 () => { @@ -131,7 +161,7 @@ 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) @@ -139,3 +169,150 @@ describe('OpenCode run process lifetime in the runtime', () => { } }) }) + +// 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> { + 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([]) + }) +}) diff --git a/src/main/runtime/opencode-run-lifetime-status.test.ts b/src/main/runtime/opencode-run-lifetime-status.test.ts index efaf0d8f0c44..fc3a27eee3b0 100644 --- a/src/main/runtime/opencode-run-lifetime-status.test.ts +++ b/src/main/runtime/opencode-run-lifetime-status.test.ts @@ -14,6 +14,8 @@ function setup( } = {} ) { const published: Published[] = [] + const events: string[] = [] + const announced: { ptyId: string; agentType: string; interrupted: boolean }[] = [] const readForegroundProcessName = vi.fn(async () => options.name === undefined ? 'opencode' : options.name ) @@ -27,15 +29,26 @@ function setup( (options.enabledAgents ?? ['opencode', 'opencode2']).includes(agent), readForegroundProcessName, readForegroundCommandLine, - publish: (ptyId, payload, yieldsToHookSince) => - published.push({ ptyId, payload, yieldsToHookSince }), + publish: (ptyId, payload, yieldsToHookSince) => { + published.push({ ptyId, payload, yieldsToHookSince }) + events.push(`${ptyId}:${payload.state}`) + }, + clearEndedRun: (ptyId) => events.push(`${ptyId}:cleared`), + announceEndedRun: (ptyId, run) => announced.push({ ptyId, ...run }), now: () => clock++ }) + // pty-1's writes and clears in order, without the pane prefix. const states = (): string[] => - published.map(({ payload }) => - payload.interrupted ? `${payload.state}:interrupted` : payload.state - ) - return { lifetime, published, states, readForegroundProcessName, readForegroundCommandLine } + events.filter((event) => event.startsWith('pty-1:')).map((event) => event.slice(6)) + return { + lifetime, + published, + events, + announced, + states, + readForegroundProcessName, + readForegroundCommandLine + } } async function settle(): Promise { @@ -51,18 +64,18 @@ describe('OpenCodeRunLifetimeStatus', () => { vi.useRealTimers() }) - it('shows Working while `opencode run` holds the foreground and Done when it exits', async () => { + it('shows Working while `opencode run` holds the foreground and clears it when it exits', async () => { const { lifetime, published, states } = setup() lifetime.onCommandStarted('pty-1') expect(states()).toEqual([]) await settle() expect(states()).toEqual(['working']) lifetime.onCommandFinished('pty-1', 0) - expect(states()).toEqual(['working', 'done']) - expect(published.map((entry) => entry.ptyId)).toEqual(['pty-1', 'pty-1']) - expect(published.map((entry) => entry.payload.agentType)).toEqual(['opencode', 'opencode']) - // Both writes yield to a hook that reports after this command started. - expect(new Set(published.map((entry) => entry.yieldsToHookSince))).toEqual(new Set([1_000])) + // Why no Done: Done means a live TUI finished its turn; the run's process has exited. + expect(states()).toEqual(['working', 'cleared']) + expect(published.map((entry) => entry.payload.agentType)).toEqual(['opencode']) + // The write yields to a hook that reports after this command started. + expect(published[0]?.yieldsToHookSince).toBe(1_000) }) it('reports `opencode2 run` as OpenCode 2', async () => { @@ -75,15 +88,32 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(published[0]?.payload.agentType).toBe('opencode2') }) - it('marks a Ctrl-C exit as interrupted and any other exit as a plain Done', async () => { - const { lifetime, states } = setup() + // Why: the exit is the run's only turn end, so it carries the completion notification. + it('announces each ended run, as interrupted only for a Ctrl+C exit', async () => { + const { lifetime, announced } = setup() lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandFinished('pty-1', 130) lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandFinished('pty-1', 1) - expect(states()).toEqual(['working', 'done:interrupted', 'working', 'done']) + expect(announced).toEqual([ + { ptyId: 'pty-1', agentType: 'opencode', interrupted: true }, + { ptyId: 'pty-1', agentType: 'opencode', interrupted: false } + ]) + }) + + it('clears each run once, so a second run in the pane shows Working again', async () => { + const { lifetime, states, announced } = setup() + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandFinished('pty-1', 0) + lifetime.onCommandStarted('pty-1') + await settle() + lifetime.onCommandFinished('pty-1', 0) + expect(states()).toEqual(['working', 'cleared', 'working', 'cleared']) + expect(announced).toHaveLength(2) }) it('reads no argv unless the foreground process is OpenCode', async () => { @@ -134,11 +164,12 @@ describe('OpenCodeRunLifetimeStatus', () => { it('leaves the OpenCode TUI and its other subcommands to their own reporters', async () => { for (const commandLine of ['opencode', 'opencode serve', 'opencode attach http://x', null]) { - const { lifetime, states } = setup({ commandLine }) + const { lifetime, states, announced } = setup({ commandLine }) lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandFinished('pty-1', 0) expect(states()).toEqual([]) + expect(announced).toEqual([]) } }) @@ -150,12 +181,13 @@ describe('OpenCodeRunLifetimeStatus', () => { }) it('stays silent for a command that finishes before the settle read (`&`, fast exit)', async () => { - const { lifetime, states, readForegroundProcessName } = setup() + const { lifetime, states, announced, readForegroundProcessName } = setup() lifetime.onCommandStarted('pty-1') lifetime.onCommandFinished('pty-1', 0) await settle() expect(readForegroundProcessName).not.toHaveBeenCalled() expect(states()).toEqual([]) + expect(announced).toEqual([]) }) it('never arms from a read that a newer command superseded', async () => { @@ -200,17 +232,19 @@ describe('OpenCodeRunLifetimeStatus', () => { expect(states()).toEqual([]) }) - it('ends an armed run with Done when the next command starts without its 133;D', async () => { - const { lifetime, states } = setup() + it('clears an armed run when the next command starts without its 133;D', async () => { + const { lifetime, states, announced } = setup() lifetime.onCommandStarted('pty-1') await settle() lifetime.onCommandStarted('pty-1') - expect(states()).toEqual(['working', 'done']) + expect(states()).toEqual(['working', 'cleared']) + expect(announced).toEqual([{ ptyId: 'pty-1', agentType: 'opencode', interrupted: false }]) await settle() - expect(states()).toEqual(['working', 'done', 'working']) + expect(states()).toEqual(['working', 'cleared', 'working']) }) - it('posts no Done for a pane torn down mid-run', async () => { + // Why: the PTY's own exit already retires the pane's row through the runtime. + it('clears nothing for a pane torn down mid-run', async () => { const { lifetime, states } = setup() lifetime.onCommandStarted('pty-1') await settle() @@ -220,15 +254,11 @@ describe('OpenCodeRunLifetimeStatus', () => { }) it('keeps each pane to its own command', async () => { - const { lifetime, published } = setup() + const { lifetime, events } = setup() lifetime.onCommandStarted('pty-1') lifetime.onCommandStarted('pty-2') await settle() lifetime.onCommandFinished('pty-2', 0) - expect(published.map((entry) => `${entry.ptyId}:${entry.payload.state}`)).toEqual([ - 'pty-1:working', - 'pty-2:working', - 'pty-2:done' - ]) + expect(events).toEqual(['pty-1:working', 'pty-2:working', 'pty-2:cleared']) }) }) diff --git a/src/main/runtime/opencode-run-lifetime-status.ts b/src/main/runtime/opencode-run-lifetime-status.ts index 304e020f31ef..bdb69534e65b 100644 --- a/src/main/runtime/opencode-run-lifetime-status.ts +++ b/src/main/runtime/opencode-run-lifetime-status.ts @@ -33,6 +33,10 @@ type Dependencies = { readForegroundCommandLine(ptyId: string, foregroundProcess: string): Promise /** `yieldsToHookSince`: the store drops this write once a hook reported the pane since then. */ publish(ptyId: string, payload: ParsedAgentStatusPayload, yieldsToHookSince: number): void + /** The run's process exited back to the shell: retire the pane's row as any ended agent's. */ + clearEndedRun(ptyId: string): void + /** The exit is the run's only turn end, so it is what completion notifications announce. */ + announceEndedRun(ptyId: string, run: { agentType: OpenCodeAgent; interrupted: boolean }): void now(): number } @@ -45,8 +49,8 @@ type CommandState = { /** * Reports `opencode run` from its own process lifetime: Working once the pane's foreground - * command is an OpenCode `run`, Done when that command finishes. OpenCode 2's `run` loads no - * plugin, so nothing else can say which pane it runs in. + * command is an OpenCode `run`, and no row once that command finishes, since nothing runs there + * anymore. OpenCode 2's `run` loads no plugin, so nothing else can say which pane it runs in. */ export class OpenCodeRunLifetimeStatus { private readonly commands = new Map() @@ -79,15 +83,11 @@ export class OpenCodeRunLifetimeStatus { if (!state?.armed) { return } - const payload = normalizeAgentStatusPayload({ - state: 'done', - prompt: '', + this.deps.clearEndedRun(ptyId) + this.deps.announceEndedRun(ptyId, { agentType: state.armed, - ...(exitCode === SIGINT_EXIT_CODE ? { interrupted: true } : {}) + interrupted: exitCode === SIGINT_EXIT_CODE }) - if (payload) { - this.deps.publish(ptyId, payload, state.startedAt) - } } forgetPty(ptyId: string): void { diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index dbbb124d34aa..9d948c51caea 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,12 +18,13 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': + // Why before retiring: retirement deletes the pane's rows without telling the renderer. + this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode }) - this.openCodeRunLifetime.onCommandFinished(ptyId, fact.exitCode) return case 'pr-link': this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 2e6f30770b0f..0dfd8888d447 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -147,9 +147,10 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime }, onCommandFinished: (exitCode: number | null) => { void this.recheckHookAgentPresenceForPty(ptyId) + // Why before retiring: retirement deletes the pane's rows without telling the renderer. + this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) - this.openCodeRunLifetime.onCommandFinished(ptyId, exitCode) }, onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -175,7 +176,6 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime lastTitleFactAtMs: null, chunkTouchedSessionTabs: false, pendingFacts: [], - afterFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99ae..81e4acf2e209 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -95,7 +95,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | null protected readonly reconcileAgentStatusForEndedProcessFn: - | ((paneKeys: Iterable) => void) + | ((paneKeys: Iterable, options?: { preserveResumeIdentity?: boolean }) => void) | null protected readonly canRecoverPersistentLocalPtysFn: () => boolean diff --git a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts index 5f874cbc182a..f62192179251 100644 --- a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts +++ b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts @@ -131,26 +131,12 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt ptyId: string, entry: RuntimePtyTitleTrackerEntry ): void { - if (entry.pendingFacts.length > 0) { - const facts = entry.pendingFacts - entry.pendingFacts = [] - this.emitTerminalSideEffectBatch(ptyId, facts) - } - const afterFacts = entry.afterFacts - entry.afterFacts = [] - for (const run of afterFacts) { - run() - } - } - - /** Runs `run` after the chunk being applied has emitted its facts, or now between chunks. */ - protected runAfterPendingTerminalSideEffectFacts(ptyId: string, run: () => void): void { - const entry = this.ptyTitleTrackersByPtyId.get(ptyId) - if (entry?.applyingChunk) { - entry.afterFacts.push(run) + if (entry.pendingFacts.length === 0) { return } - run() + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) } /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ca95bf9dc270..ab43cd300afa 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -85,7 +85,10 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { checkHookAgentPresence?: ( paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> - reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void + reconcileAgentStatusForEndedProcess?: ( + paneKeys: Iterable, + options?: { preserveResumeIdentity?: boolean } + ) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; // a closure defers the lookup past that ordering instead of inverting ownership. diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 1e54583435c6..29277abe18ee 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -179,16 +179,27 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId }, readForegroundCommandLine: (ptyId, foregroundProcess) => readLocalPtyForegroundCommandLine(ptyId, foregroundProcess), - // Why after the chunk's facts: the renderer drops an exited agent's row on command-finished - // unless the row changed after it, so the run's Done must arrive after that fact. publish: (ptyId, payload, yieldsToHookSince) => - this.runAfterPendingTerminalSideEffectFacts(ptyId, () => - this.emitTerminalAgentStatusEvents( - ptyId, - { payloads: [payload] }, - { origin: 'process', yieldsToHookSince } - ) + this.emitTerminalAgentStatusEvents( + ptyId, + { payloads: [payload] }, + { origin: 'process', yieldsToHookSince } ), + // Why the ended-process path: it clears every reader, renderer included. The shell outlived + // the run, so a row that carries a resume identity (a hook-owned OpenCode 1 run) keeps it. + clearEndedRun: (ptyId) => { + // Why: while this reads `working`, prompt-send verification takes any output as delivery. + this.recordAgentPromptLifecycleState(ptyId, null) + this.reconcileAgentStatusForEndedProcessFn?.(this.collectAgentStatusPaneKeysForPty(ptyId), { + preserveResumeIdentity: true + }) + }, + announceEndedRun: (ptyId, run) => + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-run-ended', + agentType: run.agentType, + ...(run.interrupted ? { interrupted: true } : {}) + }), now: () => Date.now() }) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d3fd9a0d7b34..265bdf1712a8 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -112,8 +112,6 @@ export type RuntimePtyTitleTrackerEntry = { lastTitleFactAtMs: number | null chunkTouchedSessionTabs: boolean pendingFacts: TerminalSideEffectFact[] - /** Run once this chunk's facts are emitted: status that readers must see after them. */ - afterFacts: (() => void)[] commandCodeDetector: { observe: (data: string) => boolean } | null } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa294020..a63c3f272007 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -118,8 +118,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + reconcileAgentStatusForEndedProcess: (paneKeys, options) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys, options), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: evaluated per call, not captured — the RPC server that owns the device registry is // constructed with this runtime and does not exist yet at this point. diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index c56e77b5bc7b..c0c34cdd2a45 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -65,6 +65,8 @@ export type AgentCompletionCoordinator = { observeTitleWorking: () => void observeOutputActivity: () => void observeHookStatus: (payload: AgentCompletionStatusSnapshot) => void + /** A run reported only by its process lifetime ended: a `done` for the turn still open, if any. */ + observeAgentRunEnded: (payload: AgentCompletionStatusSnapshot) => void seedHookStatus: (payload: AgentCompletionStatusSnapshot) => void startProcessTracking: () => void /** Another reader saw this agent in the foreground; lets the monitor confirm its exit. */ diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 24df1dce1aea..50e644d85744 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -315,6 +315,7 @@ export function createAgentCompletionCoordinator( observeTitleWorking, observeOutputActivity, observeHookStatus: hookObserver.observeHookStatus, + observeAgentRunEnded: hookObserver.observeAgentRunEnded, seedHookStatus: hookObserver.seedHookStatus, startProcessTracking: () => processMonitor.start(), observeForegroundAgentProcess: processMonitor.observeRecognizedProcess, diff --git a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts index 698747a612eb..9788a962231b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-hook-observer.ts @@ -198,6 +198,13 @@ export function createAgentCompletionHookObserver({ return { observeHookStatus, + observeAgentRunEnded: (payload: AgentCompletionStatusSnapshot) => { + // Why: a run's exit completes only a turn this lane saw open and no hook Done is completing; + // a Done in its quiet window carries the richer payload and the notification id. + if (state.workingStatusObserved && state.pendingHookDoneTimer === null) { + observeHookStatus(payload) + } + }, seedHookStatus: (payload: AgentCompletionStatusSnapshot) => { const { turnCompletedAt, ...unstampedPayload } = payload if (isFiniteTurnCompletedAt(turnCompletedAt)) { diff --git a/src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts b/src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts new file mode 100644 index 000000000000..20f0b697aa9a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-run-ended-cross-lane.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + processResult, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +// A visible pane polling its foreground, beside the hook lane that announces the run's end. +function createLanes() { + let foreground: string | null = 'opencode' + const paneDispatch = vi.fn() + const hookDispatch = vi.fn() + const paneLane = createAgentCompletionCoordinator({ + paneKey: PANE_KEY, + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foreground)), + dispatchCompletion: paneDispatch, + isLive: () => true, + shouldPollProcessCadence: () => true + }) + paneLane.startProcessTracking() + const hookLane = createAgentCompletionCoordinator({ + paneKey: PANE_KEY, + statusLane: 'hook', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(null)), + dispatchCompletion: hookDispatch, + isLive: () => true + }) + hookLane.observeHookStatus({ + state: 'working', + prompt: '', + agentType: 'opencode', + stateStartedAt: Date.now() + }) + return { + paneDispatch, + hookDispatch, + exitProcess: () => { + foreground = null + }, + announceEnd: () => + hookLane.observeAgentRunEnded({ + state: 'done', + prompt: '', + agentType: 'opencode', + stateStartedAt: Date.now() + }) + } +} + +// Why: the pane's own process-exit completion shares the per-pane identity with the hook lane, +// so whichever lane announces the run first, the other adds nothing. +describe('ended-run announcement beside the pane process monitor', () => { + useAgentCompletionCoordinatorLifecycle() + + it('keeps the process-exit completion silent after the run’s end was announced', async () => { + const lanes = createLanes() + await vi.advanceTimersByTimeAsync(10_000) + + lanes.exitProcess() + lanes.announceEnd() + await vi.advanceTimersByTimeAsync(10_000) + + expect(lanes.hookDispatch).toHaveBeenCalledTimes(1) + expect(lanes.paneDispatch).not.toHaveBeenCalled() + }) + + it('announces nothing more when the process-exit completion came first', async () => { + const lanes = createLanes() + await vi.advanceTimersByTimeAsync(10_000) + + lanes.exitProcess() + await vi.advanceTimersByTimeAsync(10_000) + expect(lanes.paneDispatch).toHaveBeenCalledTimes(1) + lanes.announceEnd() + await vi.advanceTimersByTimeAsync(10_000) + + expect(lanes.hookDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts index 2b92e607d227..19af8539b0ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-late-done.test.ts @@ -129,8 +129,8 @@ function createDeps(overrides: Record = {}) { return buildPaneConnectionDeps(() => mockStoreState, overrides) } -// A process-lifetime producer (OpenCode 2 `opencode run`) posts the run's Done after the -// command-finished fact; the exited-agent drop must keep it, as it keeps a late hook Done. +// The exited-agent drop removes only the row it captured at command-finished; a late hook Done +// that changed the row after that fact is kept. describe('command-finished drop and a Done that lands after it', () => { beforeEach(() => { vi.resetModules() diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 527fbb46b95c..1bc25a658793 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -19,6 +19,7 @@ import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' +import { observeEndedAgentRunForNotification } from '@/hooks/agent-run-ended-notifications' // Why: cached once per session — the blocking read should only ever run on // the pre-hydration startup path, never per pane bind. @@ -106,6 +107,9 @@ let channelUnsubscribe: (() => void) | null = null function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { switch (fact.kind) { + case 'agent-run-ended': + // Why: announced once per batch before the consumer lookup, not per consumer. + return case 'agent-status': entry.callbacks.onAgentStatus?.(fact.payload) return @@ -257,7 +261,25 @@ function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void { } } +// Why before the consumer lookup: the run's end must notify like a hook Done does, in panes +// that have no fact consumer (unmounted, never shown, or a visible remote-runtime pane). +function announceEndedAgentRuns(batch: TerminalSideEffectBatch): void { + if (batch.replay || !batch.paneKey) { + return + } + for (const fact of batch.facts) { + if (fact.kind === 'agent-run-ended') { + observeEndedAgentRunForNotification({ + paneKey: batch.paneKey, + agentType: fact.agentType, + interrupted: fact.interrupted === true + }) + } + } +} + export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void { + announceEndedAgentRuns(batch) const entry = consumersByPtyId.get(batch.ptyId) if (!entry) { bufferHandoffFactBatch(batch) diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 8b43478818c8..65337997a8cf 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -320,6 +320,14 @@ export function observeAgentHookCompletionForNotification({ } } +/** The coordinator a live pane's hook lane already has; never creates one. */ +export function getLiveAgentHookCompletionCoordinator( + paneKey: string +): AgentCompletionCoordinator | null { + const entry = coordinatorsByPaneKey.get(paneKey) + return entry && paneCanReceiveHookCompletion(paneKey) ? entry.coordinator : null +} + export function resetAgentHookCompletionNotificationCoordinators(): void { for (const entry of coordinatorsByPaneKey.values()) { entry.coordinator.dispose() diff --git a/src/renderer/src/hooks/agent-run-ended-notifications.test.ts b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts new file mode 100644 index 000000000000..b1f129eed25e --- /dev/null +++ b/src/renderer/src/hooks/agent-run-ended-notifications.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' + +const dispatchTerminalNotification = vi.fn() +const dispatchAgentHookTerminalLifecycle = vi.fn() + +let mockStoreState: { + settings: { notifications: { enabled: boolean; agentTaskComplete: boolean } } + ptyIdsByTabId: Record + suppressedPtyExitIds: Record + tabsByWorktree: Record + terminalLayoutsByTabId: Record + agentStatusByPaneKey: Record +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +vi.mock('@/components/terminal-pane/use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/components/terminal-pane/agent-hook-terminal-lifecycle', () => ({ + dispatchAgentHookTerminalLifecycle +})) + +const HOOK_DONE_QUIET_MS = 1_500 +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function hookStatus( + state: ParsedAgentStatusPayload['state'], + agentType: string +): ParsedAgentStatusPayload { + return { state, prompt: 'fix the bug', agentType } +} + +// The OpenCode 1 plugin's own Done for the run's turn, as the hook lane receives it. +const pluginDone = { + state: 'done' as const, + prompt: 'fix the bug', + agentType: 'opencode', + lastAssistantMessage: 'Fixed the off-by-one in parser.ts', + stateStartedAt: 1_000 +} + +function endedRunBatch( + agentType: string, + options: { interrupted?: true; replay?: true } = {} +): TerminalSideEffectBatch { + return { + ptyId: 'pty-1', + seq: 1, + paneKey: PANE_KEY, + worktreeId: 'wt-1', + tabId: 'tab-1', + ...(options.replay ? { replay: true } : {}), + facts: [ + { + kind: 'agent-run-ended', + agentType, + ...(options.interrupted ? { interrupted: true } : {}) + }, + { kind: 'command-finished', exitCode: options.interrupted ? 130 : 0 } + ] + } +} + +async function load() { + const notifications = await import('./agent-hook-completion-notifications') + const facts = await import('@/components/terminal-pane/terminal-side-effect-facts-handler') + const observe = (payload: ParsedAgentStatusPayload): void => + notifications.observeAgentHookCompletionForNotification({ + paneKey: PANE_KEY, + worktreeId: 'wt-1', + payload + }) + return { observe, dispatch: facts._dispatchTerminalSideEffectBatchForTest } +} + +// Why: `opencode run` posts no Done (its process exit clears the row), so its end reaches the +// renderer only as a side-effect fact; it must notify like a hook Done, with no consumer bound. +describe('ended agent run notifications', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + dispatchAgentHookTerminalLifecycle.mockClear() + mockStoreState = { + settings: { notifications: { enabled: true, agentTaskComplete: true } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + suppressedPtyExitIds: {}, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1' }] }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {} + } + }) + + afterEach(() => vi.useRealTimers()) + + it('announces a finished run in a pane with no fact consumer, after the quiet window', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2')) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + source: 'agent-task-complete', + paneKey: PANE_KEY, + agentStatusSnapshot: expect.objectContaining({ state: 'done', agentType: 'opencode2' }) + }) + ) + }) + + it('reads a Ctrl+C exit as an interrupted turn', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2', { interrupted: true })) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + 'wt-1', + expect.objectContaining({ + agentStatusSnapshot: expect.objectContaining({ state: 'done', interrupted: true }) + }) + ) + }) + + it('announces nothing for a pane that never showed the run working', async () => { + const { dispatch } = await load() + + dispatch(endedRunBatch('opencode2')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + it('never announces from a replayed batch', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2', { replay: true })) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + // OpenCode 1 `run` loads its plugin, whose own Done announces the turn before the process exits. + it('adds nothing when the plugin Done already announced the run', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(hookStatus('done', 'opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + // The process tears down after its plugin reported idle. + vi.advanceTimersByTime(2_000) + + dispatch(endedRunBatch('opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + + // Why the payload: it carries the body preview and the notification id that acknowledging the row clears. + it('announces the plugin Done’s own turn when the run exits inside its quiet window', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(pluginDone) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS / 2) + + dispatch(endedRunBatch('opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS * 2) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification.mock.calls[0]?.[1].agentStatusSnapshot).toEqual(pluginDone) + }) + + it('announces the plugin Done’s turn when the run’s Ctrl+C exit lands inside its quiet window', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(pluginDone) + + dispatch(endedRunBatch('opencode', { interrupted: true })) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification.mock.calls[0]?.[1].agentStatusSnapshot).toEqual(pluginDone) + }) + + // Why: a newer turn drops a pending hook Done for every agent; the run's end must not revive it. + it('announces only the newer turn when one cancels the plugin Done the run exited under', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode')) + observe(pluginDone) + dispatch(endedRunBatch('opencode')) + + observe(hookStatus('working', 'opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS * 2) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispatch(endedRunBatch('opencode')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification.mock.calls[0]?.[1].agentStatusSnapshot).toMatchObject({ + state: 'done', + agentType: 'opencode' + }) + }) + + it('lets a new turn in the pane cancel the pending announcement', async () => { + const { observe, dispatch } = await load() + observe(hookStatus('working', 'opencode2')) + + dispatch(endedRunBatch('opencode2')) + observe(hookStatus('working', 'opencode2')) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/agent-run-ended-notifications.ts b/src/renderer/src/hooks/agent-run-ended-notifications.ts new file mode 100644 index 000000000000..9e57b428876c --- /dev/null +++ b/src/renderer/src/hooks/agent-run-ended-notifications.ts @@ -0,0 +1,26 @@ +import { + getLiveAgentHookCompletionCoordinator, + syncAgentHookCompletionNotificationSettings +} from './agent-hook-completion-notifications' + +/** Completes the turn of a run reported only by its process lifetime (`opencode run`), which + * posts no Done: its exit is the turn end. Writes no status row. */ +export function observeEndedAgentRunForNotification({ + paneKey, + agentType, + interrupted +}: { + paneKey: string + agentType: string + interrupted: boolean +}): void { + syncAgentHookCompletionNotificationSettings() + // Why: only a lane that saw the run's Working has a turn to complete. + getLiveAgentHookCompletionCoordinator(paneKey)?.observeAgentRunEnded({ + state: 'done', + prompt: '', + agentType, + stateStartedAt: Date.now(), + ...(interrupted ? { interrupted: true } : {}) + }) +} diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index ae014d702300..88df1059b731 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -9,7 +9,7 @@ import { refreshRuntimeEnvironmentSshTargetMetadata } from '@/runtime/runtime-environment-ssh-state' import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events' -import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { toClientTerminalSideEffectBatch } from '@/runtime/remote-terminal-side-effect-batch' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' @@ -82,10 +82,7 @@ export function registerRuntimeClientIpcBridge( return } if (event.type === 'terminalSideEffects') { - dispatchTerminalSideEffectBatch({ - ...event.batch, - ptyId: toRemoteRuntimePtyId(event.batch.ptyId, environmentId) - }) + dispatchTerminalSideEffectBatch(toClientTerminalSideEffectBatch(event.batch, environmentId)) return } if (event.type === 'nativeChatLaunchDraftResolved') { diff --git a/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts b/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts new file mode 100644 index 000000000000..f982273f0eb0 --- /dev/null +++ b/src/renderer/src/runtime/remote-terminal-side-effect-batch.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { remapHostAgentStatus } from './web-session-tabs-sync/agent-status-primitives' +import { + toClientTerminalSideEffectBatch, + toMirroredHostPaneKey +} from './remote-terminal-side-effect-batch' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const HOST_PANE_KEY = makePaneKey('host-tab', LEAF_ID) + +// Why: a host fact's completion must reach the coordinator that this client's mirrored status +// row for the same pane feeds, so both must name the pane alike. +describe('remote terminal side-effect batches', () => { + it('maps a host pane key to the key of the mirrored status row for that pane', () => { + const mirrored = remapHostAgentStatus({ + type: 'terminal', + id: `host-tab::${LEAF_ID}`, + title: 'Terminal', + parentTabId: 'host-tab', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'host-terminal', + agentStatus: { + state: 'working', + prompt: '', + agentType: 'opencode2', + paneKey: HOST_PANE_KEY, + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + } + }) + + expect(toMirroredHostPaneKey(HOST_PANE_KEY)).toBe(mirrored?.paneKey) + }) + + it('maps no key that names no terminal leaf', () => { + expect(toMirroredHostPaneKey('host-tab:1')).toBeNull() + }) + + it('names the batch’s PTY, tab and pane in this client’s ids', () => { + expect( + toClientTerminalSideEffectBatch( + { + ptyId: 'host-pty', + seq: 3, + worktreeId: 'wt-1', + tabId: 'host-tab', + paneKey: HOST_PANE_KEY, + facts: [{ kind: 'agent-run-ended', agentType: 'opencode2' }] + }, + 'env-1' + ) + ).toEqual({ + ptyId: toRemoteRuntimePtyId('host-pty', 'env-1'), + seq: 3, + worktreeId: 'wt-1', + tabId: toWebTerminalSurfaceTabId('host-tab'), + paneKey: toMirroredHostPaneKey(HOST_PANE_KEY), + facts: [{ kind: 'agent-run-ended', agentType: 'opencode2' }] + }) + }) +}) diff --git a/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts b/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts new file mode 100644 index 000000000000..7a554f8a53cd --- /dev/null +++ b/src/renderer/src/runtime/remote-terminal-side-effect-batch.ts @@ -0,0 +1,28 @@ +import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' +import { toMirroredPaneKey } from './web-session-tabs-sync/agent-status-primitives' + +/** A host pane key (`hostTabId:leafId`) as this client's key for the same mirrored pane. */ +export function toMirroredHostPaneKey(hostPaneKey: string): string | null { + const parsed = parsePaneKey(hostPaneKey) + // Why the status rows' own rule: a fact and its pane's row must name the pane alike. + return parsed ? toMirroredPaneKey({ parentTabId: parsed.tabId, leafId: parsed.leafId }) : null +} + +/** A remote host's batch in this client's ids. The host attributes it to host tabs; this client + * keys the same panes by their mirrors, as it does the host's status rows. */ +export function toClientTerminalSideEffectBatch( + batch: TerminalSideEffectBatch, + environmentId: string +): TerminalSideEffectBatch { + const { paneKey, tabId, ...rest } = batch + const mirroredPaneKey = paneKey ? toMirroredHostPaneKey(paneKey) : null + return { + ...rest, + ptyId: toRemoteRuntimePtyId(batch.ptyId, environmentId), + ...(mirroredPaneKey ? { paneKey: mirroredPaneKey } : {}), + ...(tabId ? { tabId: toWebTerminalSurfaceTabId(tabId) } : {}) + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index a3587232e8eb..0b85702de976 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -22,7 +22,7 @@ export function isMirroredAgentStatusOwnedBy( } export function toMirroredPaneKey( - surface: TerminalSurface, + surface: Pick, leafId = surface.leafId ): string | null { if (!isTerminalLeafId(leafId)) { diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 47c416aff5d2..e2b643ba5193 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -5,7 +5,7 @@ * renderer store handler owns notification/unread policy. */ -import type { ParsedAgentStatusPayload } from './agent-status-types' +import type { AgentType, ParsedAgentStatusPayload } from './agent-status-types' import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' /** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not @@ -21,6 +21,9 @@ export type TerminalSideEffectFact = | { kind: 'agent-exited' } /** OSC 133;D — foreground shell command exited (exit code best-effort). */ | { kind: 'command-finished'; exitCode: number | null } + /** An agent run reported only by its process lifetime (`opencode run`) ended. Its row is + * already gone; the exit is the turn's only end, so the renderer announces completion. */ + | { kind: 'agent-run-ended'; agentType: AgentType; interrupted?: true } /** Carries the parsed link so the renderer store consumer never re-parses * the URL (parse drift would break the per-PTY dedupe contract). */ | { kind: 'pr-link'; link: TerminalGitHubPRLink }