diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index fb69c5e78..7d1874e13 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -137,7 +137,7 @@ type StoreState = { | { kind: 'windows-host' } | { kind: 'wsl'; distro: string } }[] - sshConnectionStates: Map + sshConnectionStates: Map cacheTimerByKey: Record settings: { theme?: 'system' | 'dark' | 'light' @@ -5620,285 +5620,6 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) - // #4557: an agent terminal that can't be resumed should come back as a fresh - // agent (prior agent, else default) instead of a blank shell. - const findReattachConnect = (transport: ReturnType) => { - const call = transport.connect.mock.calls.find( - (args) => (args[0] as { sessionId?: string })?.sessionId === 'lost-pty' - ) - return call?.[0] as - | { - command?: string - launchAgent?: string - launchConfig?: { - agentCommand?: string - agentArgs?: string - agentEnv?: Record - } - env?: Record - } - | undefined - } - - const mountColdRestore = async ( - transport: ReturnType, - depsOverrides: Record = {} - ) => { - const { connectPanePty } = await import('./pty-connection') - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { - if (sessionId) { - return { id: 'fresh-pty', coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } } - } - return 'fresh-pty' - }) - transportFactoryQueue.push(transport) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }, - ...depsOverrides - }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - return deps - } - - it('cold-restores a fresh agent from the sleeping record when its session is unresumable', async () => { - const transport = createMockTransport('fresh-pty') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - // Agent known, but no resumable provider session id. - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1 - } - } - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toMatch(/^codex /) - expect(reattach?.command).not.toContain('resume') - expect(reattach?.launchAgent).toBe('codex') - expect(reattach?.env?.ORCA_AGENT_LAUNCH_TOKEN).toMatch(new RegExp(`^${UUID_RE}$`)) - }) - - it('cold-restores a fresh agent with the sleeping record launch config when resume is unavailable', async () => { - const transport = createMockTransport('fresh-pty') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: { codex: 'current-codex' }, - agentDefaultArgs: { codex: '--current-args' } - }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - launchConfig: { - agentCommand: 'sleeping-codex --stored-args', - agentArgs: '--stored-args', - agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } - } - } - } - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toBe('sleeping-codex --stored-args') - expect(reattach?.launchAgent).toBe('codex') - expect(reattach?.launchConfig).toMatchObject({ - agentCommand: 'sleeping-codex --stored-args', - agentArgs: '--stored-args', - agentEnv: { CODEX_HOME: '/tmp/orca-stored-codex' } - }) - expect(reattach?.env).toMatchObject({ - CODEX_HOME: '/tmp/orca-stored-codex', - ORCA_AGENT_LAUNCH_TOKEN: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) - }) - }) - - it('uses the known SSH remote platform when fresh-launching an unresumable agent', async () => { - const transport = createMockTransport('fresh-pty') - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude-agent-teams' }] - }, - repos: [{ id: 'repo1', connectionId: 'ssh-win' }], - sshConnectionStates: new Map([['ssh-win', { status: 'connected', remotePlatform: 'win32' }]]), - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: {} - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toMatch(/^orca\.cmd claude-teams/) - expect(reattach?.command).not.toContain('orca-ide') - expect(reattach?.launchAgent).toBe('claude-agent-teams') - }) - - it('cold-restores the prior agent from the persisted tab launchAgent after a restart', async () => { - const transport = createMockTransport('fresh-pty') - mockStoreState = { - ...mockStoreState, - // After a restart there is no live status nor sleeping record — only the - // persisted tab.launchAgent tells us this terminal ran an agent. - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty', launchAgent: 'claude' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: {} - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toContain('claude') - expect(reattach?.command).not.toContain('resume') - expect(reattach?.launchAgent).toBe('claude') - }) - - it('cold-restores the default agent when an agent terminal has no recoverable agent', async () => { - const transport = createMockTransport('fresh-pty') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, - agentStatusByPaneKey: {}, - // A record exists (it was an agent terminal) but carries no usable agent id. - sleepingAgentSessionsByPaneKey: { - [paneKey]: { paneKey, tabId: 'tab-1', worktreeId: 'wt-1', capturedAt: 1, updatedAt: 1 } - } - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toMatch(/^codex /) - expect(reattach?.command).not.toContain('resume') - expect(reattach?.launchAgent).toBe('codex') - }) - - it('does not cold-restore a fresh agent from only a completed live status row', async () => { - const transport = createMockTransport('fresh-pty') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {}, defaultTuiAgent: 'codex' }, - agentStatusByPaneKey: { - [paneKey]: { - agentType: 'codex', - paneKey, - prompt: 'done task', - state: 'done', - stateHistory: [], - stateStartedAt: 1, - updatedAt: 1 - } - }, - sleepingAgentSessionsByPaneKey: {} - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toBeUndefined() - expect(reattach?.launchAgent).toBeUndefined() - }) - - it('ignores malformed sleeping launch config when a live resumable status exists', async () => { - const transport = createMockTransport('fresh-pty') - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: { - [paneKey]: { - agentType: 'codex', - paneKey, - prompt: 'live task', - providerSession: { key: 'session_id', id: 'live-session' }, - state: 'working', - stateHistory: [], - stateStartedAt: 1, - updatedAt: 1 - } - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - prompt: 'malformed sleeping task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - launchConfig: { - agentCommand: 'malformed-sleeping-codex', - agentArgs: '', - agentEnv: {} - } - } - } - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toContain("'resume' 'live-session'") - expect(reattach?.command).not.toBe('malformed-sleeping-codex') - expect(reattach?.launchAgent).toBe('codex') - }) - - it('leaves a plain shell blank on cold restore (no prior agent, no default)', async () => { - const transport = createMockTransport('fresh-pty') - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }] }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: {} - } as StoreState - - await mountColdRestore(transport) - - const reattach = findReattachConnect(transport) - expect(reattach?.command).toBeUndefined() - expect(reattach?.launchAgent).toBeUndefined() - }) - it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 453cc81e7..1dead4f1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -149,17 +149,16 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' +import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import { isResumableTuiAgent, normalizeAgentProviderSession, + type ResumableTuiAgent, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import { @@ -373,9 +372,7 @@ type FreshSpawnOptions = { } type ColdRestoreAgentResumeStartup = PendingStartupCommand & { - // TuiAgent (not just ResumableTuiAgent): the fresh-launch fallback can spawn any - // agent the terminal previously ran, including ones without resume support. - agent: TuiAgent + agent: ResumableTuiAgent launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean @@ -1129,34 +1126,32 @@ export function connectPanePty( ) } ) - const legacyProviderSessionKeys = legacyMatches.map(([, record]) => { - const providerSession = normalizeAgentProviderSession(record.providerSession) - return providerSession - ? [record.worktreeId, record.agent, providerSession.key, providerSession.id].join('\0') - : null - }) const exactLegacyMatch = legacyMatches.find(([paneKey]) => { const legacy = parseLegacyNumericPaneKey(paneKey) return legacy?.numericPaneId === String(pane.id) }) const providerSessionKeys = new Set( - legacyProviderSessionKeys.filter((key): key is string => key !== null) + legacyMatches.map(([, record]) => + [ + record.worktreeId, + record.agent, + record.providerSession.key, + record.providerSession.id + ].join('\0') + ) ) const oldestLegacyMatch = legacyMatches .slice() .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] // Why: duplicate legacy aliases can point at one provider session; consume // the oldest capture as canonical and clear its aliases after resume. - const allLegacyMatchesHaveProviderSession = legacyProviderSessionKeys.every( - (key) => key !== null - ) const selectedLegacyMatch = exactLegacyMatch ?? - (legacyMatches.length === 1 - ? legacyMatches[0] - : allLegacyMatchesHaveProviderSession && providerSessionKeys.size === 1 - ? oldestLegacyMatch - : null) + (providerSessionKeys.size === 1 + ? legacyMatches.length === 1 + ? legacyMatches[0] + : oldestLegacyMatch + : null) if (!selectedLegacyMatch) { return null } @@ -1168,19 +1163,13 @@ export function connectPanePty( consumed: { paneKey: string; record: SleepingAgentSessionRecord } ): void => { state.clearSleepingAgentSession(consumed.paneKey) - const consumedSession = normalizeAgentProviderSession(consumed.record.providerSession) - // No provider session (e.g. a fresh-launch fallback record) means there is no - // shared session to alias, so there is nothing else to clear. - if (!consumedSession) { - return - } for (const [paneKey, record] of Object.entries(state.sleepingAgentSessionsByPaneKey)) { if ( paneKey !== consumed.paneKey && record.worktreeId === consumed.record.worktreeId && record.agent === consumed.record.agent && - record.providerSession?.key === consumedSession.key && - record.providerSession?.id === consumedSession.id + record.providerSession.key === consumed.record.providerSession.key && + record.providerSession.id === consumed.record.providerSession.id ) { // Why: legacy pane aliases can leave multiple sleeping rows for one // provider session; once this pane resumes it, every alias is stale. @@ -3495,10 +3484,6 @@ export function connectPanePty( if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { return 'linux' } - const sshRemotePlatform = getTerminalPasteSshRemotePlatform(connectionId) - if (sshRemotePlatform) { - return sshRemotePlatform - } if (connectionId || (worktree?.path && isWslUncPath(worktree.path))) { return 'linux' } @@ -3512,123 +3497,27 @@ export function connectPanePty( const entry = state.agentStatusByPaneKey[cacheKey] const sleepingRecordEntry = getSleepingRecordForPane(state) const sleepingRecord = sleepingRecordEntry?.record - const liveEntry = entry && entry.state !== 'done' ? entry : null - const useLiveEntry = Boolean(liveEntry) - - // When the provider session can't be resumed, an agent terminal should still - // come back as its agent (or the default), not a blank shell (#4557). A plain - // shell - no agent ever ran here - stays blank (buildFreshFallback returns null). - const buildFreshFallback = (): ColdRestoreAgentResumeStartup | null => { - const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( - (candidate) => candidate.id === deps.tabId - ) - const liveAgent = - liveEntry && isTuiAgent(liveEntry.agentType) ? liveEntry.agentType : undefined - const priorAgent = - liveAgent ?? sleepingRecord?.agent ?? tab?.launchAgent ?? paneStartup?.launchAgent - const defaultPref = state.settings?.defaultTuiAgent - const defaultAgent = - defaultPref && - defaultPref !== 'blank' && - isTuiAgentEnabled(defaultPref, state.settings?.disabledTuiAgents) - ? defaultPref - : undefined - // Fall back to the default agent only when this was an active/sleeping - // agent terminal whose specific agent is unrecoverable - never for a - // genuine plain shell or a stale completed live row. - const fallbackAgent = priorAgent ?? (liveEntry || sleepingRecord ? defaultAgent : undefined) - if (!fallbackAgent) { - return null - } - const liveLaunchConfig = - liveEntry && liveAgent === fallbackAgent - ? state.getAgentLaunchConfigForStatusEntry(liveEntry) - : undefined - const fallbackLaunchConfig = - liveLaunchConfig ?? - (sleepingRecord?.agent === fallbackAgent ? sleepingRecord.launchConfig : undefined) ?? - (paneStartup?.launchAgent === fallbackAgent ? paneStartup.launchConfig : undefined) - if (fallbackLaunchConfig?.agentCommand?.trim()) { - const freshLaunchToken = createBrowserUuid() - return { - agent: fallbackAgent, - command: fallbackLaunchConfig.agentCommand.trim(), - env: { - ...fallbackLaunchConfig.agentEnv, - ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken - }, - launchConfig: { - agentCommand: fallbackLaunchConfig.agentCommand.trim(), - agentArgs: fallbackLaunchConfig.agentArgs, - agentEnv: { ...fallbackLaunchConfig.agentEnv } - }, - launchToken: freshLaunchToken, - useLiveEntry, - // A fresh launch is a new session, not a restored one, so no - // "restored" banner; the stale sleeping record is still cleared - // after the spawn. - hasSleepingRecord: false, - sleepingRecordEntry - } - } - const freshPlan = buildAgentStartupPlan({ - agent: fallbackAgent, - prompt: '', - cmdOverrides: state.settings?.agentCmdOverrides ?? {}, - agentArgs: - fallbackLaunchConfig !== undefined - ? fallbackLaunchConfig.agentArgs - : resolveTuiAgentLaunchArgs(fallbackAgent, state.settings?.agentDefaultArgs), - agentEnv: - fallbackLaunchConfig !== undefined - ? fallbackLaunchConfig.agentEnv - : resolveTuiAgentLaunchEnv(fallbackAgent, state.settings?.agentDefaultEnv), - platform: getColdRestoreAgentResumePlatform(), - allowEmptyPromptLaunch: true - }) - if (!freshPlan) { - return null - } - const freshLaunchToken = createBrowserUuid() - return { - agent: fallbackAgent, - command: freshPlan.launchCommand, - env: { - ...freshPlan.env, - ORCA_AGENT_LAUNCH_TOKEN: freshLaunchToken - }, - launchConfig: freshPlan.launchConfig, - launchToken: freshLaunchToken, - useLiveEntry, - // A fresh launch is a new session, not a restored one, so no - // "restored" banner; the stale sleeping record is still cleared after - // the spawn. - hasSleepingRecord: false, - sleepingRecordEntry - } - } - - const agent = liveEntry ? liveEntry.agentType : sleepingRecord?.agent + const useLiveEntry = entry && entry.state !== 'done' + const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent if (!agent || !isResumableTuiAgent(agent)) { - return buildFreshFallback() + return null } const providerSession = normalizeAgentProviderSession( - liveEntry ? liveEntry.providerSession : sleepingRecord?.providerSession + useLiveEntry ? entry.providerSession : sleepingRecord?.providerSession ) if (!providerSession) { - return buildFreshFallback() + return null } - const sleepingProviderSession = normalizeAgentProviderSession(sleepingRecord?.providerSession) const matchingSleepingLaunchConfig = sleepingRecord?.launchConfig && (!useLiveEntry || (sleepingRecord.agent === agent && - sleepingProviderSession?.key === providerSession.key && - sleepingProviderSession?.id === providerSession.id)) + sleepingRecord.providerSession.key === providerSession.key && + sleepingRecord.providerSession.id === providerSession.id)) ? sleepingRecord.launchConfig : undefined const launchConfig = - (liveEntry ? state.getAgentLaunchConfigForStatusEntry(liveEntry) : undefined) ?? + (useLiveEntry && entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ?? matchingSleepingLaunchConfig const resumePlatform = getColdRestoreAgentResumePlatform() const startupPlan = buildAgentResumeStartupPlan({ @@ -3647,7 +3536,7 @@ export function connectPanePty( platform: resumePlatform }) if (!startupPlan) { - return buildFreshFallback() + return null } const coldRestoreLaunchToken = createBrowserUuid() // Why: cold restore means the PTY process is gone but the agent provider diff --git a/src/shared/linear-inline-media.ts b/src/shared/linear-inline-media.ts index 0d6bc4dfd..d67095fb4 100644 --- a/src/shared/linear-inline-media.ts +++ b/src/shared/linear-inline-media.ts @@ -94,7 +94,7 @@ function fileNameFromUrl(url: string): string | null { } try { const parsed = new URL(url) - const last = parsed.pathname.split('/').filter(Boolean).pop() + const last = parsed.pathname.split('/').findLast(Boolean) return last ? decodeURIComponent(last) : null } catch { return null