From 629a9556f5ec3d48b8295f1bf7293b60eb6e5c2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:49:49 -0700 Subject: [PATCH 1/9] fix(terminal): a pane never auto-resumes a conversation a native chat owns A pane that saved a native chat's conversation before the previous fix (or a CLI conversation the user later moved into a chat) still resumed it on every restart, as a second writer on the chat's transcript and in the wrong mode. The rule already exists: a conversation any native-chat record names is never resumed in a terminal (typed resume commands, Session History). The pane's own restore resumes now meet it too, at the one place every automatic resume passes: the PTY spawn, which already receives the resume's provider session and already has a "resume dropped" outcome. When a chat owns the conversation the pane starts its plain shell, shows the existing "previous session unavailable" line, and its saved record is cleared, so a stale record dies after one restart. Ownership is read from the durable session records each time, never copied; a journal that cannot be read leaves resumes as they were. --- ...-session-ownership-terminal-resume.test.ts | 90 ++++++++++ .../ai-vault/structured-session-ownership.ts | 27 +++ ...pty-spawn-native-chat-owned-resume.test.ts | 157 ++++++++++++++++++ .../pty/host-env/native-chat-owned-resume.ts | 51 ++++++ src/main/ipc/pty/ipc/spawn-env-codex.ts | 32 ++-- src/main/ipc/pty/runtime/spawn-preflight.ts | 30 ++-- 6 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts create mode 100644 src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts create mode 100644 src/main/ipc/pty/host-env/native-chat-owned-resume.ts diff --git a/src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts b/src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts new file mode 100644 index 000000000000..9fe39e318bb2 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../shared/agent-session-provider-handle-encoding' +import { nativeChatOwnsTerminalResume } from './structured-session-ownership' + +const CLAUDE_ID = 'b6bd7d57-2ec3-4e62-a47d-715e2455301d' +const CODEX_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const PI_ID = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b' +const PI_FILE = `/Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${PI_ID}.jsonl` + +function chatRecord( + provider: AgentSessionRecord['provider'], + handles: AgentSessionProviderHandle[], + workspaceId = 'structured-chat-default' +): AgentSessionRecord { + const record = agentSessionRecordFixture() + return { + ...record, + provider, + location: { ...record.location, workspaceId }, + providerHandleChain: handles.map((handle, index) => ({ + ...record.providerHandleChain[0]!, + linkId: `link-${index}`, + handle + })) + } +} + +describe('nativeChatOwnsTerminalResume', () => { + it('owns a conversation a chat in any workspace started, whatever pane saved it', () => { + const records = [chatRecord('claude', [claudeProviderHandle(CLAUDE_ID, null)])] + + expect( + nativeChatOwnsTerminalResume(records, 'claude', { key: 'session_id', id: CLAUDE_ID }) + ).toBe(true) + expect( + nativeChatOwnsTerminalResume(records, 'claude', { + key: 'session_id', + id: CLAUDE_ID.toUpperCase() + }) + ).toBe(true) + }) + + it('owns every conversation in the chat chain, not only its current one', () => { + const records = [ + chatRecord('codex', [codexProviderHandle(CODEX_ID), codexProviderHandle('019fd7aa-0000')]) + ] + + expect( + nativeChatOwnsTerminalResume(records, 'codex', { key: 'session_id', id: CODEX_ID }) + ).toBe(true) + }) + + it('matches a session-file agent by the file its hook reported', () => { + const records = [chatRecord('pi', [{ transport: 'jsonl-rpc', agent: 'pi', nativeId: PI_FILE }])] + + expect( + nativeChatOwnsTerminalResume(records, 'pi', { + key: 'session_id', + id: 'pi-hook-id', + transcriptPath: PI_FILE + }) + ).toBe(true) + }) + + it('leaves a terminal its own conversation and other agents alone', () => { + const records = [chatRecord('claude', [claudeProviderHandle(CLAUDE_ID, null)])] + + expect( + nativeChatOwnsTerminalResume(records, 'claude', { + key: 'session_id', + id: '2f0c1d3e-0000-4000-8000-000000000001' + }) + ).toBe(false) + expect( + nativeChatOwnsTerminalResume(records, 'codex', { key: 'session_id', id: CLAUDE_ID }) + ).toBe(false) + expect( + nativeChatOwnsTerminalResume(records, 'gemini', { key: 'session_id', id: CLAUDE_ID }) + ).toBe(false) + expect(nativeChatOwnsTerminalResume([], 'claude', { key: 'session_id', id: CLAUDE_ID })).toBe( + false + ) + }) +}) diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 2499f9c50b28..7c070e804090 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -19,8 +19,11 @@ import { } from '../native-chat/structured-agent-cli-conversations' import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS, + structuredAgentRuntimeRegistration, type StructuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { commandName, commandProgramIndex, shellCommands } from './shell-command-words' /** Whether the client a projection answers opens a chat of this agent from its history row. A row @@ -175,6 +178,30 @@ export async function assertLegacyAiVaultResumeCommandAllowed( } } +/** The same rule for a resume a terminal launches from its saved state rather than a typed command: + * `agent` resuming `providerSession` would write a conversation one of `records` owns. */ +export function nativeChatOwnsTerminalResume( + records: readonly AgentSessionRecord[], + agent: string, + providerSession: AgentProviderSessionMetadata +): boolean { + const owner = historyRowOwner(agent) + const history = owner ? structuredAgentRuntimeRegistration(owner)?.sessionHistory : null + if (!owner || !history) { + return false + } + const matches = history.resumeTargetMatches ?? sameIdInAnyCase + // A session-file agent resumes by the file the hook reported, the rest by id. + const targets = [providerSession.id, providerSession.transcriptPath].flatMap((target) => + target?.trim() ? [target.trim()] : [] + ) + return listStructuredProviderSessionOwnership(records).some( + (ownership) => + ownership.provider === owner && + targets.some((target) => matches(target, ownership.providerSessionId)) + ) +} + function findResumeOwnership( args: AiVaultPrepareSessionResumeArgs ): StructuredProviderSessionOwnership | null { diff --git a/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts b/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts new file mode 100644 index 000000000000..3b07cc4f6c45 --- /dev/null +++ b/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, vi } from 'vitest' +import { spawnMock } from './pty-ipc-mock-registry' +import { posixOnlyIt } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' +import { registerPtyHandlers } from './pty/register-handlers' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +describe('registerPtyHandlers', () => { + const { handlers, mainWindow, createMockProc } = setupPtyIpcSuite() + + describe('a saved resume of a conversation a native chat owns', () => { + // The incident: a chat's Claude session saved by a floating pane that leaked into the chat. + const CHAT_SESSION_ID = 'b6bd7d57-2ec3-4e62-a47d-715e2455301d' + const PANE_SESSION_ID = '2f0c1d3e-5a6b-4c7d-8e9f-0a1b2c3d4e5f' + + function chatOwning(providerSessionId: string): AgentSessionRecord { + const record = agentSessionRecordFixture() + return { + ...record, + location: { ...record.location, workspaceId: 'structured-chat-default' }, + providerHandleChain: [ + { + ...record.providerHandleChain[0]!, + handle: claudeProviderHandle(providerSessionId, null) + } + ] + } + } + + function registerWithChats(listRecords: () => AgentSessionRecord[]) { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'openAgentSessionRecordStore').mockImplementation(async () => { + const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.listRecords = listRecords + return store + }) + registerPtyHandlers(mainWindow, runtime) + } + + /** Whether the spawn reported that it dropped the resume. */ + async function spawnClaudeResume(providerSessionId: string): Promise { + const result = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + command: `claude --resume '${providerSessionId}'`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: providerSessionId } + }) + return typeof result === 'object' && result !== null && 'agentResumeUnavailable' in result + } + + /** Everything the spawned shell is told to run: its startup variable and what is typed in. */ + async function spawnedShellInput(providerSessionId: string): Promise<{ + resumeUnavailable: boolean + input: string + }> { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + vi.useFakeTimers() + try { + const resumeUnavailable = await spawnClaudeResume(providerSessionId) + for (let flush = 0; flush < 3; flush += 1) { + await Promise.resolve() + vi.runAllTimers() + } + const env: Record = spawnMock.mock.calls.at(-1)![2].env + const typed = mockProc.proc.write.mock.calls.map((call) => String(call[0])) + return { + resumeUnavailable, + input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n') + } + } finally { + vi.useRealTimers() + } + } + + posixOnlyIt('starts the plain shell and says the old session is unavailable', async () => { + registerWithChats(() => [chatOwning(CHAT_SESSION_ID)]) + + const spawned = await spawnedShellInput(CHAT_SESSION_ID) + + expect(spawned.resumeUnavailable).toBe(true) + expect(spawned.input).not.toContain(CHAT_SESSION_ID) + }) + + posixOnlyIt('resumes a conversation no chat owns', async () => { + registerWithChats(() => [chatOwning(CHAT_SESSION_ID)]) + + const spawned = await spawnedShellInput(PANE_SESSION_ID) + + expect(spawned.resumeUnavailable).toBe(false) + expect(spawned.input).toContain(`--resume '${PANE_SESSION_ID}'`) + }) + + posixOnlyIt('resumes when the chat journal cannot be read', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + registerWithChats(() => { + throw new Error('journal unavailable') + }) + + const spawned = await spawnedShellInput(CHAT_SESSION_ID) + + expect(spawned.resumeUnavailable).toBe(false) + expect(spawned.input).toContain(`--resume '${CHAT_SESSION_ID}'`) + }) + }) +}) diff --git a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts new file mode 100644 index 000000000000..0047141bca43 --- /dev/null +++ b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts @@ -0,0 +1,51 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata +} from '../../../../shared/agent-session-resume' +import { nativeChatOwnsTerminalResume } from '../../../ai-vault/structured-session-ownership' +import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import type { CodexResumeLaunch } from './codex-resume' + +type ResumeSpawnArgs = { + connectionId?: string | null + launchAgent?: TuiAgent + resumeProviderSession?: AgentProviderSessionMetadata +} + +/** + * A pane's saved resume names a conversation a native chat owns: it reached the pane through a + * chat that leaked the pane's identity, or the conversation moved into a chat. Resuming it would + * be a second writer on the chat's transcript in the wrong mode, so the pane starts its plain shell + * and says the old session is unavailable. Null when the resume may run, or when no resume is asked; + * only a local resume reads this host's records. + */ +export function nativeChatOwnedResumeLaunch( + runtime: { openAgentSessionRecordStore(): Promise } | undefined, + args: ResumeSpawnArgs +): Promise | null { + const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) + const agent = args.launchAgent + // Why not async: a spawn that asks no resume must not gain a tick, which reorders pane-spawn races. + if (!runtime || !providerSession || !agent || args.connectionId) { + return null + } + return Promise.resolve() + .then(() => runtime.openAgentSessionRecordStore()) + .then((store): CodexResumeLaunch | null => + nativeChatOwnsTerminalResume(store.listRecords(), agent, providerSession) + ? { + codexResumeHome: null, + command: undefined, + notifyResumeUnavailable: true, + droppedResumeArgv: false, + providerSession + } + : null + ) + .catch((error: unknown) => { + // Why admit: a chat journal that cannot be opened or read must not stop every terminal agent's restore. + console.warn('[pty] native chat ownership unreadable; resuming the terminal session', error) + return null + }) +} diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 5634928b81f0..3232e1e3eae0 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -9,6 +9,7 @@ import { stampRuntimeSourceEnv } from '../../../../shared/runtime-source-env' import { getRuntimeSourceStamp } from '../../../runtime/host-descriptor' import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' import { buildPtyHostEnv } from '../host-env/assembly' +import { nativeChatOwnedResumeLaunch } from '../host-env/native-chat-owned-resume' import { getCompatibleSelectedCodexHomePath, getCodexSelectionTargetForPty, @@ -34,19 +35,26 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis ctx.cwd, ctx.expectedWslDistro ) - const codexResumePreparation = ctx.preAdoptedStablePane + const ownedResume = ctx.preAdoptedStablePane ? null - : ctx.deps.prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: ctx.codexSelectionTarget, - launchEnv: ctx.baseEnv, - ...(args.replacesPtyId ? { useSelectedAccount: true } : {}) - }) - ctx.codexResumeLaunch = codexResumePreparation - ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) - : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + : nativeChatOwnedResumeLaunch(ctx.deps.runtime, args) + const refusedResume = ownedResume ? await ownedResume : null + const codexResumePreparation = + ctx.preAdoptedStablePane || refusedResume + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: ctx.baseEnv, + ...(args.replacesPtyId ? { useSelectedAccount: true } : {}) + }) + ctx.codexResumeLaunch = + refusedResume ?? + (codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command)) // Why: these three phases have unrelated costs (session provenance + hook // repair, account/auth resolution, then the synchronous env build). One // `host_env` label hid all of them behind the name of the cheapest. diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 9a7404cda93a..5809f1bcf202 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -7,6 +7,7 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { getProvider } from '../provider/registry' import { buildPtyHostEnv } from '../host-env/assembly' +import { nativeChatOwnedResumeLaunch } from '../host-env/native-chat-owned-resume' import { getCompatibleSelectedCodexHomePath, getCodexSelectionTargetForPty, @@ -111,18 +112,25 @@ export async function prepareRuntimePtySpawn( ctx.cwd, ctx.expectedWslDistro ) - const codexResumePreparation = ctx.preAdoptedStablePane + const ownedResume = ctx.preAdoptedStablePane ? null - : ctx.deps.prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: ctx.codexSelectionTarget, - launchEnv: args.env - }) - const codexResumeLaunch = codexResumePreparation - ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) - : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + : nativeChatOwnedResumeLaunch(ctx.deps.runtime, args) + const refusedResume = ownedResume ? await ownedResume : null + const codexResumePreparation = + ctx.preAdoptedStablePane || refusedResume + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: args.env + }) + const codexResumeLaunch = + refusedResume ?? + (codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command)) const codexResumeHome = codexResumeLaunch.codexResumeHome // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. From 09d2c053cc4d2df51929b8cdd272b774dbedecbd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:04:56 -0700 Subject: [PATCH 2/9] fix(terminal): a chat-owned resume opens a plain shell and retires the pane's saved session The refusal now drops the whole agent launch, so the pane is an ordinary shell (no agent registration, unattended Git guard or Claude auth prep). The spawn reports agentResumeOwnedByChat and the renderer clears both recovery sources, the saved record and the status row. WSL spawns skip the check; the remote runtime path is left for a stacked change that can carry the result back. --- ...pty-spawn-native-chat-owned-resume.test.ts | 50 +++++++++------- .../host-env/native-chat-owned-resume.test.ts | 36 +++++++++++ .../pty/host-env/native-chat-owned-resume.ts | 59 +++++++++++-------- src/main/ipc/pty/ipc/spawn-commit.ts | 6 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 32 ++++------ src/main/ipc/pty/ipc/spawn-preflight.ts | 9 +++ src/main/ipc/pty/ipc/spawn-state.ts | 3 + src/main/ipc/pty/runtime/spawn-preflight.ts | 30 ++++------ src/preload/api/pty-api.ts | 1 + src/preload/api/pty-bridge-session-control.ts | 1 + .../terminal-pane/ipc-pty-connect-result.ts | 6 +- .../pty-connection/apply-reattach-payload.ts | 6 +- .../deferred-cold-restore-and-snapshot.ts | 9 +++ .../pty-connection/fresh-spawn-start.ts | 10 +++- .../reattach-payload-context.test.ts | 40 +++++++++++++ .../reattach-payload-session.ts | 1 + .../terminal-pane/pty-transport-types.ts | 2 + 17 files changed, 211 insertions(+), 90 deletions(-) create mode 100644 src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts diff --git a/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts b/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts index 3b07cc4f6c45..b0210fb1ad0b 100644 --- a/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts +++ b/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts @@ -86,38 +86,39 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow, runtime) } - /** Whether the spawn reported that it dropped the resume. */ - async function spawnClaudeResume(providerSessionId: string): Promise { - const result = await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd: '/tmp', - command: `claude --resume '${providerSessionId}'`, - launchAgent: 'claude', - resumeProviderSession: { key: 'session_id', id: providerSessionId } - }) - return typeof result === 'object' && result !== null && 'agentResumeUnavailable' in result - } - - /** Everything the spawned shell is told to run: its startup variable and what is typed in. */ - async function spawnedShellInput(providerSessionId: string): Promise<{ + type SpawnedShell = { + ownedByChat: boolean resumeUnavailable: boolean + /** Everything the shell is told to run: its startup variable and what is typed in. */ input: string - }> { + env: Record + } + + async function spawnClaudeResume(providerSessionId: string): Promise { const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) vi.useFakeTimers() try { - const resumeUnavailable = await spawnClaudeResume(providerSessionId) + const result = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + command: `claude --resume '${providerSessionId}'`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: providerSessionId } + }) for (let flush = 0; flush < 3; flush += 1) { await Promise.resolve() vi.runAllTimers() } const env: Record = spawnMock.mock.calls.at(-1)![2].env const typed = mockProc.proc.write.mock.calls.map((call) => String(call[0])) + const reported = typeof result === 'object' && result !== null ? result : {} return { - resumeUnavailable, - input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n') + ownedByChat: 'agentResumeOwnedByChat' in reported, + resumeUnavailable: 'agentResumeUnavailable' in reported, + input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n'), + env } } finally { vi.useRealTimers() @@ -127,19 +128,24 @@ describe('registerPtyHandlers', () => { posixOnlyIt('starts the plain shell and says the old session is unavailable', async () => { registerWithChats(() => [chatOwning(CHAT_SESSION_ID)]) - const spawned = await spawnedShellInput(CHAT_SESSION_ID) + const spawned = await spawnClaudeResume(CHAT_SESSION_ID) expect(spawned.resumeUnavailable).toBe(true) + expect(spawned.ownedByChat).toBe(true) expect(spawned.input).not.toContain(CHAT_SESSION_ID) + // A plain shell, not an unattended agent's: Git may prompt for credentials again. + expect(spawned.env.GIT_TERMINAL_PROMPT).toBeUndefined() }) posixOnlyIt('resumes a conversation no chat owns', async () => { registerWithChats(() => [chatOwning(CHAT_SESSION_ID)]) - const spawned = await spawnedShellInput(PANE_SESSION_ID) + const spawned = await spawnClaudeResume(PANE_SESSION_ID) expect(spawned.resumeUnavailable).toBe(false) + expect(spawned.ownedByChat).toBe(false) expect(spawned.input).toContain(`--resume '${PANE_SESSION_ID}'`) + expect(spawned.env.GIT_TERMINAL_PROMPT).toBe('0') }) posixOnlyIt('resumes when the chat journal cannot be read', async () => { @@ -148,7 +154,7 @@ describe('registerPtyHandlers', () => { throw new Error('journal unavailable') }) - const spawned = await spawnedShellInput(CHAT_SESSION_ID) + const spawned = await spawnClaudeResume(CHAT_SESSION_ID) expect(spawned.resumeUnavailable).toBe(false) expect(spawned.input).toContain(`--resume '${CHAT_SESSION_ID}'`) diff --git a/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts b/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts new file mode 100644 index 000000000000..198e6e522a66 --- /dev/null +++ b/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { dropAgentLaunch, nativeChatOwnsSpawnResume } from './native-chat-owned-resume' + +const RESUME = { + launchAgent: 'claude' as const, + resumeProviderSession: { key: 'session_id' as const, id: 'b6bd7d57-2ec3-4e62-a47d-715e2455301d' } +} + +describe('nativeChatOwnsSpawnResume', () => { + it('never consults this host for a remote or WSL conversation', () => { + const runtime = { openAgentSessionRecordStore: vi.fn() } + + expect(nativeChatOwnsSpawnResume(runtime, { ...RESUME, connectionId: 'ssh-1' }, null)).toBe( + null + ) + expect(nativeChatOwnsSpawnResume(runtime, RESUME, 'Ubuntu')).toBe(null) + expect(nativeChatOwnsSpawnResume(runtime, { launchAgent: 'claude' }, null)).toBe(null) + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + }) +}) + +describe('dropAgentLaunch', () => { + it('leaves only the plain shell and the caller env the agent did not add', () => { + const args = { + ...RESUME, + command: 'claude --resume x', + launchToken: 'launch-token-1', + launchConfig: { agentArgs: '', agentEnv: { ANTHROPIC_MODEL: 'opus' } }, + env: { ORCA_AGENT_LAUNCH_TOKEN: 'launch-token-1', ANTHROPIC_MODEL: 'opus', FOO: 'bar' } + } + + dropAgentLaunch(args) + + expect(args).toEqual({ env: { FOO: 'bar' } }) + }) +}) diff --git a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts index 0047141bca43..b408194af4be 100644 --- a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts +++ b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts @@ -1,51 +1,62 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import { normalizeAgentProviderSession, - type AgentProviderSessionMetadata + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' import { nativeChatOwnsTerminalResume } from '../../../ai-vault/structured-session-ownership' import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import type { CodexResumeLaunch } from './codex-resume' type ResumeSpawnArgs = { connectionId?: string | null + command?: string launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: unknown resumeProviderSession?: AgentProviderSessionMetadata + env?: Record } /** - * A pane's saved resume names a conversation a native chat owns: it reached the pane through a - * chat that leaked the pane's identity, or the conversation moved into a chat. Resuming it would - * be a second writer on the chat's transcript in the wrong mode, so the pane starts its plain shell - * and says the old session is unavailable. Null when the resume may run, or when no resume is asked; - * only a local resume reads this host's records. + * Whether a pane's saved resume names a conversation a native chat owns: one that reached the pane + * through a chat that leaked the pane's identity, or that later moved into a chat. Null when the + * spawn asks no resume this host's records can own, so it stays synchronous. */ -export function nativeChatOwnedResumeLaunch( +export function nativeChatOwnsSpawnResume( runtime: { openAgentSessionRecordStore(): Promise } | undefined, - args: ResumeSpawnArgs -): Promise | null { + args: ResumeSpawnArgs, + wslDistro: string | null +): Promise | null { const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent - // Why not async: a spawn that asks no resume must not gain a tick, which reorders pane-spawn races. - if (!runtime || !providerSession || !agent || args.connectionId) { + // Why WSL is skipped: native chats run only natively, so a WSL conversation is never a chat's. + if (!runtime || !providerSession || !agent || args.connectionId || wslDistro !== null) { return null } return Promise.resolve() .then(() => runtime.openAgentSessionRecordStore()) - .then((store): CodexResumeLaunch | null => - nativeChatOwnsTerminalResume(store.listRecords(), agent, providerSession) - ? { - codexResumeHome: null, - command: undefined, - notifyResumeUnavailable: true, - droppedResumeArgv: false, - providerSession - } - : null - ) + .then((store) => nativeChatOwnsTerminalResume(store.listRecords(), agent, providerSession)) .catch((error: unknown) => { // Why admit: a chat journal that cannot be opened or read must not stop every terminal agent's restore. console.warn('[pty] native chat ownership unreadable; resuming the terminal session', error) - return null + return false }) } + +/** Turns a refused resume into the pane's plain shell: nothing of the agent launch is left to run, + * register, or mark the shell as an unattended agent's. */ +export function dropAgentLaunch(args: ResumeSpawnArgs): void { + const agentEnv = args.launchConfig?.agentEnv ?? {} + if (args.env) { + args.env = Object.fromEntries( + Object.entries(args.env).filter( + ([key, value]) => key !== 'ORCA_AGENT_LAUNCH_TOKEN' && agentEnv[key] !== value + ) + ) + } + delete args.command + delete args.launchAgent + delete args.launchConfig + delete args.launchToken + delete args.resumeProviderSession +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index d009b7871f5c..d088796b0c9b 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -195,8 +195,12 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise | undefined selectedCodexHomePath: string | null @@ -149,6 +151,7 @@ export function createPtyIpcSpawnState( nativeWindowsConptySpawn: false, codexSelectionTarget: { runtime: 'host' }, codexResumeLaunch: noCodexResumeLaunch(undefined), + agentResumeOwnedByChat: false, launchCommand: undefined, env: undefined, selectedCodexHomePath: null, diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 5809f1bcf202..9a7404cda93a 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -7,7 +7,6 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { getProvider } from '../provider/registry' import { buildPtyHostEnv } from '../host-env/assembly' -import { nativeChatOwnedResumeLaunch } from '../host-env/native-chat-owned-resume' import { getCompatibleSelectedCodexHomePath, getCodexSelectionTargetForPty, @@ -112,25 +111,18 @@ export async function prepareRuntimePtySpawn( ctx.cwd, ctx.expectedWslDistro ) - const ownedResume = ctx.preAdoptedStablePane + const codexResumePreparation = ctx.preAdoptedStablePane ? null - : nativeChatOwnedResumeLaunch(ctx.deps.runtime, args) - const refusedResume = ownedResume ? await ownedResume : null - const codexResumePreparation = - ctx.preAdoptedStablePane || refusedResume - ? null - : ctx.deps.prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: ctx.codexSelectionTarget, - launchEnv: args.env - }) - const codexResumeLaunch = - refusedResume ?? - (codexResumePreparation - ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) - : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command)) + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: args.env + }) + const codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) const codexResumeHome = codexResumeLaunch.codexResumeHome // Why: the drop still applies here, but this controller's result has no field for // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index de470176347c..66baae3c7bd2 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -85,6 +85,7 @@ export type PtyApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + agentResumeOwnedByChat?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 500ae1b9f9df..5df327630410 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -79,6 +79,7 @@ export const ptySessionControlApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + agentResumeOwnedByChat?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index bcfc0d3f67c6..60214053c5a9 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -37,7 +37,8 @@ export function projectIpcPtyConnectResult( coldRestore: spawnResult.coldRestore, replay: spawnResult.replay, pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi, - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeOwnedByChat ? { agentResumeOwnedByChat: true as const } : {}) } } if ( @@ -53,7 +54,8 @@ export function projectIpcPtyConnectResult( ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeOwnedByChat ? { agentResumeOwnedByChat: true as const } : {}) } } return spawnResult.id diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index b57101586441..4e6dc9dc1a97 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -273,7 +273,11 @@ export function createReattachPayloadHandlers( } else if (preparedStartup?.hasSleepingRecord) { session.showSessionRestoredBanner() } - session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + if (ctx.connectResult.agentResumeOwnedByChat) { + session.retireChatOwnedColdRestore(preparedStartup) + } else { + session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + } } // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. session.writeInputModeGround(POST_REPLAY_MODE_RESET) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 0aa9ddaaf4b7..e1b4174dca07 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -44,6 +44,15 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio ) } } + session.retireChatOwnedColdRestore = (startup: ColdRestoreAgentResumeStartup | null): void => { + // Why: a native chat owns this conversation, so no status row or saved record here may name it. + const state = useAppStore.getState() + state.dropAgentStatus(session.cacheKey) + const saved = startup?.sleepingRecordEntry ?? session.getSleepingRecordForPane(state) + if (saved) { + session.clearSleepingRecordProviderDuplicates(state, saved) + } + } session.mergeStartupEnvWithPaneIdentity = ( env: Record | undefined ): Record | undefined => diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 2617ad45381c..c87fbca85b81 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -202,7 +202,15 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } else if (coldRestoreOverride?.hasSleepingRecord) { session.showSessionRestoredBanner() } - session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + if ( + spawnedPtyId && + typeof spawnedPtyId === 'object' && + spawnedPtyId.agentResumeOwnedByChat + ) { + session.retireChatOwnedColdRestore(coldRestoreOverride) + } else { + session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + } } else if ( session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index 9a5385431835..20a4769753f0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -108,4 +108,44 @@ describe('reattach payload context', () => { expect(schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() expect(context.reattachPayloadApplied).toBe(true) }) + + it('retires every recovery source when a native chat owns the saved conversation', async () => { + const startup: ColdRestoreAgentResumeStartup = { + command: "claude --resume 'chat-session'", + agent: 'claude', + resumeProviderSession: { key: 'session_id', id: 'chat-session' }, + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, + launchToken: 'transport-launch-token', + useLiveEntry: true, + hasSleepingRecord: true, + sleepingRecordEntry: null + } + const retireChatOwnedColdRestore = vi.fn() + const clearSleepingRecordAfterColdRestoreSpawn = vi.fn() + const showSessionRestoredBanner = vi.fn() + const session = createSession({ + applyColdRestoreAgentResumeStartup: vi.fn(() => true), + showSessionRestoredBanner, + retireChatOwnedColdRestore, + clearSleepingRecordAfterColdRestoreSpawn, + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + const context: ReattachPayloadContext = { + ...createContext('', 1), + connectResult: { + id: 'pty-1', + coldRestore: { scrollback: 'restored scrollback', cwd: '/workspace' }, + agentResumeUnavailable: true, + agentResumeOwnedByChat: true + }, + coldRestoreStartup: startup + } + + await createReattachPayloadHandlers(session, context).applyReattachPayload() + + expect(showSessionRestoredBanner).toHaveBeenCalledWith('resume-unavailable') + expect(retireChatOwnedColdRestore).toHaveBeenCalledWith(startup) + expect(clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index b4c963d29f48..f429570c2002 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -15,6 +15,7 @@ export type ReattachPayloadSession = Pick< | 'pane' | 'pendingReattachFit' | 'recordRendererOrderedSeq' + | 'retireChatOwnedColdRestore' | 'rememberReattachPayloadAgentSignal' | 'schedulePendingStartupCommandDelivery' | 'sendFocusedReattachFocusInAfterReplay' diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 0ff00c5a1677..a66d30a43574 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -119,6 +119,8 @@ export type PtyConnectResult = { startupCwdFallback?: { kind: 'worktree'; cwd: string } /** Main declined an unverifiable provider-session resume and launched fresh. */ agentResumeUnavailable?: true + /** Main refused to resume a conversation a native chat owns and started the plain shell. */ + agentResumeOwnedByChat?: true /** Trailing partial escape the daemon emulator held mid-parse; the reattach * replay writes it LAST (after the reset) so a racing live continuation * completes it instead of rendering literally (#7329). */ From f2ca743aebb0bd84ed3b9ce65e2226e6e8098ab5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:33:15 -0700 Subject: [PATCH 3/9] fix(terminal): settle a chat-owned resume once, wherever its result lands The renderer now retires a refused plan before anything registers it (no agent hint over the plain shell), on both the fresh-spawn and reattach paths, including a fresh result with no scrollback. The status row is dropped with no resumable remnant on the host, so a Pi pane cannot rebuild the record and refuse again. --- src/main/ipc/agent-hooks.test.ts | 9 ++ src/main/ipc/agent-status-row-teardown-ipc.ts | 18 ++- src/preload/api/agent-status-api.ts | 5 +- src/preload/api/agent-status-bridge.ts | 8 +- .../pty-connection/apply-reattach-payload.ts | 25 ++-- .../chat-owned-cold-restore-retire.test.ts | 29 +++++ .../deferred-cold-restore-and-snapshot.ts | 12 +- .../pty-connection/fresh-spawn-start.ts | 20 ++-- .../reattach-payload-context.test.ts | 25 ++-- .../reattach-payload-session.ts | 1 - ...h-result-handler-chat-owned-resume.test.ts | 108 ++++++++++++++++++ .../pty-connection/reattach-result-handler.ts | 23 ++-- .../src/store/slices/agent-status-contract.ts | 2 + .../store/slices/agent-status-drop-actions.ts | 6 +- .../slices/agent-status-drop-ipc.test.ts | 7 ++ 15 files changed, 239 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index fe488b087c55..98889e8be1f5 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -293,6 +293,15 @@ describe('agentStatus:drop IPC', () => { expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) }) + it('keeps no resumable remnant when the conversation belongs to a native chat', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + onHandlers.get('agentStatus:drop')!({}, PANE_KEY, { forgetResume: true }) + + expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY, { preserveResumeIdentity: false }) + }) + it('forwards a runtime-owned legacy numeric row dismissal', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') registerAgentHookHandlers() diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index 5e42312ec206..79c25fbde778 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -27,7 +27,7 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess') ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') - ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { + ipcMain.on('agentStatus:drop', (_event, paneKey: unknown, options?: unknown) => { if ( typeof paneKey !== 'string' || (!isValidPaneKey(paneKey) && parseLegacyNumericPaneKey(paneKey) === null) @@ -39,7 +39,12 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { // dismissing a status row, not tearing down a PTY. clearPaneState would also // wipe the per-pane prompt/tool caches, which the next hook event for that // (still-alive) pane needs to render a coherent row. - agentHookServer.dropStatusEntry(paneKey) + if (isForgetResumeDrop(options)) { + // Why: a native chat owns the conversation; a remnant would let the pane resume it again. + agentHookServer.dropStatusEntry(paneKey, { preserveResumeIdentity: false }) + } else { + agentHookServer.dropStatusEntry(paneKey) + } clearMigrationUnsupportedPtysForPaneKey(paneKey) } catch (err) { console.warn('[agent-hooks] dropStatusEntry failed:', err) @@ -122,3 +127,12 @@ function isValidAgentStatusCacheIdentity(value: unknown): value is AgentStatusCa Number.isFinite(request.stateStartedAt) ) } + +function isForgetResumeDrop(options: unknown): boolean { + return ( + typeof options === 'object' && + options !== null && + 'forgetResume' in options && + options.forgetResume === true + ) +} diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 119a3c55f6c8..f871fc27e283 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -31,8 +31,9 @@ export type AgentStatusApi = { }) => void ) => () => void getMigrationUnsupportedSnapshot: () => Promise - /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ - drop: (paneKey: string) => void + /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. + * `forgetResume`: keep no resumable remnant either, because the conversation belongs elsewhere. */ + drop: (paneKey: string, options?: { forgetResume: true }) => void /** Evict a previously-cleared status only when its identity still matches the main-process cache. */ dropPersisted: (identity: AgentStatusCacheIdentity) => void /** Same as dropPersisted for many identities in one IPC message and one listener notification. */ diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 1a056252634e..a3c92748961d 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -66,8 +66,12 @@ export const agentStatusApi = { getMigrationUnsupportedSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ - drop: (paneKey: string): void => { - ipcRenderer.send('agentStatus:drop', paneKey) + drop: (paneKey: string, options?: { forgetResume: true }): void => { + if (options) { + ipcRenderer.send('agentStatus:drop', paneKey, options) + } else { + ipcRenderer.send('agentStatus:drop', paneKey) + } }, dropPersisted: (identity: AgentStatusCacheIdentity): void => { ipcRenderer.send('agentStatus:dropPersisted', identity) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index 4e6dc9dc1a97..b7812f04a8fc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -263,21 +263,20 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - const preparedStartup = ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup() + // Why not rebuilt when chat-owned: the result handler already retired that plan. + const preparedStartup = ctx.connectResult.agentResumeOwnedByChat + ? null + : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) + if (ctx.connectResult.agentResumeUnavailable) { + // Why: main dropped the resume argv, so this pane is a NEW session — + // the plain restored banner would claim the old one came back. + session.showSessionRestoredBanner('resume-unavailable') + } else if (didPrepareResume && preparedStartup?.hasSleepingRecord) { + session.showSessionRestoredBanner() + } if (didPrepareResume) { - if (ctx.connectResult.agentResumeUnavailable) { - // Why: main dropped the resume argv, so this pane is a NEW session — - // the plain restored banner would claim the old one came back. - session.showSessionRestoredBanner('resume-unavailable') - } else if (preparedStartup?.hasSleepingRecord) { - session.showSessionRestoredBanner() - } - if (ctx.connectResult.agentResumeOwnedByChat) { - session.retireChatOwnedColdRestore(preparedStartup) - } else { - session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) - } + session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) } // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. session.writeInputModeGround(POST_REPLAY_MODE_RESET) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts new file mode 100644 index 000000000000..fcd949b1b6ab --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' + +const store = vi.hoisted(() => ({ + dropAgentStatus: vi.fn(), + clearPaneForegroundAgent: vi.fn() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) + +describe('retireChatOwnedColdRestore', () => { + it('drops the status row with no resumable remnant, the agent hint, and the saved record', () => { + const saved = { paneKey: 'tab-1:leaf-1', record: { agent: 'pi' } } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a deliberately partial session bag; retire reads only these members. + const session = { + cacheKey: 'tab-1:leaf-1', + getSleepingRecordForPane: vi.fn(() => saved), + clearSleepingRecordProviderDuplicates: vi.fn() + } as unknown as ConnectPanePtySession + bindDeferredColdRestoreAndSnapshot(session) + + expect(session.retireChatOwnedColdRestore(null)).toBeNull() + + expect(store.dropAgentStatus).toHaveBeenCalledWith('tab-1:leaf-1', { forgetResume: true }) + expect(store.clearPaneForegroundAgent).toHaveBeenCalledWith('tab-1:leaf-1') + expect(session.clearSleepingRecordProviderDuplicates).toHaveBeenCalledWith(store, saved) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index e1b4174dca07..54f0df5644e0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -44,14 +44,20 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio ) } } - session.retireChatOwnedColdRestore = (startup: ColdRestoreAgentResumeStartup | null): void => { - // Why: a native chat owns this conversation, so no status row or saved record here may name it. + /** Forgets a resume main refused because a native chat owns it; the pane continues as if it had + * no resume plan, so it returns that (null) plan for the caller to continue with. */ + session.retireChatOwnedColdRestore = ( + startup: ColdRestoreAgentResumeStartup | null | undefined + ): null => { const state = useAppStore.getState() - state.dropAgentStatus(session.cacheKey) + // Why forgetResume: the chat's record owns the conversation; the host must keep no copy for this pane. + state.dropAgentStatus(session.cacheKey, { forgetResume: true }) + state.clearPaneForegroundAgent(session.cacheKey) const saved = startup?.sleepingRecordEntry ?? session.getSleepingRecordForPane(state) if (saved) { session.clearSleepingRecordProviderDuplicates(state, saved) } + return null } session.mergeStartupEnvWithPaneIdentity = ( env: Record | undefined diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index c87fbca85b81..b88fe7473236 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -175,10 +175,14 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } + // Why first: nothing of a chat-owned resume plan may be registered for the plain shell. + const coldRestore = connectResult?.agentResumeOwnedByChat + ? session.retireChatOwnedColdRestore(coldRestoreOverride) + : coldRestoreOverride if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) + ...(coldRestore ? { launchToken: coldRestore.launchToken } : {}), + ...(coldRestore ? { launchAgent: coldRestore.agent } : {}) }) } if (resolvedPtyId) { @@ -199,18 +203,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Why: main dropped the resume argv, so this pane is a NEW session — // the plain restored banner would claim the old one came back. session.showSessionRestoredBanner('resume-unavailable') - } else if (coldRestoreOverride?.hasSleepingRecord) { + } else if (coldRestore?.hasSleepingRecord) { session.showSessionRestoredBanner() } - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.agentResumeOwnedByChat - ) { - session.retireChatOwnedColdRestore(coldRestoreOverride) - } else { - session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) - } + session.clearSleepingRecordAfterColdRestoreSpawn(coldRestore) } else if ( session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index 20a4769753f0..2d31ef0f49cd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -109,24 +109,15 @@ describe('reattach payload context', () => { expect(context.reattachPayloadApplied).toBe(true) }) - it('retires every recovery source when a native chat owns the saved conversation', async () => { - const startup: ColdRestoreAgentResumeStartup = { - command: "claude --resume 'chat-session'", - agent: 'claude', - resumeProviderSession: { key: 'session_id', id: 'chat-session' }, - launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, - launchToken: 'transport-launch-token', - useLiveEntry: true, - hasSleepingRecord: true, - sleepingRecordEntry: null - } - const retireChatOwnedColdRestore = vi.fn() + it('says a chat-owned resume is unavailable without rebuilding or re-registering its plan', async () => { + const buildColdRestoreAgentResumeStartup = vi.fn() + const applyColdRestoreAgentResumeStartup = vi.fn(() => false) const clearSleepingRecordAfterColdRestoreSpawn = vi.fn() const showSessionRestoredBanner = vi.fn() const session = createSession({ - applyColdRestoreAgentResumeStartup: vi.fn(() => true), + buildColdRestoreAgentResumeStartup, + applyColdRestoreAgentResumeStartup, showSessionRestoredBanner, - retireChatOwnedColdRestore, clearSleepingRecordAfterColdRestoreSpawn, consumeRestoredViewportBlankingMarker: vi.fn(), writeFreshShellViewportBlanking: vi.fn() @@ -139,13 +130,15 @@ describe('reattach payload context', () => { agentResumeUnavailable: true, agentResumeOwnedByChat: true }, - coldRestoreStartup: startup + // The result handler already retired the plan. + coldRestoreStartup: null } await createReattachPayloadHandlers(session, context).applyReattachPayload() expect(showSessionRestoredBanner).toHaveBeenCalledWith('resume-unavailable') - expect(retireChatOwnedColdRestore).toHaveBeenCalledWith(startup) + expect(buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(null) expect(clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index f429570c2002..b4c963d29f48 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts @@ -15,7 +15,6 @@ export type ReattachPayloadSession = Pick< | 'pane' | 'pendingReattachFit' | 'recordRendererOrderedSeq' - | 'retireChatOwnedColdRestore' | 'rememberReattachPayloadAgentSignal' | 'schedulePendingStartupCommandDelivery' | 'sendFocusedReattachFocusInAfterReplay' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts new file mode 100644 index 000000000000..52f99c388860 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { bindHandleReattachResult } from './reattach-result-handler' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { ReattachPayloadContext } from './reattach-payload-context' + +const mocks = vi.hoisted(() => { + const state: Record = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + const capturedContexts: ReattachPayloadContext[] = [] + return { state, capturedContexts } +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('../terminal-freeze-breadcrumbs', () => ({ recordTerminalFreezeBreadcrumb: vi.fn() })) +vi.mock('./apply-reattach-payload', () => ({ + createReattachPayloadHandlers: (_session: unknown, ctx: ReattachPayloadContext) => { + mocks.capturedContexts.push(ctx) + return { applyReattachPayload: async () => {}, fitAfterReattachRestore: async () => {} } + } +})) + +const CHAT_OWNED_PLAN: ColdRestoreAgentResumeStartup = { + command: "claude --resume 'chat-session'", + agent: 'claude', + resumeProviderSession: { key: 'session_id', id: 'chat-session' }, + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, + launchToken: 'launch-token-1', + useLiveEntry: true, + hasSleepingRecord: false, + sleepingRecordEntry: null +} + +function localSession(): ConnectPanePtySession { + const transport = { getPtyId: () => 'pty-1', disconnect: vi.fn(), serializeBuffer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a deliberately partial session bag; an unexpected member access fails the test. + const session = { + transport, + disposed: false, + transportStreamGeneration: 0, + authoritativeReattachGeneration: 0, + mountFollowsTerminalPark: false, + cacheKey: 'cache-1', + pane: { id: 'pane-1', leafId: 'leaf-1', terminal: { options: { scrollback: 1000 } } }, + deps: { + tabId: 'tab-1', + worktreeId: 'wt-1', + paneTransportsRef: { current: new Map([['pane-1', transport]]) }, + isVisibleRef: { current: true }, + clearTabPtyId: vi.fn(), + updateTabPtyId: vi.fn(), + restoredLeafId: null + }, + agentCompletionCoordinator: { startProcessTracking: vi.fn() }, + structuralReplayCoordinator: { run: vi.fn(async (task: () => Promise) => task()) }, + retireChatOwnedColdRestore: vi.fn(() => null), + showSessionRestoredBanner: vi.fn(), + registerEffectiveLaunchConfig: vi.fn(), + startFreshColdRestoreAgentResume: vi.fn(), + setPanePtyFitBinding: vi.fn(), + syncPanePtyLayoutBinding: vi.fn(), + clearExitedPanePtyLayoutBinding: vi.fn(), + reportPanePtyVisibility: vi.fn(), + registerSideEffectFactConsumerForPty: vi.fn(), + syncHiddenRendererPtyDelivery: vi.fn(), + registerPaneSerializerFor: vi.fn(), + sampleVisiblePaneForegroundAgent: vi.fn(), + scheduleReattachIdleAgentCursorReset: vi.fn(), + settlePaneAttachAttempt: vi.fn() + } as unknown as ConnectPanePtySession + bindHandleReattachResult(session) + return session +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.capturedContexts = [] +}) + +describe('handleReattachResult with a resume a native chat owns', () => { + // A dead daemon session with no restorable history comes back as a fresh shell, no scrollback. + const FRESH_REFUSED = { id: 'pty-1', agentResumeUnavailable: true, agentResumeOwnedByChat: true } + + it('retires the plan, says so, and registers no agent for the plain shell', async () => { + const session = localSession() + + await session.handleReattachResult(FRESH_REFUSED, null, CHAT_OWNED_PLAN) + + expect(session.retireChatOwnedColdRestore).toHaveBeenCalledExactlyOnceWith(CHAT_OWNED_PLAN) + expect(session.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith('resume-unavailable') + expect(session.registerEffectiveLaunchConfig).toHaveBeenCalledWith(undefined, {}) + expect(mocks.capturedContexts[0]?.coldRestoreStartup).toBeNull() + }) + + it('leaves an unowned resume plan to the pane', async () => { + const session = localSession() + + await session.handleReattachResult({ id: 'pty-1' }, null, CHAT_OWNED_PLAN) + + expect(session.retireChatOwnedColdRestore).not.toHaveBeenCalled() + expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() + expect(session.registerEffectiveLaunchConfig).toHaveBeenCalledWith(undefined, { + launchToken: 'launch-token-1', + launchAgent: 'claude' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 8dc18c453aff..5031738c4f86 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -43,6 +43,7 @@ type ReattachResultSession = ReattachPayloadSession & | 'registerPaneSerializerFor' | 'registerSideEffectFactConsumerForPty' | 'reportPanePtyVisibility' + | 'retireChatOwnedColdRestore' | 'retryUnverifiableParkRevealSnapshot' | 'sampleVisiblePaneForegroundAgent' | 'warnParkRevealNoHostImage' @@ -124,12 +125,16 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi }) return false } + // Why first: nothing of a chat-owned resume plan may be registered for the plain shell. + const startup = connectResult?.agentResumeOwnedByChat + ? session.retireChatOwnedColdRestore(coldRestoreStartup) + : coldRestoreStartup session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}), + ...(startup ? { launchToken: startup.launchToken } : {}), ...(connectResult?.launchAgent ? { launchAgent: connectResult.launchAgent } - : coldRestoreStartup - ? { launchAgent: coldRestoreStartup.agent } + : startup + ? { launchAgent: startup.agent } : {}) }) if (connectResult?.sessionExpired) { @@ -142,7 +147,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.deps.clearTabPtyId(session.deps.tabId, staleSessionId) } // Why: a host can invalidate the PTY while the tab stays mounted; replace it in place, not with a stale overlay. - session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + session.startFreshColdRestoreAgentResume(startup, { forceBlankRestoredViewport: true }) return false @@ -166,11 +171,15 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi const hasStructuralReplay = Boolean( connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore ) + // Why here: a fresh result with no scrollback never reaches the cold-restore payload that says so. + if (connectResult?.agentResumeUnavailable && !connectResult.coldRestore) { + session.showSessionRestoredBanner('resume-unavailable') + } // Why: reattach drops startup commands; only real hibernation is authority to retire an empty adopted shell and resume its provider session. if ( !hasStructuralReplay && connectResult?.isReattach && - hasEmptyReattachRetireEvidence(ptyId, coldRestoreStartup) + hasEmptyReattachRetireEvidence(ptyId, startup) ) { session.transport.disconnect() if (staleSessionId) { @@ -179,7 +188,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi } else { session.syncPanePtyLayoutBinding(null) } - session.startFreshColdRestoreAgentResume(coldRestoreStartup, { + session.startFreshColdRestoreAgentResume(startup, { forceBlankRestoredViewport: true }) return false @@ -278,7 +287,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi prefetchedParkModelSnapshot, revealFollowsTerminalPark, shouldApplyStructuralPayload, - coldRestoreStartup, + coldRestoreStartup: startup, reattachPayloadApplied: !shouldApplyStructuralPayload, skippedAltFrameCaptureCols: null } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index cd066a63a99c..4b9cc6031790 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -47,6 +47,8 @@ export type DropAgentStatusOptions = { * cleared-at cutoff and manual-unread stamp in place so a still-live pane's next hook event * cannot resurrect activity the user already cleared. */ paneRemoved?: boolean + /** The conversation belongs to a native chat, so the host keeps no resumable copy for this pane. */ + forgetResume?: boolean } export type DropAgentStatusByTabPrefixOptions = { diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts index 2c9b8f8be7a3..230711c2a379 100644 --- a/src/renderer/src/store/slices/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -114,7 +114,11 @@ export function createAgentStatusDropActions( freshness.scheduleDeferred() } if (typeof window !== 'undefined') { - window.api?.agentStatus?.drop?.(paneKey) + if (opts?.forgetResume) { + window.api?.agentStatus?.drop?.(paneKey, { forgetResume: true }) + } else { + window.api?.agentStatus?.drop?.(paneKey) + } } }, diff --git a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts index 9dff9574b661..0e2b6e31d742 100644 --- a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -61,6 +61,13 @@ describe('dropAgentStatus → IPC fan-out', () => { expect(drop).toHaveBeenCalledTimes(1) expect(drop).toHaveBeenCalledWith('tab-missing:0') }) + + it('asks the host to keep no resumable remnant when told to forget the resume', () => { + const { drop } = stubWindowApi() + const store = createTestStore() + store.getState().dropAgentStatus('tab-1:0', { forgetResume: true }) + expect(drop).toHaveBeenCalledWith('tab-1:0', { forgetResume: true }) + }) }) describe('dropAgentStatusByTabPrefix -> IPC fan-out', () => { From c55aa9452c4d237e9437fe6b4922b20b88ccb547 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:53:58 -0700 Subject: [PATCH 4/9] fix(terminal): a refused resume is forgotten even if its shell exits before attaching The early-exit result kept only the id, and the reattach handler returned on it before settling, so a chat-owned record stayed retryable. The result now keeps the ownership flag and the handler settles before that return. --- .../components/terminal-pane/ipc-pty-connect.ts | 7 ++++++- ...ttach-result-handler-chat-owned-resume.test.ts | 12 ++++++++++++ .../pty-connection/reattach-result-handler.ts | 9 +++++---- ...pty-transport-recycled-pty-incarnation.test.ts | 15 ++++++++++++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 121bd210b040..85d01fde9e64 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -144,7 +144,12 @@ export async function connectIpcPty( if (!context.isExpectedExitCurrent()) { return } - return { id: spawnResult.id, exitedBeforeAttach: true } + return { + id: spawnResult.id, + exitedBeforeAttach: true, + // Why kept: the pane must still forget a refused resume its exited shell replaced. + ...(spawnResult.agentResumeOwnedByChat ? { agentResumeOwnedByChat: true as const } : {}) + } } if (context.isDestroyed()) { return diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts index 52f99c388860..28381b1b7c88 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts @@ -93,6 +93,18 @@ describe('handleReattachResult with a resume a native chat owns', () => { expect(mocks.capturedContexts[0]?.coldRestoreStartup).toBeNull() }) + it('still forgets the plan when the replacement shell exited before attaching', async () => { + const session = localSession() + + await session.handleReattachResult( + { id: 'pty-1', exitedBeforeAttach: true, agentResumeOwnedByChat: true }, + null, + CHAT_OWNED_PLAN + ) + + expect(session.retireChatOwnedColdRestore).toHaveBeenCalledExactlyOnceWith(CHAT_OWNED_PLAN) + }) + it('leaves an unowned resume plan to the pane', async () => { const session = localSession() diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 5031738c4f86..8ebf63afc2a6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -95,6 +95,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.remotePtyIncarnationId = null } + // Why first: nothing of a chat-owned resume plan may be registered for the plain shell, and a + // shell that already exited must still forget it. + const startup = connectResult?.agentResumeOwnedByChat + ? session.retireChatOwnedColdRestore(coldRestoreStartup) + : coldRestoreStartup if (connectResult?.exitedBeforeAttach) { // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. return true @@ -125,10 +130,6 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi }) return false } - // Why first: nothing of a chat-owned resume plan may be registered for the plain shell. - const startup = connectResult?.agentResumeOwnedByChat - ? session.retireChatOwnedColdRestore(coldRestoreStartup) - : coldRestoreStartup session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { ...(startup ? { launchToken: startup.launchToken } : {}), ...(connectResult?.launchAgent diff --git a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts index 2c521261b73a..a024b44e679e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts @@ -49,7 +49,7 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => { * keeps this record, and only the incarnation can reject it. */ async function connectWithExitDuringSpawn( exit: PtyExitPayload, - spawnResponse: { id: string; incarnationId?: string } + spawnResponse: { id: string; incarnationId?: string; agentResumeOwnedByChat?: true } ): Promise<{ result: unknown; paneExit: ReturnType }> { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -88,6 +88,19 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => { expect(paneExit).toHaveBeenCalledWith(3) }) + it('keeps a refused chat-owned resume on the result when its plain shell exits first', async () => { + const { result } = await connectWithExitDuringSpawn( + { id: RECYCLED_PTY_ID, code: 1, incarnationId: FRESH_INCARNATION_ID }, + { id: RECYCLED_PTY_ID, incarnationId: FRESH_INCARNATION_ID, agentResumeOwnedByChat: true } + ) + + expect(result).toEqual({ + id: RECYCLED_PTY_ID, + exitedBeforeAttach: true, + agentResumeOwnedByChat: true + }) + }) + it('delivers an authentication failure and exit after the main-side binding cleanup', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = vi.mocked(window.api.pty.spawn) From dac6b1f501a1a7ece241e0dd39fa52482e6c6577 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:54:32 -0700 Subject: [PATCH 5/9] fix(terminal): the host that refuses a chat-owned resume also forgets it Each pane used to retire a refused resume after receiving the spawn result, so a pane that remounted or whose shell exited mid-spawn never forgot it, and an exited shell was counted as a resumed agent. The spawn now asks the hook server to forget the refused conversation for the pane as soon as the pane is known; the server drops a matching row with no resumable copy and sends one identity-scoped clear, which the window applies everywhere the conversation is saved. Panes only show the notice. When the chat records can't be read, an automatic restore opens a plain shell and keeps its saved session; a resume the user asked for still runs. --- .../server-ai-vault-liveness.test.ts | 24 ++++ .../agent-hooks/server/server-tab-cleanup.ts | 18 +++ ...-session-ownership-terminal-resume.test.ts | 90 ------------- src/main/ipc/agent-hooks.test.ts | 9 -- src/main/ipc/agent-status-row-teardown-ipc.ts | 18 +-- src/main/ipc/pty-ipc-mock-registry.ts | 2 + src/main/ipc/pty-ipc-suite-environment.ts | 2 + ...pty-spawn-native-chat-owned-resume.test.ts | 57 +++++---- .../host-env/native-chat-owned-resume.test.ts | 35 ++--- .../pty/host-env/native-chat-owned-resume.ts | 27 +++- src/main/ipc/pty/ipc/spawn-commit.ts | 6 +- src/main/ipc/pty/ipc/spawn-preflight.ts | 19 ++- src/main/ipc/pty/ipc/spawn-run.ts | 16 +++ src/main/ipc/pty/ipc/spawn-state.ts | 10 +- src/preload/api/agent-status-api.ts | 5 +- src/preload/api/agent-status-bridge.ts | 8 +- src/preload/api/pty-api.ts | 2 +- src/preload/api/pty-bridge-session-control.ts | 2 +- .../terminal-pane/ipc-pty-connect-result.ts | 4 +- .../terminal-pane/ipc-pty-connect.ts | 4 +- .../pty-connection/apply-reattach-payload.ts | 4 +- .../chat-owned-cold-restore-retire.test.ts | 29 ----- .../deferred-cold-restore-and-snapshot.ts | 15 --- .../deferred-session-reattach-connect.ts | 15 ++- .../pty-connection/fresh-spawn-start.ts | 10 +- .../pty-connection/fresh-spawn-types.ts | 2 + .../reattach-payload-context.test.ts | 33 ----- ...h-result-handler-chat-owned-resume.test.ts | 120 ------------------ .../pty-connection/reattach-result-handler.ts | 8 +- ...transport-recycled-pty-incarnation.test.ts | 8 +- .../terminal-pane/pty-transport-types.ts | 2 +- .../ipc-events/agent-status-listeners.ts | 5 + .../lib/forget-refused-pane-resume.test.ts | 48 +++++++ .../src/lib/forget-refused-pane-resume.ts | 54 ++++++++ .../src/store/slices/agent-status-contract.ts | 2 - .../store/slices/agent-status-drop-actions.ts | 6 +- .../slices/agent-status-drop-ipc.test.ts | 7 - src/shared/agent-status-ipc-payload.ts | 11 +- src/shared/agent-status-types.ts | 3 +- 39 files changed, 308 insertions(+), 432 deletions(-) delete mode 100644 src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts create mode 100644 src/renderer/src/lib/forget-refused-pane-resume.test.ts create mode 100644 src/renderer/src/lib/forget-refused-pane-resume.ts diff --git a/src/main/agent-hooks/server-ai-vault-liveness.test.ts b/src/main/agent-hooks/server-ai-vault-liveness.test.ts index 5afb167927ce..7b2386fa4e75 100644 --- a/src/main/agent-hooks/server-ai-vault-liveness.test.ts +++ b/src/main/agent-hooks/server-ai-vault-liveness.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { makePaneKey } from '../../shared/stable-pane-id' +import type { AgentStatusClearIpcPayload } from '../../shared/agent-status-types' import { AgentHookServer } from './server' const PANE = makePaneKey('external-live', '11111111-1111-4111-8111-111111111111') @@ -40,6 +41,29 @@ describe('AgentHookServer AI Vault liveness identity', () => { expect(server.getStatusChangeSnapshot()).toEqual([]) }) + it('forgets a resume refused for a native chat, and tells renderers, only for that conversation', () => { + const server = new AgentHookServer() + const cleared: AgentStatusClearIpcPayload[] = [] + server.setPaneStatusClearListener((clear) => cleared.push(clear)) + seedLocalIdentity(server) + const other = { + agent: 'gemini', + providerSession: { key: 'session_id' as const, id: 'other' } + } + const refused = { agent: 'gemini', providerSession: PROVIDER_SESSION } + + server.forgetRefusedPaneResume(PANE, other) + expect(server.getStatusSnapshot()).toHaveLength(1) + server.forgetRefusedPaneResume(PANE, refused) + + // No resumable remnant either, unlike a dismissal. + expect(server.getStatusSnapshot()).toEqual([]) + expect(cleared).toEqual([ + { paneKey: PANE, refusedResume: other }, + { paneKey: PANE, refusedResume: refused } + ]) + }) + it('hydrates dismissed provider identity without reviving visible status', async () => { const userDataPath = await mkdtemp(join(tmpdir(), 'orca-ai-vault-liveness-')) const first = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index bc1d481498bf..671c5ed64fb4 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -2,8 +2,26 @@ import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listene import { paneCacheKeyMatchesTab } from './server-status-identity' import { AgentHookServerCleanup } from './server-cleanup' import type { EnrichedAgentHookEventPayload } from './server-types' +import type { RefusedPaneResume } from '../../../shared/agent-status-types' +import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { + /** The host refused `paneKey`'s saved resume because a native chat owns that conversation: + * forget the pane's claim on it here, with no resumable remnant, and tell renderers to forget + * theirs. A row for another conversation is left alone. */ + forgetRefusedPaneResume(paneKey: string, refused: RefusedPaneResume): void { + const row = this.state.lastStatusByPaneKey.get(this.resolvePaneKeyAlias(paneKey)) + if ( + row && + row.payload.agentType === refused.agent && + agentProviderSessionsEqual(refused.agent, row.providerSession, refused.providerSession) + ) { + this.dropStatusEntry(row.paneKey, { preserveResumeIdentity: false }) + } + // Why renderers only: main's own subscribers heard the drop; a pane-wide clear would end a row kept above. + this.onPaneStatusCleared?.({ paneKey, refusedResume: refused }) + } + /** Drop every status/cache claim attributable to a closed tab prefix. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) diff --git a/src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts b/src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts deleted file mode 100644 index 9fe39e318bb2..000000000000 --- a/src/main/ai-vault/structured-session-ownership-terminal-resume.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionProviderHandle } from '../../shared/agent-session-provider-handle' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { nativeChatOwnsTerminalResume } from './structured-session-ownership' - -const CLAUDE_ID = 'b6bd7d57-2ec3-4e62-a47d-715e2455301d' -const CODEX_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' -const PI_ID = '019fd622-3c4d-7e5f-a061-7c8d9e0f1a2b' -const PI_FILE = `/Users/me/.pi/agent/sessions/--Users-me-repo--/2026-10-09T10-00-00-000Z_${PI_ID}.jsonl` - -function chatRecord( - provider: AgentSessionRecord['provider'], - handles: AgentSessionProviderHandle[], - workspaceId = 'structured-chat-default' -): AgentSessionRecord { - const record = agentSessionRecordFixture() - return { - ...record, - provider, - location: { ...record.location, workspaceId }, - providerHandleChain: handles.map((handle, index) => ({ - ...record.providerHandleChain[0]!, - linkId: `link-${index}`, - handle - })) - } -} - -describe('nativeChatOwnsTerminalResume', () => { - it('owns a conversation a chat in any workspace started, whatever pane saved it', () => { - const records = [chatRecord('claude', [claudeProviderHandle(CLAUDE_ID, null)])] - - expect( - nativeChatOwnsTerminalResume(records, 'claude', { key: 'session_id', id: CLAUDE_ID }) - ).toBe(true) - expect( - nativeChatOwnsTerminalResume(records, 'claude', { - key: 'session_id', - id: CLAUDE_ID.toUpperCase() - }) - ).toBe(true) - }) - - it('owns every conversation in the chat chain, not only its current one', () => { - const records = [ - chatRecord('codex', [codexProviderHandle(CODEX_ID), codexProviderHandle('019fd7aa-0000')]) - ] - - expect( - nativeChatOwnsTerminalResume(records, 'codex', { key: 'session_id', id: CODEX_ID }) - ).toBe(true) - }) - - it('matches a session-file agent by the file its hook reported', () => { - const records = [chatRecord('pi', [{ transport: 'jsonl-rpc', agent: 'pi', nativeId: PI_FILE }])] - - expect( - nativeChatOwnsTerminalResume(records, 'pi', { - key: 'session_id', - id: 'pi-hook-id', - transcriptPath: PI_FILE - }) - ).toBe(true) - }) - - it('leaves a terminal its own conversation and other agents alone', () => { - const records = [chatRecord('claude', [claudeProviderHandle(CLAUDE_ID, null)])] - - expect( - nativeChatOwnsTerminalResume(records, 'claude', { - key: 'session_id', - id: '2f0c1d3e-0000-4000-8000-000000000001' - }) - ).toBe(false) - expect( - nativeChatOwnsTerminalResume(records, 'codex', { key: 'session_id', id: CLAUDE_ID }) - ).toBe(false) - expect( - nativeChatOwnsTerminalResume(records, 'gemini', { key: 'session_id', id: CLAUDE_ID }) - ).toBe(false) - expect(nativeChatOwnsTerminalResume([], 'claude', { key: 'session_id', id: CLAUDE_ID })).toBe( - false - ) - }) -}) diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 98889e8be1f5..fe488b087c55 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -293,15 +293,6 @@ describe('agentStatus:drop IPC', () => { expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY) }) - it('keeps no resumable remnant when the conversation belongs to a native chat', async () => { - const { registerAgentHookHandlers } = await import('./agent-hooks') - registerAgentHookHandlers() - - onHandlers.get('agentStatus:drop')!({}, PANE_KEY, { forgetResume: true }) - - expect(dropStatusEntry).toHaveBeenCalledWith(PANE_KEY, { preserveResumeIdentity: false }) - }) - it('forwards a runtime-owned legacy numeric row dismissal', async () => { const { registerAgentHookHandlers } = await import('./agent-hooks') registerAgentHookHandlers() diff --git a/src/main/ipc/agent-status-row-teardown-ipc.ts b/src/main/ipc/agent-status-row-teardown-ipc.ts index 79c25fbde778..5e42312ec206 100644 --- a/src/main/ipc/agent-status-row-teardown-ipc.ts +++ b/src/main/ipc/agent-status-row-teardown-ipc.ts @@ -27,7 +27,7 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { ipcMain.removeAllListeners('agentStatus:reconcileEndedProcess') ipcMain.removeAllListeners('agentStatus:dropByTabPrefix') - ipcMain.on('agentStatus:drop', (_event, paneKey: unknown, options?: unknown) => { + ipcMain.on('agentStatus:drop', (_event, paneKey: unknown) => { if ( typeof paneKey !== 'string' || (!isValidPaneKey(paneKey) && parseLegacyNumericPaneKey(paneKey) === null) @@ -39,12 +39,7 @@ export function registerAgentStatusRowTeardownIpcHandlers(): void { // dismissing a status row, not tearing down a PTY. clearPaneState would also // wipe the per-pane prompt/tool caches, which the next hook event for that // (still-alive) pane needs to render a coherent row. - if (isForgetResumeDrop(options)) { - // Why: a native chat owns the conversation; a remnant would let the pane resume it again. - agentHookServer.dropStatusEntry(paneKey, { preserveResumeIdentity: false }) - } else { - agentHookServer.dropStatusEntry(paneKey) - } + agentHookServer.dropStatusEntry(paneKey) clearMigrationUnsupportedPtysForPaneKey(paneKey) } catch (err) { console.warn('[agent-hooks] dropStatusEntry failed:', err) @@ -127,12 +122,3 @@ function isValidAgentStatusCacheIdentity(value: unknown): value is AgentStatusCa Number.isFinite(request.stateStartedAt) ) } - -function isForgetResumeDrop(options: unknown): boolean { - return ( - typeof options === 'object' && - options !== null && - 'forgetResume' in options && - options.forgetResume === true - ) -} diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 4eba8d0b1641..07338447a0ec 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -31,6 +31,7 @@ export const openCodeClearPtyMock: Mock = vi.fn() export const buildAgentHookEnvMock: Mock = vi.fn() export const clearAgentHookPaneStateMock: Mock = vi.fn() export const registerPaneKeyAliasMock: Mock = vi.fn() +export const forgetRefusedPaneResumeMock: Mock = vi.fn() export const piBuildPtyEnvMock: Mock = vi.fn() export const piClearPtyMock: Mock = vi.fn() export const trackMock: Mock = vi.fn() @@ -134,6 +135,7 @@ export const agentHookServerModuleMock = () => ({ buildPtyEnv: buildAgentHookEnvMock, clearPaneState: clearAgentHookPaneStateMock, registerPaneKeyAlias: registerPaneKeyAliasMock, + forgetRefusedPaneResume: forgetRefusedPaneResumeMock, clearPaneKeyAliasesForPty: clearPaneKeyAliasesForPtyMock } }) diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 9c1409214f86..a7f4637f383a 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -28,6 +28,7 @@ import { buildAgentHookEnvMock, clearAgentHookPaneStateMock, registerPaneKeyAliasMock, + forgetRefusedPaneResumeMock, piBuildPtyEnvMock, piClearPtyMock, isPwshAvailableMock, @@ -159,6 +160,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() registerPaneKeyAliasMock.mockReset() + forgetRefusedPaneResumeMock.mockReset() piBuildPtyEnvMock.mockReset() piClearPtyMock.mockReset() isPwshAvailableMock.mockReset() diff --git a/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts b/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts index b0210fb1ad0b..505e618c29a2 100644 --- a/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts +++ b/src/main/ipc/pty-spawn-native-chat-owned-resume.test.ts @@ -1,5 +1,5 @@ import { describe, expect, vi } from 'vitest' -import { spawnMock } from './pty-ipc-mock-registry' +import { forgetRefusedPaneResumeMock, spawnMock } from './pty-ipc-mock-registry' import { posixOnlyIt } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from '../pty/posix-shell-startup-command' @@ -64,15 +64,10 @@ describe('registerPtyHandlers', () => { function chatOwning(providerSessionId: string): AgentSessionRecord { const record = agentSessionRecordFixture() + const [link] = record.providerHandleChain return { ...record, - location: { ...record.location, workspaceId: 'structured-chat-default' }, - providerHandleChain: [ - { - ...record.providerHandleChain[0]!, - handle: claudeProviderHandle(providerSessionId, null) - } - ] + providerHandleChain: [{ ...link!, handle: claudeProviderHandle(providerSessionId, null) }] } } @@ -86,15 +81,12 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow, runtime) } - type SpawnedShell = { - ownedByChat: boolean - resumeUnavailable: boolean - /** Everything the shell is told to run: its startup variable and what is typed in. */ - input: string - env: Record - } + const LEAF_ID = '11111111-1111-4111-8111-111111111111' - async function spawnClaudeResume(providerSessionId: string): Promise { + async function spawnClaudeResume( + providerSessionId: string, + { spawnIntent = 'automatic-restore', leafId = LEAF_ID } = {} + ) { const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) vi.useFakeTimers() @@ -103,9 +95,12 @@ describe('registerPtyHandlers', () => { cols: 80, rows: 24, cwd: '/tmp', + tabId: 'tab-floating', + leafId, command: `claude --resume '${providerSessionId}'`, launchAgent: 'claude', - resumeProviderSession: { key: 'session_id', id: providerSessionId } + resumeProviderSession: { key: 'session_id', id: providerSessionId }, + spawnIntent }) for (let flush = 0; flush < 3; flush += 1) { await Promise.resolve() @@ -115,7 +110,7 @@ describe('registerPtyHandlers', () => { const typed = mockProc.proc.write.mock.calls.map((call) => String(call[0])) const reported = typeof result === 'object' && result !== null ? result : {} return { - ownedByChat: 'agentResumeOwnedByChat' in reported, + refused: 'agentResumeRefused' in reported, resumeUnavailable: 'agentResumeUnavailable' in reported, input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n'), env @@ -125,16 +120,20 @@ describe('registerPtyHandlers', () => { } } - posixOnlyIt('starts the plain shell and says the old session is unavailable', async () => { + posixOnlyIt('starts the plain shell, says so, and the host forgets it', async () => { registerWithChats(() => [chatOwning(CHAT_SESSION_ID)]) const spawned = await spawnClaudeResume(CHAT_SESSION_ID) expect(spawned.resumeUnavailable).toBe(true) - expect(spawned.ownedByChat).toBe(true) + expect(spawned.refused).toBe(true) expect(spawned.input).not.toContain(CHAT_SESSION_ID) // A plain shell, not an unattended agent's: Git may prompt for credentials again. expect(spawned.env.GIT_TERMINAL_PROMPT).toBeUndefined() + expect(forgetRefusedPaneResumeMock).toHaveBeenCalledWith(`tab-floating:${LEAF_ID}`, { + agent: 'claude', + providerSession: { key: 'session_id', id: CHAT_SESSION_ID } + }) }) posixOnlyIt('resumes a conversation no chat owns', async () => { @@ -143,21 +142,29 @@ describe('registerPtyHandlers', () => { const spawned = await spawnClaudeResume(PANE_SESSION_ID) expect(spawned.resumeUnavailable).toBe(false) - expect(spawned.ownedByChat).toBe(false) expect(spawned.input).toContain(`--resume '${PANE_SESSION_ID}'`) expect(spawned.env.GIT_TERMINAL_PROMPT).toBe('0') + expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled() }) - posixOnlyIt('resumes when the chat journal cannot be read', async () => { + posixOnlyIt('with unreadable chat records, refuses only an automatic restore', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) registerWithChats(() => { throw new Error('journal unavailable') }) - const spawned = await spawnClaudeResume(CHAT_SESSION_ID) + const restored = await spawnClaudeResume(CHAT_SESSION_ID) + const asked = await spawnClaudeResume(CHAT_SESSION_ID, { + spawnIntent: 'user-resume', + leafId: '22222222-2222-4222-8222-222222222222' + }) - expect(spawned.resumeUnavailable).toBe(false) - expect(spawned.input).toContain(`--resume '${CHAT_SESSION_ID}'`) + expect(restored.refused).toBe(true) + expect(restored.input).not.toContain(CHAT_SESSION_ID) + expect(asked.refused).toBe(false) + expect(asked.input).toContain(`--resume '${CHAT_SESSION_ID}'`) + // Nothing is forgotten: the saved session is tried again on the next restore. + expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled() }) }) }) diff --git a/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts b/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts index 198e6e522a66..35141d4c9eb1 100644 --- a/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts +++ b/src/main/ipc/pty/host-env/native-chat-owned-resume.test.ts @@ -1,36 +1,23 @@ import { describe, expect, it, vi } from 'vitest' -import { dropAgentLaunch, nativeChatOwnsSpawnResume } from './native-chat-owned-resume' +import { nativeChatSpawnResumeOwnership } from './native-chat-owned-resume' const RESUME = { launchAgent: 'claude' as const, - resumeProviderSession: { key: 'session_id' as const, id: 'b6bd7d57-2ec3-4e62-a47d-715e2455301d' } + resumeProviderSession: { + key: 'session_id' as const, + id: 'b6bd7d57-2ec3-4e62-a47d-715e2455301d' + } } -describe('nativeChatOwnsSpawnResume', () => { +describe('nativeChatSpawnResumeOwnership', () => { it('never consults this host for a remote or WSL conversation', () => { const runtime = { openAgentSessionRecordStore: vi.fn() } - expect(nativeChatOwnsSpawnResume(runtime, { ...RESUME, connectionId: 'ssh-1' }, null)).toBe( - null - ) - expect(nativeChatOwnsSpawnResume(runtime, RESUME, 'Ubuntu')).toBe(null) - expect(nativeChatOwnsSpawnResume(runtime, { launchAgent: 'claude' }, null)).toBe(null) + expect( + nativeChatSpawnResumeOwnership(runtime, { ...RESUME, connectionId: 'ssh-1' }, null) + ).toBe(null) + expect(nativeChatSpawnResumeOwnership(runtime, RESUME, 'Ubuntu')).toBe(null) + expect(nativeChatSpawnResumeOwnership(runtime, { launchAgent: 'claude' }, null)).toBe(null) expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() }) }) - -describe('dropAgentLaunch', () => { - it('leaves only the plain shell and the caller env the agent did not add', () => { - const args = { - ...RESUME, - command: 'claude --resume x', - launchToken: 'launch-token-1', - launchConfig: { agentArgs: '', agentEnv: { ANTHROPIC_MODEL: 'opus' } }, - env: { ORCA_AGENT_LAUNCH_TOKEN: 'launch-token-1', ANTHROPIC_MODEL: 'opus', FOO: 'bar' } - } - - dropAgentLaunch(args) - - expect(args).toEqual({ env: { FOO: 'bar' } }) - }) -}) diff --git a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts index b408194af4be..80f63ef3833f 100644 --- a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts +++ b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/agent-session-resume' import { nativeChatOwnsTerminalResume } from '../../../ai-vault/structured-session-ownership' import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import type { RefusedPaneResume } from '../../../../shared/agent-status-types' type ResumeSpawnArgs = { connectionId?: string | null @@ -17,16 +18,22 @@ type ResumeSpawnArgs = { env?: Record } +/** Who may resume a pane's saved conversation, judged from this host's native chat records. */ +export type SpawnResumeOwnership = + | { verdict: 'owned'; refused: RefusedPaneResume } + | { verdict: 'unowned' } + | { verdict: 'unverifiable' } + /** * Whether a pane's saved resume names a conversation a native chat owns: one that reached the pane * through a chat that leaked the pane's identity, or that later moved into a chat. Null when the * spawn asks no resume this host's records can own, so it stays synchronous. */ -export function nativeChatOwnsSpawnResume( +export function nativeChatSpawnResumeOwnership( runtime: { openAgentSessionRecordStore(): Promise } | undefined, args: ResumeSpawnArgs, wslDistro: string | null -): Promise | null { +): Promise | null { const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent // Why WSL is skipped: native chats run only natively, so a WSL conversation is never a chat's. @@ -35,11 +42,17 @@ export function nativeChatOwnsSpawnResume( } return Promise.resolve() .then(() => runtime.openAgentSessionRecordStore()) - .then((store) => nativeChatOwnsTerminalResume(store.listRecords(), agent, providerSession)) - .catch((error: unknown) => { - // Why admit: a chat journal that cannot be opened or read must not stop every terminal agent's restore. - console.warn('[pty] native chat ownership unreadable; resuming the terminal session', error) - return false + .then((store): SpawnResumeOwnership => + nativeChatOwnsTerminalResume(store.listRecords(), agent, providerSession) + ? { verdict: 'owned', refused: { agent, providerSession } } + : { verdict: 'unowned' } + ) + .catch((error: unknown): SpawnResumeOwnership => { + console.warn( + '[pty] native chat records unreadable; cannot tell who owns the saved session', + error + ) + return { verdict: 'unverifiable' } }) } diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index d088796b0c9b..48580da5b662 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -195,12 +195,12 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { restoreProvisionalPtySize(ctx) diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index 2627b867e14a..64347fb214b4 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -6,6 +6,7 @@ import type { import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' import type { PtySpawnTiming } from '../../pty-spawn-timing' import { createPtySpawnTiming } from '../../pty-spawn-timing' @@ -75,8 +76,10 @@ export type PtyIpcSpawnState = { nativeWindowsConptySpawn: boolean codexSelectionTarget: CodexAccountSelectionTarget codexResumeLaunch: CodexResumeLaunch - /** The pane's saved resume named a conversation a native chat owns; it starts its plain shell. */ - agentResumeOwnedByChat: boolean + /** The pane's saved resume was refused, so it starts its plain shell instead. */ + agentResumeRefused: boolean + /** The refused conversation, when a native chat is known to own it. */ + refusedResume: RefusedPaneResume | null launchCommand: string | undefined env: Record | undefined selectedCodexHomePath: string | null @@ -151,7 +154,8 @@ export function createPtyIpcSpawnState( nativeWindowsConptySpawn: false, codexSelectionTarget: { runtime: 'host' }, codexResumeLaunch: noCodexResumeLaunch(undefined), - agentResumeOwnedByChat: false, + agentResumeRefused: false, + refusedResume: null, launchCommand: undefined, env: undefined, selectedCodexHomePath: null, diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index f871fc27e283..119a3c55f6c8 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -31,9 +31,8 @@ export type AgentStatusApi = { }) => void ) => () => void getMigrationUnsupportedSnapshot: () => Promise - /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. - * `forgetResume`: keep no resumable remnant either, because the conversation belongs elsewhere. */ - drop: (paneKey: string, options?: { forgetResume: true }) => void + /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ + drop: (paneKey: string) => void /** Evict a previously-cleared status only when its identity still matches the main-process cache. */ dropPersisted: (identity: AgentStatusCacheIdentity) => void /** Same as dropPersisted for many identities in one IPC message and one listener notification. */ diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index a3c92748961d..1a056252634e 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -66,12 +66,8 @@ export const agentStatusApi = { getMigrationUnsupportedSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ - drop: (paneKey: string, options?: { forgetResume: true }): void => { - if (options) { - ipcRenderer.send('agentStatus:drop', paneKey, options) - } else { - ipcRenderer.send('agentStatus:drop', paneKey) - } + drop: (paneKey: string): void => { + ipcRenderer.send('agentStatus:drop', paneKey) }, dropPersisted: (identity: AgentStatusCacheIdentity): void => { ipcRenderer.send('agentStatus:dropPersisted', identity) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 66baae3c7bd2..a70ed8db1cd8 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -85,7 +85,7 @@ export type PtyApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true - agentResumeOwnedByChat?: true + agentResumeRefused?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 5df327630410..e2c73edafeee 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -79,7 +79,7 @@ export const ptySessionControlApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true - agentResumeOwnedByChat?: true + agentResumeRefused?: true /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index 60214053c5a9..824d8b30686a 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -38,7 +38,7 @@ export function projectIpcPtyConnectResult( replay: spawnResult.replay, pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi, ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), - ...(spawnResult.agentResumeOwnedByChat ? { agentResumeOwnedByChat: true as const } : {}) + ...(spawnResult.agentResumeRefused ? { agentResumeRefused: true as const } : {}) } } if ( @@ -55,7 +55,7 @@ export function projectIpcPtyConnectResult( ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), - ...(spawnResult.agentResumeOwnedByChat ? { agentResumeOwnedByChat: true as const } : {}) + ...(spawnResult.agentResumeRefused ? { agentResumeRefused: true as const } : {}) } } return spawnResult.id diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 85d01fde9e64..af0d31e6f5d1 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -147,8 +147,8 @@ export async function connectIpcPty( return { id: spawnResult.id, exitedBeforeAttach: true, - // Why kept: the pane must still forget a refused resume its exited shell replaced. - ...(spawnResult.agentResumeOwnedByChat ? { agentResumeOwnedByChat: true as const } : {}) + // Why kept: a shell that replaced a resume and then exited never resumed the agent. + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) } } if (context.isDestroyed()) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index b7812f04a8fc..9cefc7b9f62d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -263,8 +263,8 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - // Why not rebuilt when chat-owned: the result handler already retired that plan. - const preparedStartup = ctx.connectResult.agentResumeOwnedByChat + // Why not rebuilt when refused: main started a plain shell instead of that plan. + const preparedStartup = ctx.connectResult.agentResumeRefused ? null : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts deleted file mode 100644 index fcd949b1b6ab..000000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection/chat-owned-cold-restore-retire.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ConnectPanePtySession } from './connect-pane-pty-session' -import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' - -const store = vi.hoisted(() => ({ - dropAgentStatus: vi.fn(), - clearPaneForegroundAgent: vi.fn() -})) - -vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) - -describe('retireChatOwnedColdRestore', () => { - it('drops the status row with no resumable remnant, the agent hint, and the saved record', () => { - const saved = { paneKey: 'tab-1:leaf-1', record: { agent: 'pi' } } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a deliberately partial session bag; retire reads only these members. - const session = { - cacheKey: 'tab-1:leaf-1', - getSleepingRecordForPane: vi.fn(() => saved), - clearSleepingRecordProviderDuplicates: vi.fn() - } as unknown as ConnectPanePtySession - bindDeferredColdRestoreAndSnapshot(session) - - expect(session.retireChatOwnedColdRestore(null)).toBeNull() - - expect(store.dropAgentStatus).toHaveBeenCalledWith('tab-1:leaf-1', { forgetResume: true }) - expect(store.clearPaneForegroundAgent).toHaveBeenCalledWith('tab-1:leaf-1') - expect(session.clearSleepingRecordProviderDuplicates).toHaveBeenCalledWith(store, saved) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 54f0df5644e0..0aa9ddaaf4b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -44,21 +44,6 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio ) } } - /** Forgets a resume main refused because a native chat owns it; the pane continues as if it had - * no resume plan, so it returns that (null) plan for the caller to continue with. */ - session.retireChatOwnedColdRestore = ( - startup: ColdRestoreAgentResumeStartup | null | undefined - ): null => { - const state = useAppStore.getState() - // Why forgetResume: the chat's record owns the conversation; the host must keep no copy for this pane. - state.dropAgentStatus(session.cacheKey, { forgetResume: true }) - state.clearPaneForegroundAgent(session.cacheKey) - const saved = startup?.sleepingRecordEntry ?? session.getSleepingRecordForPane(state) - if (saved) { - session.clearSleepingRecordProviderDuplicates(state, saved) - } - return null - } session.mergeStartupEnvWithPaneIdentity = ( env: Record | undefined ): Record | undefined => diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index 8aea08a9a45d..5b7496ed649c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -115,16 +115,23 @@ function connectDeferredSessionReattach( !session.disposed && session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && outputCallbacks.generation === session.transportStreamGeneration - const startFreshInstead = (): Promise => - session - .startFreshColdRestoreAgentResume(coldRestoreStartup, { forceBlankRestoredViewport: true }) + const startFreshInstead = (): Promise => { + let resumeUnavailable = false + return session + .startFreshColdRestoreAgentResume(coldRestoreStartup, { + forceBlankRestoredViewport: true, + onAgentResumeUnavailable: () => { + resumeUnavailable = true + } + }) .then( (ptyId: string | null) => ({ reopened: ptyId !== null, - agentResumed: ptyId !== null && Boolean(coldRestoreStartup) + agentResumed: ptyId !== null && Boolean(coldRestoreStartup) && !resumeUnavailable }), () => NOT_REOPENED ) + } void Promise.resolve(reattachPromise) .catch(() => null) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index b88fe7473236..6922d14bc6e9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -175,10 +175,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } - // Why first: nothing of a chat-owned resume plan may be registered for the plain shell. - const coldRestore = connectResult?.agentResumeOwnedByChat - ? session.retireChatOwnedColdRestore(coldRestoreOverride) - : coldRestoreOverride + // Why: main started a plain shell instead, so nothing of the plan may stay registered over it. + const coldRestore = connectResult?.agentResumeRefused ? null : coldRestoreOverride + if (connectResult?.agentResumeRefused) { + session.clearRegisteredStartupLaunchConfig() + } if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { ...(coldRestore ? { launchToken: coldRestore.launchToken } : {}), @@ -203,6 +204,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Why: main dropped the resume argv, so this pane is a NEW session — // the plain restored banner would claim the old one came back. session.showSessionRestoredBanner('resume-unavailable') + options.onAgentResumeUnavailable?.() } else if (coldRestore?.hasSleepingRecord) { session.showSessionRestoredBanner() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts index 4bb06f8dbde4..671d061d4f29 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts @@ -12,6 +12,8 @@ export type PendingStartupCommand = { export type FreshSpawnOptions = { forceBlankRestoredViewport?: boolean + /** Main started the pane without resuming the requested agent session. */ + onAgentResumeUnavailable?: () => void } export type ColdRestoreAgentResumeStartup = PendingStartupCommand & { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index 2d31ef0f49cd..9a5385431835 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -108,37 +108,4 @@ describe('reattach payload context', () => { expect(schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() expect(context.reattachPayloadApplied).toBe(true) }) - - it('says a chat-owned resume is unavailable without rebuilding or re-registering its plan', async () => { - const buildColdRestoreAgentResumeStartup = vi.fn() - const applyColdRestoreAgentResumeStartup = vi.fn(() => false) - const clearSleepingRecordAfterColdRestoreSpawn = vi.fn() - const showSessionRestoredBanner = vi.fn() - const session = createSession({ - buildColdRestoreAgentResumeStartup, - applyColdRestoreAgentResumeStartup, - showSessionRestoredBanner, - clearSleepingRecordAfterColdRestoreSpawn, - consumeRestoredViewportBlankingMarker: vi.fn(), - writeFreshShellViewportBlanking: vi.fn() - }) - const context: ReattachPayloadContext = { - ...createContext('', 1), - connectResult: { - id: 'pty-1', - coldRestore: { scrollback: 'restored scrollback', cwd: '/workspace' }, - agentResumeUnavailable: true, - agentResumeOwnedByChat: true - }, - // The result handler already retired the plan. - coldRestoreStartup: null - } - - await createReattachPayloadHandlers(session, context).applyReattachPayload() - - expect(showSessionRestoredBanner).toHaveBeenCalledWith('resume-unavailable') - expect(buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() - expect(applyColdRestoreAgentResumeStartup).toHaveBeenCalledWith(null) - expect(clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts deleted file mode 100644 index 28381b1b7c88..000000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-chat-owned-resume.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { bindHandleReattachResult } from './reattach-result-handler' -import type { ConnectPanePtySession } from './connect-pane-pty-session' -import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' -import type { ReattachPayloadContext } from './reattach-payload-context' - -const mocks = vi.hoisted(() => { - const state: Record = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } - const capturedContexts: ReattachPayloadContext[] = [] - return { state, capturedContexts } -}) - -vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) -vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) -vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) -vi.mock('../terminal-freeze-breadcrumbs', () => ({ recordTerminalFreezeBreadcrumb: vi.fn() })) -vi.mock('./apply-reattach-payload', () => ({ - createReattachPayloadHandlers: (_session: unknown, ctx: ReattachPayloadContext) => { - mocks.capturedContexts.push(ctx) - return { applyReattachPayload: async () => {}, fitAfterReattachRestore: async () => {} } - } -})) - -const CHAT_OWNED_PLAN: ColdRestoreAgentResumeStartup = { - command: "claude --resume 'chat-session'", - agent: 'claude', - resumeProviderSession: { key: 'session_id', id: 'chat-session' }, - launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} }, - launchToken: 'launch-token-1', - useLiveEntry: true, - hasSleepingRecord: false, - sleepingRecordEntry: null -} - -function localSession(): ConnectPanePtySession { - const transport = { getPtyId: () => 'pty-1', disconnect: vi.fn(), serializeBuffer: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a deliberately partial session bag; an unexpected member access fails the test. - const session = { - transport, - disposed: false, - transportStreamGeneration: 0, - authoritativeReattachGeneration: 0, - mountFollowsTerminalPark: false, - cacheKey: 'cache-1', - pane: { id: 'pane-1', leafId: 'leaf-1', terminal: { options: { scrollback: 1000 } } }, - deps: { - tabId: 'tab-1', - worktreeId: 'wt-1', - paneTransportsRef: { current: new Map([['pane-1', transport]]) }, - isVisibleRef: { current: true }, - clearTabPtyId: vi.fn(), - updateTabPtyId: vi.fn(), - restoredLeafId: null - }, - agentCompletionCoordinator: { startProcessTracking: vi.fn() }, - structuralReplayCoordinator: { run: vi.fn(async (task: () => Promise) => task()) }, - retireChatOwnedColdRestore: vi.fn(() => null), - showSessionRestoredBanner: vi.fn(), - registerEffectiveLaunchConfig: vi.fn(), - startFreshColdRestoreAgentResume: vi.fn(), - setPanePtyFitBinding: vi.fn(), - syncPanePtyLayoutBinding: vi.fn(), - clearExitedPanePtyLayoutBinding: vi.fn(), - reportPanePtyVisibility: vi.fn(), - registerSideEffectFactConsumerForPty: vi.fn(), - syncHiddenRendererPtyDelivery: vi.fn(), - registerPaneSerializerFor: vi.fn(), - sampleVisiblePaneForegroundAgent: vi.fn(), - scheduleReattachIdleAgentCursorReset: vi.fn(), - settlePaneAttachAttempt: vi.fn() - } as unknown as ConnectPanePtySession - bindHandleReattachResult(session) - return session -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.capturedContexts = [] -}) - -describe('handleReattachResult with a resume a native chat owns', () => { - // A dead daemon session with no restorable history comes back as a fresh shell, no scrollback. - const FRESH_REFUSED = { id: 'pty-1', agentResumeUnavailable: true, agentResumeOwnedByChat: true } - - it('retires the plan, says so, and registers no agent for the plain shell', async () => { - const session = localSession() - - await session.handleReattachResult(FRESH_REFUSED, null, CHAT_OWNED_PLAN) - - expect(session.retireChatOwnedColdRestore).toHaveBeenCalledExactlyOnceWith(CHAT_OWNED_PLAN) - expect(session.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith('resume-unavailable') - expect(session.registerEffectiveLaunchConfig).toHaveBeenCalledWith(undefined, {}) - expect(mocks.capturedContexts[0]?.coldRestoreStartup).toBeNull() - }) - - it('still forgets the plan when the replacement shell exited before attaching', async () => { - const session = localSession() - - await session.handleReattachResult( - { id: 'pty-1', exitedBeforeAttach: true, agentResumeOwnedByChat: true }, - null, - CHAT_OWNED_PLAN - ) - - expect(session.retireChatOwnedColdRestore).toHaveBeenCalledExactlyOnceWith(CHAT_OWNED_PLAN) - }) - - it('leaves an unowned resume plan to the pane', async () => { - const session = localSession() - - await session.handleReattachResult({ id: 'pty-1' }, null, CHAT_OWNED_PLAN) - - expect(session.retireChatOwnedColdRestore).not.toHaveBeenCalled() - expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() - expect(session.registerEffectiveLaunchConfig).toHaveBeenCalledWith(undefined, { - launchToken: 'launch-token-1', - launchAgent: 'claude' - }) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index 8ebf63afc2a6..f5a5926438a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -43,7 +43,6 @@ type ReattachResultSession = ReattachPayloadSession & | 'registerPaneSerializerFor' | 'registerSideEffectFactConsumerForPty' | 'reportPanePtyVisibility' - | 'retireChatOwnedColdRestore' | 'retryUnverifiableParkRevealSnapshot' | 'sampleVisiblePaneForegroundAgent' | 'warnParkRevealNoHostImage' @@ -95,16 +94,13 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.remotePtyIncarnationId = null } - // Why first: nothing of a chat-owned resume plan may be registered for the plain shell, and a - // shell that already exited must still forget it. - const startup = connectResult?.agentResumeOwnedByChat - ? session.retireChatOwnedColdRestore(coldRestoreStartup) - : coldRestoreStartup if (connectResult?.exitedBeforeAttach) { // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. return true } + // Why: main started a plain shell instead, so nothing of the plan may be registered over it. + const startup = connectResult?.agentResumeRefused ? null : coldRestoreStartup const ptyId = connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId()) const hasExplicitPtyId = Boolean(connectResult?.id || typeof result === 'string') diff --git a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts index a024b44e679e..36be4e5acc45 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-recycled-pty-incarnation.test.ts @@ -49,7 +49,7 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => { * keeps this record, and only the incarnation can reject it. */ async function connectWithExitDuringSpawn( exit: PtyExitPayload, - spawnResponse: { id: string; incarnationId?: string; agentResumeOwnedByChat?: true } + spawnResponse: { id: string; incarnationId?: string; agentResumeUnavailable?: true } ): Promise<{ result: unknown; paneExit: ReturnType }> { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -88,16 +88,16 @@ describe('createIpcPtyTransport against a relay-recycled PTY id', () => { expect(paneExit).toHaveBeenCalledWith(3) }) - it('keeps a refused chat-owned resume on the result when its plain shell exits first', async () => { + it('still reports the resume unavailable when the shell that replaced it exits first', async () => { const { result } = await connectWithExitDuringSpawn( { id: RECYCLED_PTY_ID, code: 1, incarnationId: FRESH_INCARNATION_ID }, - { id: RECYCLED_PTY_ID, incarnationId: FRESH_INCARNATION_ID, agentResumeOwnedByChat: true } + { id: RECYCLED_PTY_ID, incarnationId: FRESH_INCARNATION_ID, agentResumeUnavailable: true } ) expect(result).toEqual({ id: RECYCLED_PTY_ID, exitedBeforeAttach: true, - agentResumeOwnedByChat: true + agentResumeUnavailable: true }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index a66d30a43574..e57175bac790 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -120,7 +120,7 @@ export type PtyConnectResult = { /** Main declined an unverifiable provider-session resume and launched fresh. */ agentResumeUnavailable?: true /** Main refused to resume a conversation a native chat owns and started the plain shell. */ - agentResumeOwnedByChat?: true + agentResumeRefused?: true /** Trailing partial escape the daemon emulator held mid-parse; the reattach * replay writes it LAST (after the reset) so a racing live continuation * completes it instead of rendering literally (#7329). */ diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 62d9f9c3f036..205e6eeed0eb 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -8,6 +8,7 @@ import { rollbackLegacyWorkerTerminalSurfaceInStore } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' +import { forgetRefusedPaneResume } from '@/lib/forget-refused-pane-resume' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' @@ -72,6 +73,10 @@ export function registerAgentStatusListeners(args: { if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } + if (data.refusedResume) { + forgetRefusedPaneResume(useAppStore.getState(), data.paneKey, data.refusedResume) + return + } // Why: preserve set→clear FIFO so a queued completion still survives pane teardown. if (liveAgentStatusBurstQueue.some((queued) => queued.paneKey === data.paneKey)) { drainQueuedLiveAgentStatusesForPane(data.paneKey) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.test.ts b/src/renderer/src/lib/forget-refused-pane-resume.test.ts new file mode 100644 index 000000000000..fe373020690c --- /dev/null +++ b/src/renderer/src/lib/forget-refused-pane-resume.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' +import { forgetRefusedPaneResume } from './forget-refused-pane-resume' + +const PANE = 'tab-floating:11111111-1111-4111-8111-111111111111' +const CHAT = { key: 'session_id' as const, id: 'chat-session' } +const TITLE = { agent: 'claude' as const, sessionId: CHAT.id, title: 'the chat prompt' } + +function saved(paneKey: string, id: string): SleepingAgentSessionRecord { + const providerSession = { key: 'session_id' as const, id } + return { + paneKey, + worktreeId: 'wt', + agent: 'claude', + providerSession, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } +} + +describe('forgetRefusedPaneResume', () => { + it('forgets the chat conversation as any pane resume, and leaves the chat and others alone', () => { + const store = createTestStore() + store.setState({ + sleepingAgentSessionsByPaneKey: { + [PANE]: saved(PANE, CHAT.id), + 'tab-floating:alias': saved('tab-floating:alias', CHAT.id), + 'tab-other:leaf': saved('tab-other:leaf', 'other-session') + }, + tabsByWorktree: { + wt: [makeTab({ id: 'tab-floating', worktreeId: 'wt', aiVaultTitle: TITLE })], + chat: [makeTab({ id: 'tab-chat', worktreeId: 'chat', aiVaultTitle: TITLE })] + } + }) + store.getState().registerAgentLaunchConfig(PANE, { agentArgs: '', agentEnv: {} }, {}) + + forgetRefusedPaneResume(store.getState(), PANE, { agent: 'claude', providerSession: CHAT }) + + const state = store.getState() + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual(['tab-other:leaf']) + expect(state.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() + expect(state.tabsByWorktree.wt![0]!.aiVaultTitle).toBeNull() + expect(state.tabsByWorktree.chat![0]!.aiVaultTitle).toEqual(TITLE) + }) +}) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts new file mode 100644 index 000000000000..4d1bb6151a8c --- /dev/null +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -0,0 +1,54 @@ +import type { AppState } from '@/store/types' +import type { RefusedPaneResume } from '../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +/** + * Main refused `paneKey`'s saved resume because a native chat owns that conversation. Forgets it + * everywhere this renderer kept it, whether or not the pane is mounted, so nothing offers or + * retries it. Main already forgot its own copy, so nothing is sent back. + */ +export function forgetRefusedPaneResume( + state: AppState, + paneKey: string, + refused: RefusedPaneResume +): void { + const names = (agent: string | undefined, session: AgentProviderSessionMetadata | undefined) => + agent === refused.agent && + agentProviderSessionsEqual(refused.agent, session, refused.providerSession) + const live = state.agentStatusByPaneKey[paneKey] + // Why also with no row: the launch config the pane registered for the refused resume goes too. + if (!live || names(live.agentType, live.providerSession)) { + state.removeAgentStatus(paneKey) + } + const retained = state.retainedAgentsByPaneKey[paneKey] + if (retained && names(retained.agentType, retained.entry.providerSession)) { + state.dismissRetainedAgent(paneKey) + } + // Why every pane: legacy aliases can hold the same conversation under other keys. + const sleeping = Object.entries(state.sleepingAgentSessionsByPaneKey).flatMap(([key, record]) => + names(record.agent, record.providerSession) ? [key] : [] + ) + if (sleeping.length > 0) { + state.clearSleepingAgentSessionsByPaneKey(sleeping) + } + if (state.paneForegroundAgentByPaneKey[paneKey]?.agentEvidence === 'launch-record') { + state.clearPaneForegroundAgent(paneKey) + } + // Why only this pane's tab: the chat's own tab carries the same conversation's title. + const tabId = parsePaneKey(paneKey)?.tabId + const tab = tabId + ? Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + : undefined + if ( + tab?.aiVaultTitle?.agent === refused.agent && + tab.aiVaultTitle.sessionId === refused.providerSession.id + ) { + state.setAiVaultTabTitle(tab.id, null) + } +} diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 4b9cc6031790..cd066a63a99c 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -47,8 +47,6 @@ export type DropAgentStatusOptions = { * cleared-at cutoff and manual-unread stamp in place so a still-live pane's next hook event * cannot resurrect activity the user already cleared. */ paneRemoved?: boolean - /** The conversation belongs to a native chat, so the host keeps no resumable copy for this pane. */ - forgetResume?: boolean } export type DropAgentStatusByTabPrefixOptions = { diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts index 230711c2a379..2c9b8f8be7a3 100644 --- a/src/renderer/src/store/slices/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -114,11 +114,7 @@ export function createAgentStatusDropActions( freshness.scheduleDeferred() } if (typeof window !== 'undefined') { - if (opts?.forgetResume) { - window.api?.agentStatus?.drop?.(paneKey, { forgetResume: true }) - } else { - window.api?.agentStatus?.drop?.(paneKey) - } + window.api?.agentStatus?.drop?.(paneKey) } }, diff --git a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts index 0e2b6e31d742..9dff9574b661 100644 --- a/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts +++ b/src/renderer/src/store/slices/agent-status-drop-ipc.test.ts @@ -61,13 +61,6 @@ describe('dropAgentStatus → IPC fan-out', () => { expect(drop).toHaveBeenCalledTimes(1) expect(drop).toHaveBeenCalledWith('tab-missing:0') }) - - it('asks the host to keep no resumable remnant when told to forget the resume', () => { - const { drop } = stubWindowApi() - const store = createTestStore() - store.getState().dropAgentStatus('tab-1:0', { forgetResume: true }) - expect(drop).toHaveBeenCalledWith('tab-1:0', { forgetResume: true }) - }) }) describe('dropAgentStatusByTabPrefix -> IPC fan-out', () => { diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 59803d498702..4d2c3e05d998 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -82,9 +82,16 @@ export type AgentStatusCacheIdentity = { stateStartedAt: number } -/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ +/** A pane's saved resume the host refused because a native chat owns that conversation. */ +export type RefusedPaneResume = { + agent: string + providerSession: AgentProviderSessionMetadata +} + +/** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. `refusedResume`: forget + * that conversation as any pane's resume target too, not only this pane's status. */ export type AgentStatusClearIpcPayload = - | { paneKey: string; statusUnavailable?: true } + | { paneKey: string; statusUnavailable?: true; refusedResume?: RefusedPaneResume } | { transient: true connectionId: string diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 1fff0ea17e3e..df63e7e3900f 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -43,7 +43,8 @@ export type { AgentStatusCacheIdentity, AgentStatusClearIpcPayload, AgentStatusIpcPayload, - MigrationUnsupportedPtyEntry + MigrationUnsupportedPtyEntry, + RefusedPaneResume } from './agent-status-ipc-payload' export { mainAgentStatusEqual, type AgentMainAgentStatus } from './main-agent-status' export { AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry } from './agent-state-history' From ae89b18fd00841bd5700b8c20caf8cd36bd694d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:29:56 -0700 Subject: [PATCH 6/9] fix(terminal): finish the refused-resume contract on every path The host now publishes the moved pane's own key and, when it really deleted the row, tells every subscriber; the window drops queued events that would restore the conversation and forgets it only within the pane's own workspace, leaving a pane that moved on to another conversation alone. Every spawn result, including an exit during registration and an exit before attaching, says the resume was refused and why. An owner that can't be checked refuses any resume the user didn't ask for, and the pane keeps the saved session for the next try even when it was consumed before the spawn. --- .../agent-hooks/server/server-tab-cleanup.ts | 13 +++-- .../pty/host-env/native-chat-owned-resume.ts | 3 + src/main/ipc/pty/ipc/spawn-commit.ts | 34 ++++++----- src/main/ipc/pty/ipc/spawn-preflight.ts | 9 ++- src/main/ipc/pty/ipc/spawn-run.ts | 4 +- src/main/ipc/pty/ipc/spawn-state.ts | 11 ++-- src/preload/api/pty-api.ts | 2 +- src/preload/api/pty-bridge-session-control.ts | 2 +- .../terminal-pane/ipc-pty-connect-result.ts | 8 ++- .../terminal-pane/ipc-pty-connect.ts | 8 ++- .../pty-connection/fresh-spawn-start.ts | 4 ++ .../pty-connection/reattach-result-handler.ts | 3 + .../pty-connection/spawn-deferred-sleep.ts | 17 ++++-- .../terminal-pane/pty-transport-types.ts | 5 +- .../ipc-events/agent-status-listeners.ts | 34 ++++++++++- .../lib/forget-refused-pane-resume.test.ts | 15 +++-- .../src/lib/forget-refused-pane-resume.ts | 58 +++++++++++++------ 17 files changed, 161 insertions(+), 69 deletions(-) diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 671c5ed64fb4..6da78191eef2 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -10,16 +10,21 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { * forget the pane's claim on it here, with no resumable remnant, and tell renderers to forget * theirs. A row for another conversation is left alone. */ forgetRefusedPaneResume(paneKey: string, refused: RefusedPaneResume): void { - const row = this.state.lastStatusByPaneKey.get(this.resolvePaneKeyAlias(paneKey)) + // Why the owner's key: a moved pane's row, and the renderer's state for it, live under it. + const ownerKey = this.resolvePaneKeyAlias(paneKey) + const row = this.state.lastStatusByPaneKey.get(ownerKey) + const clear = { paneKey: ownerKey, refusedResume: refused } if ( row && row.payload.agentType === refused.agent && agentProviderSessionsEqual(refused.agent, row.providerSession, refused.providerSession) ) { - this.dropStatusEntry(row.paneKey, { preserveResumeIdentity: false }) + this.dropStatusEntry(ownerKey, { preserveResumeIdentity: false }) + this.emitPaneStatusCleared(clear) + return } - // Why renderers only: main's own subscribers heard the drop; a pane-wide clear would end a row kept above. - this.onPaneStatusCleared?.({ paneKey, refusedResume: refused }) + // Why renderers only: the pane's row is another conversation's, which other subscribers keep. + this.onPaneStatusCleared?.(clear) } /** Drop every status/cache claim attributable to a closed tab prefix. */ diff --git a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts index 80f63ef3833f..f04a99607af2 100644 --- a/src/main/ipc/pty/host-env/native-chat-owned-resume.ts +++ b/src/main/ipc/pty/host-env/native-chat-owned-resume.ts @@ -24,6 +24,9 @@ export type SpawnResumeOwnership = | { verdict: 'unowned' } | { verdict: 'unverifiable' } +/** A verdict that stops the resume: the pane starts its plain shell instead. */ +export type SpawnResumeRefusal = Exclude + /** * Whether a pane's saved resume names a conversation a native chat owns: one that reached the pane * through a chat that leaked the pane's identity, or that later moved into a chat. Null when the diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 48580da5b662..0f774faef4fd 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -27,6 +27,21 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedPtyIpcSpawn(ctx)) } +/** What became of a requested resume, on every result the renderer receives. */ +function resumeDisposition(ctx: PtyIpcSpawnState) { + // Why: the pane asked to resume and got a fresh session instead; only the + // renderer can say so, and a reattach never ran this launch command. + if (ctx.result.isReattach) { + return {} + } + return { + ...(ctx.codexResumeLaunch.notifyResumeUnavailable || ctx.resumeRefusal + ? { agentResumeUnavailable: true as const } + : {}), + ...(ctx.resumeRefusal ? { agentResumeRefused: ctx.resumeRefusal.verdict } : {}) + } +} + async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result) @@ -87,11 +102,10 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise | undefined selectedCodexHomePath: string | null @@ -154,8 +152,7 @@ export function createPtyIpcSpawnState( nativeWindowsConptySpawn: false, codexSelectionTarget: { runtime: 'host' }, codexResumeLaunch: noCodexResumeLaunch(undefined), - agentResumeRefused: false, - refusedResume: null, + resumeRefusal: null, launchCommand: undefined, env: undefined, selectedCodexHomePath: null, diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index a70ed8db1cd8..d184bb4afd73 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -85,7 +85,7 @@ export type PtyApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true - agentResumeRefused?: true + agentResumeRefused?: 'owned' | 'unverifiable' /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index e2c73edafeee..cba713aacca9 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -79,7 +79,7 @@ export const ptySessionControlApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true - agentResumeRefused?: true + agentResumeRefused?: 'owned' | 'unverifiable' /** Host verdict on the shell-ready marker; absent when the execution host predates the field. */ shellReadyArmed?: boolean }> => ipcRenderer.invoke('pty:spawn', opts), diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts index 824d8b30686a..4d2df79e4753 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect-result.ts @@ -38,7 +38,9 @@ export function projectIpcPtyConnectResult( replay: spawnResult.replay, pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi, ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), - ...(spawnResult.agentResumeRefused ? { agentResumeRefused: true as const } : {}) + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } if ( @@ -55,7 +57,9 @@ export function projectIpcPtyConnectResult( ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), - ...(spawnResult.agentResumeRefused ? { agentResumeRefused: true as const } : {}) + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } return spawnResult.id diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index af0d31e6f5d1..759a2e9d071f 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -147,8 +147,12 @@ export async function connectIpcPty( return { id: spawnResult.id, exitedBeforeAttach: true, - // Why kept: a shell that replaced a resume and then exited never resumed the agent. - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + // Why kept: a shell that replaced a resume and then exited never resumed the agent, and a + // refused plan must still not be registered or consume the saved session. + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + ...(spawnResult.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } if (context.isDestroyed()) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 6922d14bc6e9..dd5131efcbc2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -180,6 +180,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { if (connectResult?.agentResumeRefused) { session.clearRegisteredStartupLaunchConfig() } + if (connectResult?.agentResumeRefused === 'unverifiable') { + // Why: the saved session may have been consumed before this spawn; keep it for the next try. + session.keepUnrunResumeHandle(startupOverride) + } if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { ...(coldRestore ? { launchToken: coldRestore.launchToken } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index f5a5926438a2..067ae8c11b66 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -95,6 +95,9 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi } if (connectResult?.exitedBeforeAttach) { + if (connectResult.agentResumeUnavailable) { + session.showSessionRestoredBanner('resume-unavailable') + } // Why: the transport already delivered the dead session's final frame + exit; treat as terminal state, not a failed reattach. return true } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts index 1c8e0a6305f1..895defaa985a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts @@ -144,17 +144,24 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void session.pendingSpawnDeferral = null session.deferredSpawn = null - session.enterSpawnDeferredSleep = ( - reason: SpawnDeferralReason, - startupOverride: PendingStartupCommand | null | undefined, - options: FreshSpawnOptions, - visibleAtSpawn: boolean + /** Keeps the resume handle of a restore the host did not run, so a later restore tries again. */ + session.keepUnrunResumeHandle = ( + startupOverride: PendingStartupCommand | null | undefined ): void => { const record = deferredResumeRecord(session, startupOverride) if (record) { // Why left after a later resume: the next quit capture replaces it, and a pane with a live PTY owns it. useAppStore.getState().rememberSleepingAgentSession(record) } + } + + session.enterSpawnDeferredSleep = ( + reason: SpawnDeferralReason, + startupOverride: PendingStartupCommand | null | undefined, + options: FreshSpawnOptions, + visibleAtSpawn: boolean + ): void => { + session.keepUnrunResumeHandle(startupOverride) session.deferredSpawn = { reason, startupOverride, options } const deferral = { reason, resume: () => void session.resumeDeferredSpawn() } setPaneSpawnDeferral(session.cacheKey, deferral) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index e57175bac790..8c018ad84db9 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -119,8 +119,9 @@ export type PtyConnectResult = { startupCwdFallback?: { kind: 'worktree'; cwd: string } /** Main declined an unverifiable provider-session resume and launched fresh. */ agentResumeUnavailable?: true - /** Main refused to resume a conversation a native chat owns and started the plain shell. */ - agentResumeRefused?: true + /** Main refused the resume and started the plain shell: a native chat owns the conversation, or + * its owner could not be checked (the saved session is then kept for the next try). */ + agentResumeRefused?: 'owned' | 'unverifiable' /** Trailing partial escape the daemon emulator held mid-parse; the reattach * replay writes it LAST (after the reset) so a racing live continuation * completes it instead of rendering literally (#7329). */ diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 205e6eeed0eb..f1591951b93b 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -1,17 +1,43 @@ import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import type { AgentStatusClearIpcPayload, - AgentStatusIpcPayload + AgentStatusIpcPayload, + RefusedPaneResume } from '../../../../shared/agent-status-types' import { resolveLegacyWorkerTerminalRecoveryAction, rollbackLegacyWorkerTerminalSurfaceInStore } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' -import { forgetRefusedPaneResume } from '@/lib/forget-refused-pane-resume' +import { forgetRefusedPaneResume, namesRefusedResume } from '@/lib/forget-refused-pane-resume' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' +/** An already delivered event for the pane naming a refused conversation would restore it. */ +function dropEventsRestoringRefused( + paneKey: string, + refused: RefusedPaneResume, + queues: { + pendingAgentStatusEvents: PendingAgentStatusEvent[] + liveAgentStatusBurstQueue: AgentStatusIpcPayload[] + } +): void { + const restores = (event: AgentStatusIpcPayload): boolean => + event.paneKey === paneKey && namesRefusedResume(refused, { ...event, agent: event.agentType }) + const pending = queues.pendingAgentStatusEvents + for (let index = pending.length - 1; index >= 0; index -= 1) { + if (restores(pending[index].data)) { + pending.splice(index, 1) + } + } + const burst = queues.liveAgentStatusBurstQueue + for (let index = burst.length - 1; index >= 0; index -= 1) { + if (restores(burst[index])) { + burst.splice(index, 1) + } + } +} + export function registerAgentStatusListeners(args: { unsubs: (() => void)[] enqueueLiveAgentStatus: (data: AgentStatusIpcPayload) => void @@ -74,6 +100,10 @@ export function registerAgentStatusListeners(args: { return } if (data.refusedResume) { + dropEventsRestoringRefused(data.paneKey, data.refusedResume, { + pendingAgentStatusEvents, + liveAgentStatusBurstQueue + }) forgetRefusedPaneResume(useAppStore.getState(), data.paneKey, data.refusedResume) return } diff --git a/src/renderer/src/lib/forget-refused-pane-resume.test.ts b/src/renderer/src/lib/forget-refused-pane-resume.test.ts index fe373020690c..cae92d6dbee8 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.test.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.test.ts @@ -7,11 +7,11 @@ const PANE = 'tab-floating:11111111-1111-4111-8111-111111111111' const CHAT = { key: 'session_id' as const, id: 'chat-session' } const TITLE = { agent: 'claude' as const, sessionId: CHAT.id, title: 'the chat prompt' } -function saved(paneKey: string, id: string): SleepingAgentSessionRecord { +function saved(paneKey: string, id: string, worktreeId = 'wt'): SleepingAgentSessionRecord { const providerSession = { key: 'session_id' as const, id } return { paneKey, - worktreeId: 'wt', + worktreeId, agent: 'claude', providerSession, prompt: '', @@ -22,13 +22,15 @@ function saved(paneKey: string, id: string): SleepingAgentSessionRecord { } describe('forgetRefusedPaneResume', () => { - it('forgets the chat conversation as any pane resume, and leaves the chat and others alone', () => { + it("forgets the chat conversation as this workspace's pane resume, and leaves the chat and others alone", () => { const store = createTestStore() store.setState({ sleepingAgentSessionsByPaneKey: { [PANE]: saved(PANE, CHAT.id), 'tab-floating:alias': saved('tab-floating:alias', CHAT.id), - 'tab-other:leaf': saved('tab-other:leaf', 'other-session') + 'tab-other:leaf': saved('tab-other:leaf', 'other-session'), + // The same id saved on another host's worktree is another conversation. + 'tab-ssh:leaf': saved('tab-ssh:leaf', CHAT.id, 'ssh-wt') }, tabsByWorktree: { wt: [makeTab({ id: 'tab-floating', worktreeId: 'wt', aiVaultTitle: TITLE })], @@ -40,7 +42,10 @@ describe('forgetRefusedPaneResume', () => { forgetRefusedPaneResume(store.getState(), PANE, { agent: 'claude', providerSession: CHAT }) const state = store.getState() - expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual(['tab-other:leaf']) + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([ + 'tab-other:leaf', + 'tab-ssh:leaf' + ]) expect(state.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() expect(state.tabsByWorktree.wt![0]!.aiVaultTitle).toBeNull() expect(state.tabsByWorktree.chat![0]!.aiVaultTitle).toEqual(TITLE) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts index 4d1bb6151a8c..aa61008db293 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -6,6 +6,23 @@ import { } from '../../../shared/agent-session-resume' import { parsePaneKey } from '../../../shared/stable-pane-id' +/** Whether a saved claim names the refused conversation. Only local claims can: the host judged + * its own chats, and a remote host's same id is a different conversation. */ +export function namesRefusedResume( + refused: RefusedPaneResume, + claim: { + agent: string | undefined + providerSession: AgentProviderSessionMetadata | undefined + connectionId?: string | null + } +): boolean { + return ( + !claim.connectionId && + claim.agent === refused.agent && + agentProviderSessionsEqual(refused.agent, claim.providerSession, refused.providerSession) + ) +} + /** * Main refused `paneKey`'s saved resume because a native chat owns that conversation. Forgets it * everywhere this renderer kept it, whether or not the pane is mounted, so nothing offers or @@ -16,35 +33,42 @@ export function forgetRefusedPaneResume( paneKey: string, refused: RefusedPaneResume ): void { - const names = (agent: string | undefined, session: AgentProviderSessionMetadata | undefined) => - agent === refused.agent && - agentProviderSessionsEqual(refused.agent, session, refused.providerSession) + const names = (claim: Parameters[1]) => + namesRefusedResume(refused, claim) const live = state.agentStatusByPaneKey[paneKey] - // Why also with no row: the launch config the pane registered for the refused resume goes too. - if (!live || names(live.agentType, live.providerSession)) { - state.removeAgentStatus(paneKey) - } const retained = state.retainedAgentsByPaneKey[paneKey] - if (retained && names(retained.agentType, retained.entry.providerSession)) { + if (retained && names({ ...retained.entry, agent: retained.agentType })) { state.dismissRetainedAgent(paneKey) } - // Why every pane: legacy aliases can hold the same conversation under other keys. + const tabId = parsePaneKey(paneKey)?.tabId + const tab = tabId + ? Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + : undefined + const worktreeId = + tab?.worktreeId ?? state.sleepingAgentSessionsByPaneKey[paneKey]?.worktreeId ?? live?.worktreeId + // Why: a pane already on another conversation keeps that one's row, launch config and hint; + // its next capture replaces its own saved record. + const movedOn = live !== undefined && !names({ ...live, agent: live.agentType }) + // Why this worktree: another host's or namespace's copy of the id is another conversation, and + // legacy aliases of this pane hold duplicates of it here. const sleeping = Object.entries(state.sleepingAgentSessionsByPaneKey).flatMap(([key, record]) => - names(record.agent, record.providerSession) ? [key] : [] + record.worktreeId === worktreeId && names(record) && !(movedOn && key === paneKey) ? [key] : [] ) if (sleeping.length > 0) { state.clearSleepingAgentSessionsByPaneKey(sleeping) } - if (state.paneForegroundAgentByPaneKey[paneKey]?.agentEvidence === 'launch-record') { + if (movedOn) { + return + } + // Why also with no row: the launch config the pane registered for the refused resume goes too. + state.removeAgentStatus(paneKey) + const hint = state.paneForegroundAgentByPaneKey[paneKey] + if (hint?.agentEvidence === 'launch-record' && hint.agent === refused.agent) { state.clearPaneForegroundAgent(paneKey) } // Why only this pane's tab: the chat's own tab carries the same conversation's title. - const tabId = parsePaneKey(paneKey)?.tabId - const tab = tabId - ? Object.values(state.tabsByWorktree) - .flat() - .find((candidate) => candidate.id === tabId) - : undefined if ( tab?.aiVaultTitle?.agent === refused.agent && tab.aiVaultTitle.sessionId === refused.providerSession.id From 8686ba65040c21d23d9e495310e41c85eb226f39 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:36:53 -0700 Subject: [PATCH 7/9] fix(session): an emptied sleeping-session map reaches disk while Orca runs Clearing the last saved agent session sent the field as undefined, which the per-host session split drops, so the record stayed on disk until the quit snapshot and a crash brought it back. The patch now sends an empty map, which the split already keeps. Also loosens the refused-resume matcher to the status types' optional fields. --- src/renderer/src/lib/forget-refused-pane-resume.ts | 4 ++-- src/renderer/src/lib/workspace-session-patch.test.ts | 6 +++--- src/renderer/src/lib/workspace-session-patch.ts | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts index aa61008db293..e366f6241c70 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -11,8 +11,8 @@ import { parsePaneKey } from '../../../shared/stable-pane-id' export function namesRefusedResume( refused: RefusedPaneResume, claim: { - agent: string | undefined - providerSession: AgentProviderSessionMetadata | undefined + agent?: string + providerSession?: AgentProviderSessionMetadata connectionId?: string | null } ): boolean { diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 00c47f208593..5ca1dfc8287a 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -411,14 +411,14 @@ describe('buildWorkspaceSessionPatch', () => { expect(patch.defaultTerminalTabsAppliedByWorktreeId).toBeUndefined() }) - it('keeps sleeping agent session clearing keys in patches', () => { + it('clears the last sleeping agent session with an empty map the host split keeps', () => { const patch = buildWorkspaceSessionPatch( createSnapshot({ sleepingAgentSessionsByPaneKey: {} }), ['sleepingAgentSessionsByPaneKey'] ) - expect(Object.hasOwn(patch, 'sleepingAgentSessionsByPaneKey')).toBe(true) - expect(patch.sleepingAgentSessionsByPaneKey).toBeUndefined() + // undefined would be dropped before the write, leaving the record on disk until quit. + expect(patch.sleepingAgentSessionsByPaneKey).toEqual({}) }) it('keeps a staged browser tab out of incremental patches too', () => { diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index d1a16cf25e9c..2eb9ad956d11 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -200,8 +200,9 @@ export function buildWorkspaceSessionPatch( : undefined } if (changed.has('sleepingAgentSessionsByPaneKey')) { + // Why `{}`: the host split drops undefined, so clearing the last record never reached disk. patch.sleepingAgentSessionsByPaneKey = - buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey + buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey ?? {} } return patch From 5f86ccef5488d57b2ad6678d690867e04414629a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:00:41 -0700 Subject: [PATCH 8/9] fix(terminal): forget a refused resume only as the refusing pane's Clearing every saved copy of the conversation in the workspace reached panes in another namespace (a WSL tab in the same folder), missed the pane's own record after its workspace was re-keyed, and let one pane's refusal undo another's kept session. The window now forgets the conversation only for the refusing pane and its older keys in the same tab; any other pane holding it is refused and forgotten at its own restore, since the host checks every time. Queued events are matched by the pane's current key, as they are applied. An unknown owner again refuses only what restore admission already calls automatic, so reopening a terminal is never blocked by bookkeeping. --- src/main/ipc/pty/ipc/spawn-preflight.ts | 6 +-- .../ipc-events/agent-status-listeners.ts | 4 +- .../lib/forget-refused-pane-resume.test.ts | 16 +++---- .../src/lib/forget-refused-pane-resume.ts | 42 ++++++++----------- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 34535e3746e7..f9fb7e77435c 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -206,11 +206,11 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis const ownership = ctx.preAdoptedStablePane ? null : await nativeChatSpawnResumeOwnership(ctx.deps.runtime, args, ctx.expectedWslDistro) - // Why an unknown owner refuses all but a user's resume: bookkeeping must not block what the user - // asked for, and every automatic restore keeps its saved session for the next try. + // Why an unknown owner refuses only what admission already calls an automatic restore: bookkeeping + // must not block a resume the user asked for, and the refused restore keeps its saved session. if ( ownership?.verdict === 'owned' || - (ownership?.verdict === 'unverifiable' && args.spawnIntent !== 'user-resume') + (ownership?.verdict === 'unverifiable' && args.spawnIntent === 'automatic-restore') ) { ctx.resumeRefusal = ownership dropAgentLaunch(args) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index f1591951b93b..ca942aba2e90 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -10,6 +10,7 @@ import { } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' import { forgetRefusedPaneResume, namesRefusedResume } from '@/lib/forget-refused-pane-resume' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' @@ -23,7 +24,8 @@ function dropEventsRestoringRefused( } ): void { const restores = (event: AgentStatusIpcPayload): boolean => - event.paneKey === paneKey && namesRefusedResume(refused, { ...event, agent: event.agentType }) + resolveAgentPaneAuthorityKey(event.paneKey) === paneKey && + namesRefusedResume(refused, { ...event, agent: event.agentType }) const pending = queues.pendingAgentStatusEvents for (let index = pending.length - 1; index >= 0; index -= 1) { if (restores(pending[index].data)) { diff --git a/src/renderer/src/lib/forget-refused-pane-resume.test.ts b/src/renderer/src/lib/forget-refused-pane-resume.test.ts index cae92d6dbee8..58ab42a78af8 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.test.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.test.ts @@ -7,11 +7,11 @@ const PANE = 'tab-floating:11111111-1111-4111-8111-111111111111' const CHAT = { key: 'session_id' as const, id: 'chat-session' } const TITLE = { agent: 'claude' as const, sessionId: CHAT.id, title: 'the chat prompt' } -function saved(paneKey: string, id: string, worktreeId = 'wt'): SleepingAgentSessionRecord { +function saved(paneKey: string, id: string): SleepingAgentSessionRecord { const providerSession = { key: 'session_id' as const, id } return { paneKey, - worktreeId, + worktreeId: 'wt', agent: 'claude', providerSession, prompt: '', @@ -22,15 +22,14 @@ function saved(paneKey: string, id: string, worktreeId = 'wt'): SleepingAgentSes } describe('forgetRefusedPaneResume', () => { - it("forgets the chat conversation as this workspace's pane resume, and leaves the chat and others alone", () => { + it("forgets the chat conversation as this pane's resume, and leaves the chat and other panes alone", () => { const store = createTestStore() store.setState({ sleepingAgentSessionsByPaneKey: { [PANE]: saved(PANE, CHAT.id), 'tab-floating:alias': saved('tab-floating:alias', CHAT.id), - 'tab-other:leaf': saved('tab-other:leaf', 'other-session'), - // The same id saved on another host's worktree is another conversation. - 'tab-ssh:leaf': saved('tab-ssh:leaf', CHAT.id, 'ssh-wt') + // Another pane's copy is refused and forgotten at that pane's own restore. + 'tab-other:leaf': saved('tab-other:leaf', CHAT.id) }, tabsByWorktree: { wt: [makeTab({ id: 'tab-floating', worktreeId: 'wt', aiVaultTitle: TITLE })], @@ -42,10 +41,7 @@ describe('forgetRefusedPaneResume', () => { forgetRefusedPaneResume(store.getState(), PANE, { agent: 'claude', providerSession: CHAT }) const state = store.getState() - expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([ - 'tab-other:leaf', - 'tab-ssh:leaf' - ]) + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual(['tab-other:leaf']) expect(state.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() expect(state.tabsByWorktree.wt![0]!.aiVaultTitle).toBeNull() expect(state.tabsByWorktree.chat![0]!.aiVaultTitle).toEqual(TITLE) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts index e366f6241c70..2acd63ed1045 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -24,9 +24,10 @@ export function namesRefusedResume( } /** - * Main refused `paneKey`'s saved resume because a native chat owns that conversation. Forgets it - * everywhere this renderer kept it, whether or not the pane is mounted, so nothing offers or - * retries it. Main already forgot its own copy, so nothing is sent back. + * Main refused `paneKey`'s saved resume because a native chat owns that conversation. Forgets it as + * this pane's, whether or not the pane is mounted, so nothing offers or retries it here. Another + * pane holding the same conversation is refused and forgotten at its own restore. Main already + * forgot its own copy, so nothing is sent back. */ export function forgetRefusedPaneResume( state: AppState, @@ -35,39 +36,32 @@ export function forgetRefusedPaneResume( ): void { const names = (claim: Parameters[1]) => namesRefusedResume(refused, claim) - const live = state.agentStatusByPaneKey[paneKey] const retained = state.retainedAgentsByPaneKey[paneKey] if (retained && names({ ...retained.entry, agent: retained.agentType })) { state.dismissRetainedAgent(paneKey) } - const tabId = parsePaneKey(paneKey)?.tabId - const tab = tabId - ? Object.values(state.tabsByWorktree) - .flat() - .find((candidate) => candidate.id === tabId) - : undefined - const worktreeId = - tab?.worktreeId ?? state.sleepingAgentSessionsByPaneKey[paneKey]?.worktreeId ?? live?.worktreeId - // Why: a pane already on another conversation keeps that one's row, launch config and hint; - // its next capture replaces its own saved record. - const movedOn = live !== undefined && !names({ ...live, agent: live.agentType }) - // Why this worktree: another host's or namespace's copy of the id is another conversation, and - // legacy aliases of this pane hold duplicates of it here. - const sleeping = Object.entries(state.sleepingAgentSessionsByPaneKey).flatMap(([key, record]) => - record.worktreeId === worktreeId && names(record) && !(movedOn && key === paneKey) ? [key] : [] - ) - if (sleeping.length > 0) { - state.clearSleepingAgentSessionsByPaneKey(sleeping) - } - if (movedOn) { + const live = state.agentStatusByPaneKey[paneKey] + // Why: a pane already on another conversation keeps that one's row, record, launch config and hint. + if (live && !names({ ...live, agent: live.agentType })) { return } + const tabId = parsePaneKey(paneKey)?.tabId ?? paneKey.split(':')[0] + // Why the tab's other keys: legacy aliases of this pane can hold duplicates of its record. + const saved = Object.entries(state.sleepingAgentSessionsByPaneKey).flatMap(([key, record]) => + (key === paneKey || key.startsWith(`${tabId}:`)) && names(record) ? [key] : [] + ) + if (saved.length > 0) { + state.clearSleepingAgentSessionsByPaneKey(saved) + } // Why also with no row: the launch config the pane registered for the refused resume goes too. state.removeAgentStatus(paneKey) const hint = state.paneForegroundAgentByPaneKey[paneKey] if (hint?.agentEvidence === 'launch-record' && hint.agent === refused.agent) { state.clearPaneForegroundAgent(paneKey) } + const tab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) // Why only this pane's tab: the chat's own tab carries the same conversation's title. if ( tab?.aiVaultTitle?.agent === refused.agent && From ff0fcdfdf1d2eefbf81ef4929cc56bab4c5fc20a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:17:04 -0700 Subject: [PATCH 9/9] fix(terminal): a refused resume never clears a split sibling's saved session The same-tab match meant for a pane's older numeric keys also matched a split sibling's current key, so one pane's refusal could clear another's saved session and let that pane's late result save the chat's conversation again. Only legacy numeric keys of the tab now count as the pane's own. --- src/renderer/src/lib/forget-refused-pane-resume.test.ts | 7 +++++-- src/renderer/src/lib/forget-refused-pane-resume.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.test.ts b/src/renderer/src/lib/forget-refused-pane-resume.test.ts index 58ab42a78af8..c6036cf90165 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.test.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.test.ts @@ -4,6 +4,7 @@ import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' import { forgetRefusedPaneResume } from './forget-refused-pane-resume' const PANE = 'tab-floating:11111111-1111-4111-8111-111111111111' +const SIBLING = 'tab-floating:22222222-2222-4222-8222-222222222222' const CHAT = { key: 'session_id' as const, id: 'chat-session' } const TITLE = { agent: 'claude' as const, sessionId: CHAT.id, title: 'the chat prompt' } @@ -27,7 +28,9 @@ describe('forgetRefusedPaneResume', () => { store.setState({ sleepingAgentSessionsByPaneKey: { [PANE]: saved(PANE, CHAT.id), - 'tab-floating:alias': saved('tab-floating:alias', CHAT.id), + 'tab-floating:1': saved('tab-floating:1', CHAT.id), + // A split sibling is another pane, refused at its own restore. + [SIBLING]: saved(SIBLING, CHAT.id), // Another pane's copy is refused and forgotten at that pane's own restore. 'tab-other:leaf': saved('tab-other:leaf', CHAT.id) }, @@ -41,7 +44,7 @@ describe('forgetRefusedPaneResume', () => { forgetRefusedPaneResume(store.getState(), PANE, { agent: 'claude', providerSession: CHAT }) const state = store.getState() - expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual(['tab-other:leaf']) + expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([SIBLING, 'tab-other:leaf']) expect(state.agentLaunchConfigByPaneKey[PANE]).toBeUndefined() expect(state.tabsByWorktree.wt![0]!.aiVaultTitle).toBeNull() expect(state.tabsByWorktree.chat![0]!.aiVaultTitle).toEqual(TITLE) diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts index 2acd63ed1045..3576d463630f 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -4,7 +4,7 @@ import { agentProviderSessionsEqual, type AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' -import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' /** Whether a saved claim names the refused conversation. Only local claims can: the host judged * its own chats, and a remote host's same id is a different conversation. */ @@ -46,9 +46,12 @@ export function forgetRefusedPaneResume( return } const tabId = parsePaneKey(paneKey)?.tabId ?? paneKey.split(':')[0] - // Why the tab's other keys: legacy aliases of this pane can hold duplicates of its record. + // Why legacy numeric keys of this tab: they are this pane's older names, which its restore reads; + // a split sibling's current key is another pane. const saved = Object.entries(state.sleepingAgentSessionsByPaneKey).flatMap(([key, record]) => - (key === paneKey || key.startsWith(`${tabId}:`)) && names(record) ? [key] : [] + (key === paneKey || parseLegacyNumericPaneKey(key)?.tabId === tabId) && names(record) + ? [key] + : [] ) if (saved.length > 0) { state.clearSleepingAgentSessionsByPaneKey(saved)