From 24bec2d260ea7f7557a8b847ae8de1f4ca2839b4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:53:32 -0700 Subject: [PATCH] fix(terminal): preserve wake records after hibernation exit (#29288) --- ...-hibernation-kill-shell-foreground.test.ts | 302 ++++++++++++++++++ .../pty-connection/pane-agent-identity.ts | 12 +- 2 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts new file mode 100644 index 00000000000..20c187dd681 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-hibernation-kill-shell-foreground.test.ts @@ -0,0 +1,302 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { createDeferred, flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: useCallback must pass through; no test here renders React. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +// A hibernation kill ends the agent before the PTY, so the pane sees its own shell for a moment. +describe('shell showing through a hibernation kill', () => { + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + await installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function connectHibernatingPane(suppressExit: boolean): Promise<{ + finishCommand: () => void + exitPty: () => void + rebindPty: (id: string | null) => void + paneKey: string + record: SleepingAgentSessionRecord + }> { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-hibernating' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const args = [ + createPane(1), + createManager(1), + createDeps({ + isVisibleRef: { current: false }, + consumeSuppressedPtyExit: vi.fn((id: string) => { + const suppressed = mockStoreState.suppressedPtyExitIds[id] === true + delete mockStoreState.suppressedPtyExitIds[id] + return suppressed + }) + }) + ] as unknown as Parameters + connectPanePty(...args) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' } + } + // Hibernation writes the wake record and suppresses the exit before it kills the PTY. + const record: SleepingAgentSessionRecord = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernating' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = record + if (suppressExit) { + mockStoreState.suppressedPtyExitIds[ptyId] = true + } + return { + paneKey, + record, + finishCommand: () => dataCallbackRef.current?.('\x1b]133;D;0\x07'), + rebindPty: (id: string | null) => transport.getPtyId.mockReturnValue(id), + exitPty: () => { + const onPtyExit = createdTransportOptions[0]?.onPtyExit + if (typeof onPtyExit === 'function') { + onPtyExit(ptyId) + } + } + } + } + + it('keeps the wake record while the kill is in flight', async () => { + const pane = await connectHibernatingPane(true) + + pane.finishCommand() + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.paneForegroundAgentByPaneKey[pane.paneKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[pane.paneKey]).toMatchObject({ + providerSession: { id: 'sess-hibernating' } + }) + }) + + it.each(['pty-hibernating', null])( + 'preserves the wake record when a pending confirmation settles with PTY %s', + async (boundPtyId) => { + const pane = await connectHibernatingPane(true) + const foreground = createDeferred() + vi.mocked(window.api.pty.confirmForegroundProcess).mockReturnValue(foreground.promise) + + pane.finishCommand() + await vi.advanceTimersByTimeAsync(350) + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledExactlyOnceWith( + 'pty-hibernating' + ) + pane.exitPty() + pane.rebindPty(boundPtyId) + await flushAsyncTicks() + expect(mockStoreState.suppressedPtyExitIds).toEqual({}) + foreground.resolve('zsh') + await flushAsyncTicks() + + // A detached transport rejects the stale read; a retained binding reaches shell cleanup. + expect(mockStoreState.paneForegroundAgentByPaneKey[pane.paneKey]?.shellForeground).toBe( + boundPtyId === null ? undefined : true + ) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + expect(mockStoreState.sleepingAgentSessionsByPaneKey[pane.paneKey]).toMatchObject({ + providerSession: { id: 'sess-hibernating' } + }) + } + ) + + it('clears the record once another PTY is bound in place of the hibernated one', async () => { + const pane = await connectHibernatingPane(true) + + pane.exitPty() + await flushAsyncTicks() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + // A restart binds a new PTY without consuming the armed wake. + pane.rebindPty('pty-restarted') + pane.finishCommand() + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledExactlyOnceWith(pane.paneKey) + }) + + it('still clears a record beside a PTY that is not being hibernated', async () => { + const pane = await connectHibernatingPane(false) + + pane.finishCommand() + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledExactlyOnceWith(pane.paneKey) + }) + + it('does not protect a replacement record with the old wake target', async () => { + const pane = await connectHibernatingPane(true) + + pane.exitPty() + await flushAsyncTicks() + mockStoreState.sleepingAgentSessionsByPaneKey[pane.paneKey] = { + ...pane.record, + updatedAt: 2 + } + pane.finishCommand() + await vi.advanceTimersByTimeAsync(350) + + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledExactlyOnceWith(pane.paneKey) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index bd5437ba430..88ea0b68472 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -203,14 +203,16 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { session.agentCompletionCoordinator?.observeForegroundAgentProcess(process), hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, onConfirmedShellForeground: (reason) => { - // Why: a confirmed local shell proves any hibernation record for this pane is stale; - // otherwise the tab resolver can repaint the exited agent from sleeping occupancy. + // Retire stale agent occupancy when a normal exit returns to the shell. const state = useAppStore.getState() const sleepingRecord = session.getSleepingRecordForPane(state) const ptyId = session.transport.getPtyId() - // Why: a shell surfacing while Orca stops this PTY is that stop, not the agent exiting; a - // hibernation records the pane's resume before its kill, so this would delete it. - if (sleepingRecord && !(ptyId && state.suppressedPtyExitIds[ptyId])) { + const wake = session.hibernatedWakeTarget + // A delayed shell read can settle after exit consumes suppression; preserve only that PTY's exact wake record. + const isHibernationWakeRecord = + (ptyId && state.suppressedPtyExitIds[ptyId]) || + (wake && wake.record === sleepingRecord?.record && (ptyId === null || wake.ptyId === ptyId)) + if (sleepingRecord && !isHibernationWakeRecord) { session.clearSleepingRecordProviderDuplicates(state, sleepingRecord) } session.clearStaleAgentTabTitleOnConfirmedShell()