From e47e0778f5cd481850ff631aff96d9b8af7cf286 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 16:38:13 -0700 Subject: [PATCH] test(runtime): pin sleeping-agent resume on a failed SSH target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal-state floor in workspace-terminal-host-authority.ts has three consumers: initial-terminal seeding, the startup terminal watcher, and sleeping-agent resume. Seeding is covered end to end by worktree-agent-activation-seam.test.ts. Resume was covered only at the predicate, so nothing failed if the floor stopped reaching it — and the floor's own comment says the cost of losing it is a failed target left terminal-less with unresumable agents for the rest of the app session. Pins the resume half directly: an SSH git worktree on a target whose sync terminated in offline/error with an empty hydrated set resumes its sleeping agent. Two controls keep the floor from widening into "resume whenever we are unsure" — an in-flight 'pulling' sync and no sync status at all both stay unverifiable and resume nothing. Verified by mutation: emptying TERMINATED_WITHOUT_ANSWER_PHASES fails exactly the two floor assertions and leaves both controls passing. Routes independently of the two fixes on this branch: the floor predates this stack (#16750), and this only closes a coverage gap in it. --- ...ailed-target-sleeping-agent-resume.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts diff --git a/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts b/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts new file mode 100644 index 00000000000..a94c0438231 --- /dev/null +++ b/src/renderer/src/lib/ssh-failed-target-sleeping-agent-resume.test.ts @@ -0,0 +1,115 @@ +/** + * The resume half of the terminal-state floor. + * + * `workspace-terminal-host-authority.ts` says an SSH target whose sync terminated in + * `offline`/`error` without ever hydrating answers `none`, so this client may act. The seeding + * consumer is covered end to end (worktree-agent-activation-seam.test.ts); the sleeping-agent + * consumer (resume-sleeping-agent-session.ts) was only covered at the predicate. Without this, + * a failed target's agents stay unresumable for the rest of the app session and nothing fails. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/store-test-helpers' +import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +const initialAppStoreState = useAppStore.getState() +const TARGET_ID = 'ssh-target-1' +const WORKTREE_ID = 'repoSsh::/srv/proj/feature' + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +function seedFailedSshTarget(phase?: 'offline' | 'error' | 'pulling'): void { + const tab: TerminalTab = { + id: 'tab-1', + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const record: SleepingAgentSessionRecord = { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'pi', + providerSession: { key: 'session_id', id: 'pi-session-1', transcriptPath: '/tmp/pi-1.jsonl' }, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + useAppStore.setState({ + repos: [ + { + id: 'repoSsh', + path: '/srv/proj', + displayName: 'repoSsh', + badgeColor: '#000', + addedAt: 0, + connectionId: TARGET_ID + } + ] as never, + worktreesByRepo: { + repoSsh: [ + makeWorktree({ + id: WORKTREE_ID, + repoId: 'repoSsh', + path: '/srv/proj/feature', + hostId: `ssh:${TARGET_ID}` + } as never) + ] + }, + remoteWorkspaceHydratedTargetIds: new Set(), + remoteWorkspaceSyncStatusByTargetId: + phase === undefined ? {} : { [TARGET_ID]: { phase, direction: 'pull' as const } }, + tabsByWorktree: { [WORKTREE_ID]: [tab] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + }) +} + +describe('sleeping-agent resume on a failed SSH target', () => { + it.each(['offline', 'error'] as const)( + 'resumes a sleeping agent once a sync terminates in %s without ever hydrating', + (phase) => { + seedFailedSshTarget(phase) + + expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe( + 'none' + ) + // The gate this exists for: a target that failed must not stay unresumable for the session. + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined() + } + ) + + it('still declines to resume while the host has not answered', () => { + // Control: an in-flight sync is `unverifiable`, and resuming there forks a session the host + // may still be running. The floor must not widen into "resume whenever we are unsure". + seedFailedSshTarget('pulling') + + expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe( + 'unverifiable' + ) + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeDefined() + }) + + it('still declines to resume when no sync status exists at all', () => { + seedFailedSshTarget(undefined) + + expect(resolveWorkspaceTerminalHostAuthority(useAppStore.getState(), WORKTREE_ID)).toBe( + 'unverifiable' + ) + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + }) +})