From e1d8629ade2885fcdd1b61c68a339ace7033975b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:36:43 -0700 Subject: [PATCH 001/207] fix(native-chat): one ownership index with link roles, and a chat refuses to start while a terminal writes its conversation Each chat link now carries its role (head or earlier) and key; Session History names the chat continuing a conversation first, duplicate adoption checks only head links in both the pre-commit check and the store transaction, and terminal resumes stay refused exactly as before. The terminal-writer check now also runs inside acquireOwner before the spawn, refusing with the typed pre-spawn reason conversationOpenInTerminal. --- ...ructured-session-ownership.test-support.ts | 26 +++- .../structured-session-ownership.test.ts | 36 +++++ .../ai-vault/structured-session-ownership.ts | 73 +++++---- .../structured-agent-session-acquisition.ts | 9 ++ .../structured-agent-session-adapter.ts | 1 + .../structured-agent-session-attach-flow.ts | 2 + ...ured-agent-session-attach-orchestration.ts | 3 + .../structured-agent-session-failure-text.ts | 3 +- .../structured-agent-session-host-types.ts | 2 + ...nt-session-terminal-writer-acquire.test.ts | 138 ++++++++++++++++++ .../structured-provider-session-ownership.ts | 61 ++++++-- ...red-agent-session-history-adoption.test.ts | 9 +- ...ructured-agent-session-history-adoption.ts | 10 +- .../structured-conversation-key.ts | 63 ++++++++ ...gent-session-reservation-admission.test.ts | 23 +++ .../agent-session-reservation-admission.ts | 18 ++- .../structured-session-cli-writer-host.ts | 17 +++ .../structured-session-create-support.ts | 4 +- .../structured-agent-session-runtime.ts | 3 + .../workspace-process-listing-dependencies.ts | 4 + .../workspaces/workspace-process-listing.ts | 1 + .../agent-session-failure-words-text.ts | 6 + .../tab-bar/cli-chat-resume-feedback.ts | 8 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../agent-session-attachment-failure-words.ts | 43 ++++++ src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 36 +---- src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + ...ctured-agent-session-dispatch-rejection.ts | 2 + 37 files changed, 521 insertions(+), 93 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts create mode 100644 src/main/native-chat/structured-conversation-key.ts create mode 100644 src/shared/agent-session-attachment-failure-words.ts diff --git a/src/main/ai-vault/structured-session-ownership.test-support.ts b/src/main/ai-vault/structured-session-ownership.test-support.ts index f1b8048549b9..51f1b60bc6b5 100644 --- a/src/main/ai-vault/structured-session-ownership.test-support.ts +++ b/src/main/ai-vault/structured-session-ownership.test-support.ts @@ -18,12 +18,12 @@ export const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' /** Installs a host whose one record owns a conversation: by default a Codex chat on * PROVIDER_SESSION, or the agent `handle` names. */ export function installOwnership( - overrides: Partial & { + overrides: Partial> & { handle?: AgentSessionProviderHandle } = {} ): void { const { handle, ...ownershipOverrides } = overrides - const ownership: StructuredProviderSessionOwnership = { + const ownership: Omit = { sessionId: 'session-alpha', workspaceId: 'workspace-1', provider: handle?.agent ?? 'codex', @@ -90,6 +90,28 @@ export function installCopyingChat( } as never) } +/** A live Codex chat whose chain held `threadIds` in order; the last is its head. */ +export function codexChatHolding(sessionId: string, threadIds: string[]): AgentSessionRecord { + const lease = { ...agentSessionLeaseFixture(), sessionId } + const fixture = agentSessionRecordFixture(lease) + return { + ...fixture, + sessionId, + provider: 'codex', + providerHandleChain: threadIds.map((threadId, index) => ({ + ...fixture.providerHandleChain[0]!, + linkId: `${sessionId}-${index}`, + handle: codexProviderHandle(threadId) + })), + lease + } +} + +export function installChats(records: AgentSessionRecord[]): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership read touches only `deps.store.listRecords`; the rest of the host is never reached. + setStructuredAgentSessionHost({ deps: { store: { listRecords: () => records } } } as never) +} + export function listResult(): AiVaultListResult { const session: AiVaultSession = { id: `local:codex:${PROVIDER_SESSION}`, diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 3cda409fbadc..53367be93765 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -4,12 +4,15 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { assertLegacyAiVaultResumeAllowed, assertLegacyAiVaultResumeCommandAllowed, + findResumeOwnership, OPENS_EVERY_STRUCTURED_CHAT, projectStructuredAiVaultSearchResponse, projectStructuredAiVaultSessions } from './structured-session-ownership' import type { AiVaultSearchHit, AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import { + codexChatHolding, + installChats, installOwnership, listResult, PROVIDER_SESSION @@ -108,6 +111,39 @@ describe('structured AI Vault ownership', () => { } ) + it('names the chat continuing a conversation first, while every link still refuses a terminal', async () => { + const movedOn = codexChatHolding('session-moved', [PROVIDER_SESSION, OTHER_SESSION]) + const owned = (records: ReturnType[]) => { + installChats(records) + return projectStructuredAiVaultSessions(listResult(), OPENS_EVERY_STRUCTURED_CHAT).sessions[0] + ?.structuredSession?.sessionId + } + expect(owned([movedOn, codexChatHolding('session-now', [PROVIDER_SESSION])])).toBe( + 'session-now' + ) + expect(owned([movedOn])).toBe('session-moved') + expect( + findResumeOwnership({ agent: 'codex', sessionId: PROVIDER_SESSION, filePath: '' }) + ).toMatchObject({ + sessionId: 'session-moved', + role: 'earlier' + }) + expect(() => + assertLegacyAiVaultResumeAllowed({ + agent: 'codex', + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + executionHostId: 'local' + }) + ).toThrow('agent_session_conflict') + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `codex resume ${PROVIDER_SESSION}`, + async () => undefined + ) + ).rejects.toThrow('agent_session_conflict') + }) + it('derives typed refusals from the single writer predicate for live and proving leases', async () => { installOwnership() expect(() => diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index e2551eab731f..2bd9640c3193 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -9,6 +9,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' import { listStructuredProviderSessionOwnership, + terminalResumeBlocked, type StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' import type { StructuredAgentId } from '../../shared/agent-session-provider-handle' @@ -115,7 +116,8 @@ function ownedRow(rowAgent: string, ownership: StructuredProviderSessionOwnershi : { structuredSession } } -/** One index per projection: a list holds hundreds of rows, each once searched every record. */ +/** One index per projection: a list holds hundreds of rows, each once searched every record. A row + * names the chat continuing its conversation now, else one whose chain held it earlier. */ function ownershipLookup(): | ((row: { agent: string; sessionId: string }) => StructuredProviderSessionOwnership | null) | null { @@ -125,18 +127,16 @@ function ownershipLookup(): const byProviderSession = new Map() for (const ownership of listOwnership()) { const key = `${ownership.provider}\0${ownership.providerSessionId}` - if (!byProviderSession.has(key)) { + const kept = byProviderSession.get(key) + if (!kept || (kept.role !== 'head' && ownership.role === 'head')) { byProviderSession.set(key, ownership) } } return (row) => { - for (const owner of historyRowOwners(row.agent)) { - const ownership = byProviderSession.get(`${owner}\0${row.sessionId}`) - if (ownership) { - return ownership - } - } - return null + const found = historyRowOwners(row.agent).flatMap( + (owner) => byProviderSession.get(`${owner}\0${row.sessionId}`) ?? [] + ) + return found.find((ownership) => ownership.role === 'head') ?? found[0] ?? null } } @@ -157,50 +157,63 @@ function historyRowOwners(rowAgent: string): readonly StructuredAgentId[] { export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { // A fork writes a new conversation, and preparing its home never writes this one. - const ownership = args.fork ? null : findResumeOwnership(args) - if (ownership) { - refuseLegacyWriter(ownership) + const blocking = args.fork ? undefined : resumeOwnerships(args).find(terminalResumeBlocked) + if (blocking) { + refuseLegacyWriter(blocking) } } -export async function assertLegacyAiVaultResumeCommandAllowed( +/** A terminal resume a command asks for: the chat agent whose conversations it names, and the + * conversation (null: any of that agent's). */ +export type AdmittedResumeInvocation = { provider: StructuredAgentId; target: string | null } + +/** Refuses a command resuming a conversation a chat holds; otherwise `register` runs in the same + * synchronous step as the check, so a chat starting meanwhile sees what it registered. */ +export async function assertLegacyAiVaultResumeCommandAllowed( command: string, - ensureHost: () => Promise -): Promise { + ensureHost: () => Promise, + register?: (invocations: AdmittedResumeInvocation[]) => T +): Promise { const invocations = parseResumeInvocations(command) if (invocations.length === 0) { - return + return undefined } // A terminal command is not a chat: with chats refused here there is no ownership to check. await ensureStructuredAgentSessionHostUnlessRefused(ensureHost) - const host = getStructuredAgentSessionHost() - if (!host) { - return - } for (const ownership of listOwnership()) { - if (invocations.some((invocation) => resumesOwnedSession(invocation, ownership))) { + if ( + terminalResumeBlocked(ownership) && + invocations.some((invocation) => resumesOwnedSession(invocation, ownership)) + ) { refuseLegacyWriter(ownership) } } + return register?.(invocations.map(({ provider, target }) => ({ provider, target }))) } -/** The chat that owns the row a resume or fork names; null when none does. */ +/** Any chat whose chain ever held the row a resume or fork names; null when none did. */ export function findResumeOwnership( args: Pick ): StructuredProviderSessionOwnership | null { + return resumeOwnerships(args)[0] ?? null +} + +/** Every link naming the row, those matched by id first. */ +function resumeOwnerships( + args: Pick +): StructuredProviderSessionOwnership[] { const owners = new Set(historyRowOwners(args.agent)) if (owners.size === 0) { - return null + return [] } const owned = listOwnership().filter((ownership) => owners.has(ownership.provider)) const fileName = args.filePath.split(/[\\/]/).at(-1) ?? '' - return ( - (args.sessionId - ? owned.find((ownership) => ownership.providerSessionId === args.sessionId) - : undefined) ?? - owned.find((ownership) => fileName.includes(ownership.providerSessionId)) ?? - null - ) + return [ + ...(args.sessionId + ? owned.filter((ownership) => ownership.providerSessionId === args.sessionId) + : []), + ...owned.filter((ownership) => fileName.includes(ownership.providerSessionId)) + ] } function listOwnership(): StructuredProviderSessionOwnership[] { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index dac32c5e34ec..5a2028e82fc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -45,6 +45,15 @@ export async function acquireOwner( throw new AgentSessionPreSpawnError(error) } const permissionMode = await input.launchPermissionMode?.(record) + try { + await input.assertNoTerminalWriter?.(record) + } catch (error) { + // Not a code: a code message would travel as the census's own conflict, not this reason. + throw new AgentSessionPreSpawnError(error, { + reason: 'conversationOpenInTerminal', + message: 'a terminal may still be writing this conversation' + }) + } // Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing. if (input.acquireSignal?.aborted) { throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index baabf4eb1f10..6af0c62364e2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -132,6 +132,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'claudeAccountSetupFailed' | 'agentCommandNotRunnable' | 'runtimeNotInstalled' + | 'conversationOpenInTerminal' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index f45d9bdddfc7..43603cebdc5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -104,6 +104,8 @@ export type AttachFlowInput = { eventSink?: StructuredAgentSessionEventSink /** Stops acquisition-window events targeting the superseded journal. */ onAcquiring?: () => Promise | void + /** Refuses the spawn while a terminal may still be writing the record's conversation. */ + assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void /** The conversation's own open journal, which the attach adopts: it never opens one itself. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 76c1655826a4..a3b0df17933b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -256,6 +256,9 @@ async function runAttachUnderAbort( launchPermissionMode: async (record) => (await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ?? undefined, + assertNoTerminalWriter: async (record) => { + await lock.wait(Promise.resolve(context.deps.assertNoTerminalWriter?.(record))) + }, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { acquisition: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index ac7c598fecb8..f4438f6e3a2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -37,7 +37,8 @@ const TYPED_START_REFUSALS = [ 'claudeAccountFolderMissing', 'claudeAccountSetupFailed', 'agentCommandNotRunnable', - 'runtimeNotInstalled' + 'runtimeNotInstalled', + 'conversationOpenInTerminal' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 29bf3d3ab55b..4a3caddd92da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -160,6 +160,8 @@ export type StructuredAgentSessionHostDeps = { hasOpenDispatch?: (record: AgentSessionRecord) => boolean /** A chat tab left the screen: closed, or its workspace removed. Advisory; a throw is logged. */ onSessionTabHidden?: (sessionId: string) => void + /** Throws while a terminal may still write the record's head conversation; run before a spawn. */ + assertNoTerminalWriter?: (record: AgentSessionRecord) => Promise /** Where every failure the host carries on past is reported. Required: a host without one would * drop exactly the failures nobody sees in the UI. */ logger: StructuredAgentSessionLogger diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts new file mode 100644 index 000000000000..001d926b0860 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -0,0 +1,138 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let terminalWriting: boolean +const CALLER = { callerKey: 'client-1' } +const acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: fence === 1 ? ('created' as const) : ('resumed' as const), + mintedAtFence: fence, + observedAt: NOW + } +})) +const assertNoTerminalWriter = vi.fn(async () => { + if (terminalWriting) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } +}) + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-terminal-writer-acquire-')) + resetHostTestOperationIds() + terminalWriting = false + acquire.mockClear() + assertNoTerminalWriter.mockClear() + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire, + dispatch: vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { provider: 'codex' as const, threadId: THREAD, turnId: 't', ordinal: 1 } + })), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined), + open: async () => ({ reportedOptions: { model: '' } }) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + assertNoTerminalWriter, + now: () => NOW + }) + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const envelope = { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + expect(await host.send(CALLER, { envelope, body })).toMatchObject({ ok: true }) + return envelope.clientOperationId +} + +async function rejection(id: string) { + await vi.waitFor( + async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === id) + ?.dispatchState + ).toBe('rejected'), + { timeout: 10_000 } + ) + return (await host.journalSnapshot(SESSION)).submissions.find((s) => s.clientMessageId === id) +} + +// H10: a chat reopened while its conversation runs in a terminal starts no second writer. +it('refuses the chat agent start while a terminal writes its conversation, once per send', async () => { + await host.close(SESSION, 'evict') + terminalWriting = true + await host.revealSession(SESSION) + const spawnsBefore = acquire.mock.calls.length + assertNoTerminalWriter.mockClear() + + const first = await rejection(await send('first')) + expect(first?.rejection).toMatchObject({ kind: 'conversationOpenInTerminal' }) + expect(first?.reason).toContain('has exited in its terminal') + expect(acquire.mock.calls.length).toBe(spawnsBefore) + expect(store.getRecord(SESSION)?.lease.reservedSpawnToken).toBeNull() + + await rejection(await send('second')) + expect(assertNoTerminalWriter).toHaveBeenCalledTimes(2) + expect(acquire.mock.calls.length).toBe(spawnsBefore) + + terminalWriting = false + await send('after the terminal exits') + await vi.waitFor(() => expect(acquire.mock.calls.length).toBe(spawnsBefore + 1), { + timeout: 10_000 + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index a541bfcfbbae..a7f34eb73fd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -1,6 +1,12 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' +import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata' +import { + keysMayMatch, + recordConversationKey, + type ConversationKey +} from '../structured-conversation-key' export type StructuredProviderSessionOwnership = { sessionId: string @@ -9,24 +15,57 @@ export type StructuredProviderSessionOwnership = { providerSessionId: string conversationName?: string lease: AgentSessionLease + /** `head`: the conversation the chat continues now; `earlier`: one its chain held before. */ + role: 'head' | 'earlier' + key: ConversationKey } -/** Which chat owns each conversation, under the Session History row id its agent's registration - * names: the one index both the duplicate-adoption check and Session History ownership read. */ +/** Every handle link of every chat, under the Session History row id its agent's registration + * names, with its role and lease; each consumer applies its own predicate. */ export function listStructuredProviderSessionOwnership( records: readonly AgentSessionRecord[] ): StructuredProviderSessionOwnership[] { return records.flatMap((record) => { const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory - return history - ? record.providerHandleChain.map((link) => ({ - sessionId: record.sessionId, - workspaceId: record.location.workspaceId, - provider: record.provider, - providerSessionId: history.rowSessionId(link), - conversationName: record.conversationName, - lease: record.lease - })) + const head = record.providerHandleChain.at(-1) + const headKey = head ? recordConversationKey(record, head) : null + return history && headKey + ? record.providerHandleChain.flatMap((link) => { + const key = recordConversationKey(record, link) + return key + ? [ + { + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + provider: record.provider, + providerSessionId: history.rowSessionId(link), + conversationName: record.conversationName, + lease: record.lease, + // A repeat resume link of the head's id is the head too. + role: keysMayMatch(key, headKey) ? ('head' as const) : ('earlier' as const), + key + } + ] + : [] + }) : [] }) } + +/** Fail closed: only a lease released on proof its owner exited can't be writing. */ +export function agentSessionLeasePossiblyWriting(lease: AgentSessionLease): boolean { + return agentSessionLeaseOwnerVerdict(lease) !== 'exited' +} + +/** Agents whose chats let a terminal resume the same conversation once the chat stopped writing. */ +export function sameIdTerminalResumeEnabled(_provider: StructuredAgentId): boolean { + return false +} + +/** Whether a terminal resume of this link's conversation is refused. */ +export function terminalResumeBlocked(holding: StructuredProviderSessionOwnership): boolean { + return ( + !sameIdTerminalResumeEnabled(holding.provider) || + (holding.role === 'head' && agentSessionLeasePossiblyWriting(holding.lease)) + ) +} diff --git a/src/main/native-chat/structured-agent-session-history-adoption.test.ts b/src/main/native-chat/structured-agent-session-history-adoption.test.ts index 718e7fa85b54..412d4ff6d179 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.test.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.test.ts @@ -64,12 +64,13 @@ function ownership( provider: 'codex', providerSessionId: 'thread-1', lease: agentSessionLeaseFixture(), + role: 'head', ...overrides } } describe('findConflictingStructuredAdoption', () => { - it('names the session that already holds the conversation', () => { + it('names the session continuing the conversation now, never one that held it earlier', () => { const owner = ownership() expect( @@ -77,7 +78,11 @@ describe('findConflictingStructuredAdoption', () => { agent: 'codex', providerSessionId: 'thread-1', selfSessionId: 'codex_new', - ownership: [ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), owner] + ownership: [ + ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), + ownership({ sessionId: 'moved_on', role: 'earlier' }), + owner + ] }) ).toBe(owner) }) diff --git a/src/main/native-chat/structured-agent-session-history-adoption.ts b/src/main/native-chat/structured-agent-session-history-adoption.ts index 65a5e37c92c8..6f0cb006735e 100644 --- a/src/main/native-chat/structured-agent-session-history-adoption.ts +++ b/src/main/native-chat/structured-agent-session-history-adoption.ts @@ -24,6 +24,7 @@ export type StructuredAgentSessionAdoptionOwnership = { provider: StructuredAgentId providerSessionId: string lease: AgentSessionLease + role: 'head' | 'earlier' } export type StructuredAgentSessionAdoption = { @@ -75,10 +76,10 @@ export function findCommittedStructuredAgentSessionAdoptionReplay(input: { } /** - * A conversation has exactly one writer. Codex takes no lock of its own: a second app-server holding - * the same thread never errors, it loads history once and then diverges, and the rollout ends up - * recording a conversation that never happened. So the refusal is the correctness guard, and it has - * to be able to tell "someone else owns this" from "this very operation owns it". + * A conversation has exactly one writer, and an adoption must never mint a second chat for one a + * chat continues now (its head link; an earlier link holds nothing). Codex's own writer lock covers + * only a running process, so this refusal is the guard for chats at rest, and it has to tell + * "someone else owns this" from "this very operation owns it". * * @param selfSessionId the structured session this create is reserving. A retry of a committed * create re-runs every pre-commit check, and by then the record it created is itself in the @@ -94,6 +95,7 @@ export function findConflictingStructuredAdoption(input: { input.ownership.find( (owner) => owner.sessionId !== input.selfSessionId && + owner.role === 'head' && chatAgentsShareConversations(owner.provider, input.agent) && owner.providerSessionId === input.providerSessionId ) ?? null diff --git a/src/main/native-chat/structured-conversation-key.ts b/src/main/native-chat/structured-conversation-key.ts new file mode 100644 index 000000000000..ac1a7c3823e4 --- /dev/null +++ b/src/main/native-chat/structured-conversation-key.ts @@ -0,0 +1,63 @@ +import { + chatAgentsShareConversations, + CLI_CONVERSATION_ADOPTING_AGENTS +} from '../../shared/structured-agent-cli-conversation-adoption' +import { + agentSessionAccountHomesEqual, + type AgentSessionAccountHome +} from '../../shared/agent-session-account-home' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { structuredAgentRuntimeMetadata } from '../runtime/structured-agent-runtime-metadata' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' + +/** One conversation wherever it is named: the agents sharing its ids, its id (null: a targetless + * resume that may pick any of the family's), and its account home (null: the namer can't tell). */ +export type ConversationKey = { + family: string + id: string | null + home: AgentSessionAccountHome | null +} + +const FAMILY_MEMBERS = [...new Set(Object.entries(CLI_CONVERSATION_ADOPTING_AGENTS).flat(2))].sort() + +/** Agents that open one conversation's ids (`opencode`, `opencode2`) share one family name. */ +export function conversationFamily(agent: string): string { + return FAMILY_MEMBERS.find((member) => chatAgentsShareConversations(member, agent)) ?? agent +} + +export function recordConversationKey( + record: AgentSessionRecord, + link: AgentSessionProviderHandleLink +): ConversationKey | null { + const history = structuredAgentRuntimeMetadata(record.provider)?.sessionHistory + return history + ? { + family: conversationFamily(record.provider), + id: history.rowSessionId(link), + home: record.accountHome + } + : null +} + +/** A terminal names a conversation by the id its hooks or command carry; its home is unknown. */ +export function terminalConversationKey(agent: string, id: string | null): ConversationKey { + return { family: conversationFamily(agent), id: id?.trim() || null, home: null } +} + +export function keysMayMatch(left: ConversationKey, right: ConversationKey): boolean { + return ( + left.family === right.family && + (left.id === null || right.id === null || sameConversationId(left.id, right.id)) && + (left.home === null || + right.home === null || + agentSessionAccountHomesEqual(left.home, right.home)) + ) +} + +// A session-file agent's terminal may name the file; Session History lists its file-name id. +function sameConversationId(left: string, right: string): boolean { + const normalize = (id: string) => + (/[\\/]/.test(id) ? sessionIdFromFileName(id) : id).toLowerCase() + return normalize(left) === normalize(right) +} diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 0858424559e4..c4a0b534f156 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -143,6 +143,29 @@ describe('adopted conversation ownership', () => { ).not.toThrow() }) + it('admits a conversation another record only held before its current one', () => { + const fixture = agentSessionRecordFixture() + const holder: AgentSessionRecord = { + ...fixture, + providerHandleChain: [ + ...fixture.providerHandleChain, + adoptedLink({ + linkId: 'later', + handle: claudeProviderHandle('provider-session-later', null), + origin: 'created' + }) + ] + } + + expect(() => + applyAgentSessionReservation( + storeState([holder]), + reserveRequest({ adoptedHandleLink: adoptedLink() }), + LEASE_TTL_MS + ) + ).not.toThrow() + }) + it('exempts the requesting session so a committed create can be re-run', () => { // Pins the guard's own contract. No wire shape reaches it today: `adopt` is accepted only on // create-by-intent, which always carries a null expected fence, and an existing record with a diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 31a1ece91ff5..eceb805ffed9 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -221,7 +221,8 @@ export function applyAgentSessionReservation( } /** - * Refuse an adoption whose conversation ANOTHER record already holds. + * Refuse an adoption whose conversation ANOTHER record continues now (its head link), the same rule + * as the pre-commit check, so that check never admits what this one refuses. * * The self-exemption is part of that definition, not a replay mechanism: replay is settled earlier * by the operation ledger, and an adoption always arrives with a null expected fence, so a request @@ -246,14 +247,15 @@ export function assertAdoptedConversationUnowned( if (record.sessionId === request.sessionId) { continue } - const holdsSameConversation = record.providerHandleChain.some( - (link) => - agentSessionProviderHandleRoot(link.handle) === root || + // Only the conversation a chat continues now: an earlier link no longer holds anything. + const head = record.providerHandleChain.at(-1) + const holdsSameConversation = + !!head && + (agentSessionProviderHandleRoot(head.handle) === root || // One id from two chat agents that open the same conversations (`opencode`, `opencode2`). - (link.handle.transport === adopted.handle.transport && - link.handle.nativeId === adopted.handle.nativeId && - chatAgentsShareConversations(link.handle.agent, adopted.handle.agent)) - ) + (head.handle.transport === adopted.handle.transport && + head.handle.nativeId === adopted.handle.nativeId && + chatAgentsShareConversations(head.handle.agent, adopted.handle.agent))) if (holdsSameConversation) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere', diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 9cc4e05c4532..6621f1daabb7 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -16,6 +16,9 @@ import { OPEN_CODE_WRITER_TOPOLOGY, cliWriterTopology } from '../../agent-hooks/server/cli-writer-observation' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' +import { recordConversationKey } from '../../native-chat/structured-conversation-key' export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { private cliWriterPtyPane(ptyId: string | null | undefined): CliWriterPane | null { @@ -70,6 +73,20 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca }) } + /** Before a chat's agent spawns: refuses while a terminal may still write its head conversation. + * Never enters the session queue, so it can run inside an acquisition. */ + protected async assertStructuredChatAcquireAdmissible(record: AgentSessionRecord): Promise { + const head = record.providerHandleChain.at(-1) + const key = head ? recordConversationKey(record, head) : null + const adopts = Object.values(CLI_CONVERSATION_ADOPTING_AGENTS).some( + (agents: readonly string[]) => agents.includes(record.provider) + ) + if (!key?.id || !adopts) { + return + } + await this.assertStructuredAdoptionTerminalWritersExited(record.provider, key.id) + } + async assertStructuredAdoptionTerminalWritersExited( agent: StructuredAgentId, providerSessionId: string, diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index b71a8c6003dd..eb3262759d67 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -128,8 +128,8 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte selfSessionId: input.envelope.sessionId }) } catch (ownerConflict) { - // A switch out of a live terminal refuses as such: the owning chat's first send would - // otherwise start a second writer. + // A switch out of a live terminal refuses as such, early; the owning chat's agent start + // refuses it again. await this.assertStructuredAdoptionTerminalWritersExited( input.agent, providerSessionId, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index fa5a7afab6dd..98b4c39c092e 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -130,6 +130,8 @@ export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportO prepareCodexCatalogProbeHome?: (homePath: string) => void /** See `StructuredAgentSessionHostDeps.onSessionTabHidden`. */ onSessionTabHidden?: StructuredAgentSessionHostDeps['onSessionTabHidden'] + /** See `StructuredAgentSessionHostDeps.assertNoTerminalWriter`. */ + assertNoTerminalWriter?: StructuredAgentSessionHostDeps['assertNoTerminalWriter'] /** Host-owned phone delivery and reconciliation from the current journal projection. */ attentionDelivery?: StructuredAttentionMobileDelivery /** The account home a structured launch would pin right now, for catalog @@ -316,6 +318,7 @@ async function installOnJournal( ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), + ...(deps.assertNoTerminalWriter ? { assertNoTerminalWriter: deps.assertNoTerminalWriter } : {}), ...(await modelCatalogHostDeps({ store, agents, diff --git a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts index b7add175f797..607b4cfdf11d 100644 --- a/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts +++ b/src/main/runtime/workspaces/workspace-process-listing-dependencies.ts @@ -5,6 +5,7 @@ import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout } from '../session import type { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from '../session-tabs/structured-session-create-support' import type { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orchestration/terminal-pane-mail-routing' import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from '../terminal/wait/terminal-wait-visible-probe' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' /** Members the worktree listing reads from later runtime layers; declarations only. */ export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { @@ -43,6 +44,9 @@ export abstract class OrcaRuntimeWithWorktreePsDependencies extends OrcaRuntimeW abstract onStructuredSessionStatusForMail( ...args: Parameters ): ReturnType + protected abstract assertStructuredChatAcquireAdmissible( + record: AgentSessionRecord + ): Promise abstract onStructuredSessionTabHidden( ...args: Parameters ): ReturnType diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 52078926e78f..916ce04af7be 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -300,6 +300,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr ...(this.structuredAgentStatusSinkFn ? { statusSink: this.structuredAgentStatusSinkFn } : {}), // A closed chat settles the Dispatch it was working, as a closed terminal does. onSessionTabHidden: (sessionId) => this.onStructuredSessionTabHidden(sessionId), + assertNoTerminalWriter: (record) => this.assertStructuredChatAcquireAdmissible(record), attentionDelivery: createStructuredAttentionMobileDelivery({ readNotificationSettings: () => this.requireStore().getSettings().notifications, readWorkspaceLabels: (scope) => diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 036f0d14c8eb..ccfb02113db0 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -142,6 +142,12 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.conversationOpenInTerminal', + COPY.conversationOpenInTerminal, + values + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 2b2949f763f3..1807c15f9c3f 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -36,6 +36,14 @@ export async function reportCliChatResumeFailure( ) return } + // The chat's agent refused to start while the terminal may still write the conversation. + if ( + refusal?.code === 'agent_session_operation_invalid' && + refusal.details?.reason === 'conversationOpenInTerminal' + ) { + toast.error(cliConversationExitGuidance(source.agent, true)) + return + } if (refusal?.code === 'agent_session_identity_required') { if (refusal.details?.reason === 'transcriptNotFound') { toast.error( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2d64b000458d..cfb2f0362c15 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2969,6 +2969,7 @@ "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7695e77a7dbd..6baed6a4e4fc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18346,6 +18346,7 @@ "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7dd3a99fd388..6d425fd3aa2b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18060,6 +18060,7 @@ "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", + "conversationOpenInTerminal": "Orca no puede confirmar que {{agent}} haya salido en su terminal. Detenga lo que se esté ejecutando allí o cierre la terminal y vuelva a intentarlo.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 35ae420d8dc6..bfcd4eaac1d2 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18169,6 +18169,7 @@ "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", + "conversationOpenInTerminal": "Orca ne peut pas confirmer que {{agent}} a quitté son terminal. Arrêtez ce qui s'y exécute ou fermez le terminal, puis réessayez.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 04e50160373e..ff8ec483edad 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18100,6 +18100,7 @@ "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", + "conversationOpenInTerminal": "Orca は {{agent}} がターミナルで終了したことを確認できません。そこで実行中のものを停止するかターミナルを閉じてから、もう一度お試しください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ed902a140f04..a2bf337d92a5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18100,6 +18100,7 @@ "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", + "conversationOpenInTerminal": "Orca에서 {{agent}}이(가) 터미널에서 종료되었는지 확인할 수 없습니다. 그곳에서 실행 중인 항목을 중지하거나 터미널을 닫은 후 다시 시도하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8838e457e685..7a8ec0fb5a7c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18065,6 +18065,7 @@ "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", + "conversationOpenInTerminal": "Orca 无法确认 {{agent}} 已在其终端中退出。请停止那里正在运行的内容或关闭终端,然后重试。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", diff --git a/src/shared/agent-session-attachment-failure-words.ts b/src/shared/agent-session-attachment-failure-words.ts new file mode 100644 index 000000000000..c53239e8f238 --- /dev/null +++ b/src/shared/agent-session-attachment-failure-words.ts @@ -0,0 +1,43 @@ +import type { + AgentSessionAttachmentProblem, + AgentSessionAttachmentProblemReason +} from './agent-session-failure' +import type { AgentSessionFailureSay } from './agent-session-failure-copy' + +const BYTES_PER_MB = 1024 * 1024 + +/** The sentence for an attachment a chat refused, from the problem the host found. */ +export function attachmentProblemSentence( + say: AgentSessionFailureSay, + agent: { agent: string }, + problem: AgentSessionAttachmentProblem +): string { + return ATTACHMENT_SENTENCES[problem.reason](say, agent, problem) +} + +// The number only; each language's sentence carries its own unit. +function megabytes(bytes: number): string { + return String(Math.round((bytes / BYTES_PER_MB) * 10) / 10) +} + +const ATTACHMENT_SENTENCES = { + empty: (say) => say('attachmentEmpty'), + tooLarge: (say, _, { limit }) => + limit ? say('attachmentLargerThan', { size: megabytes(limit) }) : say('attachmentTooLarge'), + tooMany: (say, agent, { limit }) => + limit ? say('attachmentAtMost', { ...agent, limit: String(limit) }) : say('attachmentTooMany'), + totalTooLarge: (say, _, { limit }) => + limit + ? say('attachmentTotalMoreThan', { size: megabytes(limit) }) + : say('attachmentTotalTooLarge'), + unsupportedType: (say, agent) => say('attachmentUnsupportedType', agent), + notAFile: (say) => say('attachmentNotAFile'), + noSource: (say) => say('attachmentNoSource') +} satisfies Record< + AgentSessionAttachmentProblemReason, + ( + say: AgentSessionFailureSay, + agent: { agent: string }, + problem: AgentSessionAttachmentProblem + ) => string +> diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index bd7523db85cd..d0043aa466bc 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -76,6 +76,8 @@ export const AGENT_SESSION_FAILURE_COPY = { "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", runtimeNotInstalled: "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + conversationOpenInTerminal: + "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 2d163cf3b7e9..a6dc78b30004 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -9,8 +9,6 @@ import { isSubmissionRejectionFact, - type AgentSessionAttachmentProblem, - type AgentSessionAttachmentProblemReason, type AgentSessionFailureFact, type AgentSessionFailureKind, type ProviderDiagnostic, @@ -30,6 +28,7 @@ import { START_REFUSAL_RESUMABLE } from './agent-session-start-refusal-resumable import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words' import { commandRefusedByReason } from './agent-session-command-refusal-words' import { joinSentences } from './sentence-joining' +import { attachmentProblemSentence } from './agent-session-attachment-failure-words' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_CODEX_QUEUE_FULL, @@ -76,7 +75,6 @@ export type AgentSessionFailureWordsContext = { /** Person-facing provider text is quoted, but bounded so the sentence stays one. */ const MAX_QUOTED_DETAIL_CHARS = 512 -const BYTES_PER_MB = 1024 * 1024 type Sentence = ( context: AgentSessionFailureWordsContext, @@ -168,35 +166,6 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { } } -// The number only; each language's sentence carries its own unit. -function megabytes(bytes: number): string { - return String(Math.round((bytes / BYTES_PER_MB) * 10) / 10) -} - -const ATTACHMENT_SENTENCES = { - empty: (say) => say('attachmentEmpty'), - tooLarge: (say, _, { limit }) => - limit ? say('attachmentLargerThan', { size: megabytes(limit) }) : say('attachmentTooLarge'), - tooMany: (say, context, { limit }) => - limit - ? say('attachmentAtMost', { ...agent(say, context), limit: String(limit) }) - : say('attachmentTooMany'), - totalTooLarge: (say, _, { limit }) => - limit - ? say('attachmentTotalMoreThan', { size: megabytes(limit) }) - : say('attachmentTotalTooLarge'), - unsupportedType: (say, context) => say('attachmentUnsupportedType', agent(say, context)), - notAFile: (say) => say('attachmentNotAFile'), - noSource: (say) => say('attachmentNoSource') -} satisfies Record< - AgentSessionAttachmentProblemReason, - ( - say: AgentSessionFailureSay, - context: AgentSessionFailureWordsContext, - problem: AgentSessionAttachmentProblem - ) => string -> - const FAILURE_SENTENCES = { providerStartFailed: (context, _fact, _surface, say) => joinSentences([say('providerStartFailed', agent(say, context)), ...startRetry(say, context)]), @@ -213,6 +182,7 @@ const FAILURE_SENTENCES = { claudeAccountSetupFailed: (_context, _fact, _surface, say) => say('claudeAccountSetupFailed'), agentCommandNotRunnable: namingAgent('agentCommandNotRunnable'), runtimeNotInstalled: namingAgent('runtimeNotInstalled'), + conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), @@ -242,7 +212,7 @@ const FAILURE_SENTENCES = { ), attachmentInvalid: (context, fact, _surface, say) => fact.attachment - ? ATTACHMENT_SENTENCES[fact.attachment.reason](say, context, fact.attachment) + ? attachmentProblemSentence(say, agent(say, context), fact.attachment) : say('attachmentInvalid'), attachmentUnreadable: (_context, _fact, _surface, say) => say('attachmentUnreadable'), emptyMessage: (_context, _fact, _surface, say) => say('emptyMessage'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 2d5984331e6a..3237bbad826f 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -38,6 +38,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'runtimeNotInstalled', + 'conversationOpenInTerminal', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 0f44e4f4785e..d4e756e0ef6f 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -86,6 +86,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'agentCommandNotRunnable', /** The runtime Orca installs for the agent is not installed on this host. */ 'runtimeNotInstalled', + /** A terminal may still be writing the chat's conversation, so its agent did not start. */ + 'conversationOpenInTerminal', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 6fd418277394..8819bb4e7855 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -119,6 +119,7 @@ const REASON_WORDS = { claudeAccountSetupFailed: { fact: 'claudeAccountSetupFailed', action: 'retry' }, agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, + conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 991070f1466d..d38686cc4c23 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -97,6 +97,7 @@ const KIND_CATEGORY = { claudeAccountSetupFailed: 'startFailed', agentCommandNotRunnable: 'startFailed', runtimeNotInstalled: 'startFailed', + conversationOpenInTerminal: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -130,6 +131,7 @@ const KIND_VERDICT = { claudeAccountSetupFailed: 'failure', agentCommandNotRunnable: 'failure', runtimeNotInstalled: 'failure', + conversationOpenInTerminal: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', From f7af5301e4def3c8748bc6d77251a1bd3013230c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:46:35 -0700 Subject: [PATCH 002/207] fix(native-chat): hold a terminal start Orca issues until its terminal reports it An in-memory registry of managed terminal starts per conversation, registered in the same step as the resume admission (terminal.send with a resume command, createTerminal with a command), bound to the PTY execution that runs it, and ended only when that terminal's hooks report the conversation or the execution is gone. A chat's agent start refuses while an entry may hold its conversation. --- .../managed-conversation-starts.test.ts | 60 ++++++++++++++ .../runtime/managed-conversation-starts.ts | 79 +++++++++++++++++++ src/main/runtime/rpc/methods/session-tabs.ts | 36 +++++++-- .../methods/terminal/terminal-send-method.ts | 17 +--- .../terminal/terminal-send-resume-starts.ts | 72 +++++++++++++++++ .../structured-session-cli-writer-host.ts | 62 ++++++++++++++- 6 files changed, 304 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/managed-conversation-starts.test.ts create mode 100644 src/main/runtime/managed-conversation-starts.ts create mode 100644 src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts diff --git a/src/main/runtime/managed-conversation-starts.test.ts b/src/main/runtime/managed-conversation-starts.test.ts new file mode 100644 index 000000000000..3b15c83d802e --- /dev/null +++ b/src/main/runtime/managed-conversation-starts.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { terminalConversationKey } from '../native-chat/structured-conversation-key' +import { ManagedConversationStarts, type ManagedStartPty } from './managed-conversation-starts' +import { OrcaRuntimeService } from './orca-runtime' + +const C = 'provider-session-alpha-1' +const key = terminalConversationKey('claude', C) + +describe('managed conversation starts', () => { + it('holds a start until its terminal reports the conversation or its execution ends', () => { + const starts = new ManagedConversationStarts() + const pty: ManagedStartPty = { incarnationId: null, cliConversations: undefined, exited: false } + const read = vi.fn((): ManagedStartPty | null => pty) + const reported = starts.reserve([key]) + expect(starts.live(key, read)).toBe(true) + reported.bind('pty-1', null) + expect(starts.live(key, read)).toBe(true) + pty.cliConversations = { + incarnationId: null, + byKey: new Map([['k', { agent: 'claude', id: C, paneOwned: true }]]) + } + expect(starts.live(key, read)).toBe(false) + + starts.reserve([terminalConversationKey('opencode2', null)]).bind('pty-2', null) + // A targetless resume holds the whole family; a gone PTY ends it. + expect( + starts.live(terminalConversationKey('opencode', 'ses_1'), () => ({ + ...pty, + cliConversations: undefined + })) + ).toBe(true) + expect(starts.live(terminalConversationKey('opencode', 'ses_1'), () => null)).toBe(false) + }) + + it("refuses a chat's agent start while a start holds its conversation, never for its own hook row", async () => { + const runtime = new OrcaRuntimeService() + const record = agentSessionRecordFixture() + const ownRow = { + paneKey: 'chat', + agentType: 'claude', + structuredHost: 'owned', + providerSession: { key: 'claude', id: C } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only these row fields. + const rows = [ownRow as unknown as AgentStatusIpcPayload] + vi.spyOn(runtime, 'getAgentProviderSessionRows').mockReturnValue(rows) + const admit = () => runtime['assertStructuredChatAcquireAdmissible'](record) + await expect(admit()).resolves.toBeUndefined() + + const start = runtime.reserveManagedConversationStart([key]) + await expect(admit()).rejects.toMatchObject({ + refusal: { details: { reason: 'conversationHeldElsewhere' } } + }) + // Bound to a PTY this host doesn't run, the start can't write here. + runtime.bindManagedConversationStart(start, 'pty-missing') + await expect(admit()).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/managed-conversation-starts.ts b/src/main/runtime/managed-conversation-starts.ts new file mode 100644 index 000000000000..b2bb8a0ecab6 --- /dev/null +++ b/src/main/runtime/managed-conversation-starts.ts @@ -0,0 +1,79 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { + keysMayMatch, + terminalConversationKey, + type ConversationKey +} from '../native-chat/structured-conversation-key' + +/** A terminal start of a conversation Orca issued itself, which a chat's agent start must not race. */ +export type ManagedConversationStart = { + /** Ties the start to the PTY execution running it; until then it holds unconditionally. */ + bind: (ptyId: string, incarnationId: PtyIncarnationId | null) => void + drop: () => void +} + +export type ManagedStartPty = Pick< + RuntimePtyWorktreeRecord, + 'incarnationId' | 'cliConversations' +> & { + exited: boolean +} + +type Entry = { + keys: readonly ConversationKey[] + pty: { ptyId: string; incarnationId: PtyIncarnationId | null } | null +} + +/** + * In memory only: a host restart loses every entry, and a restored pane then goes through the + * automatic-home guard instead. No timer ends an entry; only re-derived truth does: its terminal's + * hooks reported the conversation (the census takes over), or its PTY execution is gone. + */ +export class ManagedConversationStarts { + private readonly entries = new Set() + + /** Synchronous, so a caller's admission check and this registration are one step. */ + reserve(keys: readonly ConversationKey[]): ManagedConversationStart { + const entry: Entry = { keys, pty: null } + this.entries.add(entry) + return { + bind: (ptyId, incarnationId) => { + if (this.entries.has(entry)) { + entry.pty = { ptyId, incarnationId } + } + }, + drop: () => { + this.entries.delete(entry) + } + } + } + + /** Whether a managed start may still be writing `key`'s conversation; ends those that can't. */ + live(key: ConversationKey, readPty: (ptyId: string) => ManagedStartPty | null): boolean { + let live = false + for (const entry of this.entries) { + if (entry.pty && ended(entry, readPty(entry.pty.ptyId))) { + this.entries.delete(entry) + } else if (entry.keys.some((held) => keysMayMatch(held, key))) { + live = true + } + } + return live + } +} + +function ended(entry: Entry, pty: ManagedStartPty | null): boolean { + if (!pty || pty.exited || pty.incarnationId !== entry.pty?.incarnationId) { + return true + } + const reported = + pty.cliConversations?.incarnationId === pty.incarnationId + ? [...pty.cliConversations.byKey.values()] + : [] + return reported.some((conversation) => + entry.keys.some((held) => + keysMayMatch(terminalConversationKey(conversation.agent, conversation.id), held) + ) + ) +} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 11440e01fe1b..7eec6432b222 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -19,6 +19,7 @@ import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' +import { terminalConversationKey } from '../../../native-chat/structured-conversation-key' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { createSessionTabsTabDeltaEncoder } from '../../../../shared/session-tabs-tab-delta' @@ -57,12 +58,22 @@ export const SESSION_TAB_METHODS = [ params: CreateTerminalTab, handler: async (params, context) => { const { runtime, signal, clientKind, pairedDeviceId, clientCapabilities } = context - if (params.command) { - await assertLegacyAiVaultResumeCommandAllowed(params.command, () => - runtime.ensureStructuredAgentSessionHost() - ) - } - return runtime.createMobileSessionTerminal(params.worktree, { + // With a resume session the spawn step registers the start; a bare command registers here. + const start = params.command + ? await assertLegacyAiVaultResumeCommandAllowed( + params.command, + () => runtime.ensureStructuredAgentSessionHost(), + params.resumeProviderSession + ? undefined + : (invocations) => + runtime.reserveManagedConversationStart( + invocations.map((invocation) => + terminalConversationKey(invocation.provider, invocation.target) + ) + ) + ) + : undefined + const created = runtime.createMobileSessionTerminal(params.worktree, { ...(params.sourceChatFork ? { sourceChatFork: params.sourceChatFork } : {}), terminalImageViewerSupported: terminalImageViewerSupported(context), afterTabId: params.afterTabId, @@ -102,6 +113,19 @@ export const SESSION_TAB_METHODS = [ // of running down the timeout and rolling back a live tab (#7718). signal }) + // A renderer-backed create has no live PTY yet, so its start isn't held (a known gap). + return start + ? created.then( + (result) => { + runtime.bindManagedConversationStartToTerminal(start, result.tab.terminal) + return result + }, + (error: unknown) => { + start.drop() + throw error + } + ) + : created } }), defineStreamingMethod({ diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index f162192474de..5a9e8d29568e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,12 +1,11 @@ -import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' import { InvalidArgumentError, defineMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { isUntypedTerminalInput } from '../../../../../shared/terminal-untyped-input' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' import { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params' +import { withTerminalSendResumeStarts } from './terminal-send-resume-starts' import { assertTerminalSendExactPtyBinding, - assertTerminalSendTextWithinLimit, commitMobileInputFloorClaim, getTerminalSendGuardRefusedReason, isTerminalInputLockedForClient, @@ -37,18 +36,6 @@ export const TERMINAL_SEND_METHODS = [ replayedMutationReceipt } ) => { - await assertTerminalSendTextWithinLimit(params.text) - await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) - if (params.text) { - await assertLegacyAiVaultResumeCommandAllowed(params.text, () => - runtime.ensureStructuredAgentSessionHost() - ) - } - if (params.resolvedLaunchDraft?.text) { - await assertLegacyAiVaultResumeCommandAllowed(params.resolvedLaunchDraft.text, () => - runtime.ensureStructuredAgentSessionHost() - ) - } const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -304,4 +291,4 @@ export const TERMINAL_SEND_METHODS = [ return { send: result } } }) -] +].map(withTerminalSendResumeStarts) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts new file mode 100644 index 000000000000..f2192e6a38d4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts @@ -0,0 +1,72 @@ +import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' +import { terminalConversationKey } from '../../../../native-chat/structured-conversation-key' +import type { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params' +import type { ManagedConversationStart } from '../../../managed-conversation-starts' +import type { RpcContext, RpcTypedMethod } from '../../core' +import { assertTerminalSendTextWithinLimit } from './terminal-input-delivery' + +/** A send whose text resumes a conversation refuses one a chat holds; otherwise it registers the + * start in the same step, bound to the PTY it writes, and drops it when nothing was written. */ +export function withTerminalSendResumeStarts( + method: RpcTypedMethod> +): RpcTypedMethod> { + const send = method.handler + return { + ...method, + handler: async (params, context) => { + const { runtime } = context + await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + const starts: ManagedConversationStart[] = [] + try { + for (const text of [params.text, params.resolvedLaunchDraft?.text]) { + const start = text + ? await assertLegacyAiVaultResumeCommandAllowed( + text, + () => runtime.ensureStructuredAgentSessionHost(), + (invocations) => + runtime.reserveManagedConversationStart( + invocations.map((invocation) => + terminalConversationKey(invocation.provider, invocation.target) + ), + livePtyId(context, params.terminal) + ) + ) + : undefined + if (start) { + starts.push(start) + } + } + const result = await send(params, context) + if (!sendAccepted(result)) { + starts.forEach((start) => start.drop()) + } + return result + } catch (error) { + starts.forEach((start) => start.drop()) + throw error + } + } + } +} + +// The send itself reports a stale handle; here it only means nothing to bind. +function livePtyId({ runtime }: RpcContext, handle: string): string | null { + try { + return runtime.resolveLiveLeafForHandle(handle)?.ptyId ?? null + } catch { + return null + } +} + +function sendAccepted(result: unknown): boolean { + return ( + typeof result === 'object' && + result !== null && + 'send' in result && + typeof result.send === 'object' && + result.send !== null && + 'accepted' in result.send && + result.send.accepted === true + ) +} diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 6621f1daabb7..ada5f7b3f2aa 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -18,9 +18,63 @@ import { } from '../../agent-hooks/server/cli-writer-observation' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' -import { recordConversationKey } from '../../native-chat/structured-conversation-key' +import { + recordConversationKey, + type ConversationKey +} from '../../native-chat/structured-conversation-key' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { + ManagedConversationStarts, + type ManagedConversationStart, + type ManagedStartPty +} from '../managed-conversation-starts' export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { + private readonly managedConversationStarts = new ManagedConversationStarts() + + /** Registers a terminal start Orca issues of `keys`; `ptyId` binds it now, else its spawn does. */ + reserveManagedConversationStart( + keys: readonly ConversationKey[], + ptyId?: string | null + ): ManagedConversationStart { + const start = this.managedConversationStarts.reserve(keys) + if (ptyId !== undefined) { + this.bindManagedConversationStart(start, ptyId) + } + return start + } + + bindManagedConversationStart(start: ManagedConversationStart, ptyId: string | null): void { + const pty = ptyId ? this.ptysById.get(ptyId) : undefined + // Only this host's own panes write its chats' conversations; a remote or WSL pane's is another's. + if (!pty || pty.connectionId || pty.isWsl) { + start.drop() + return + } + start.bind(pty.ptyId, pty.incarnationId) + } + + bindManagedConversationStartToTerminal( + start: ManagedConversationStart, + handle: string | null | undefined + ): void { + this.bindManagedConversationStart( + start, + (handle && this.getLivePtyForHandle(handle)?.pty.ptyId) || null + ) + } + + private managedStartPty(ptyId: string): ManagedStartPty | null { + const pty = this.ptysById.get(ptyId) + return pty + ? { + incarnationId: pty.incarnationId, + cliConversations: pty.cliConversations, + exited: this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + } + : null + } + private cliWriterPtyPane(ptyId: string | null | undefined): CliWriterPane | null { const pty = ptyId ? this.ptysById.get(ptyId) : undefined return pty?.connected && !pty.connectionId && !pty.isWsl && pty.incarnationId @@ -84,6 +138,12 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca if (!key?.id || !adopts) { return } + // A start Orca issued that its terminal's hooks haven't reported yet: the census can't see it. + if (this.managedConversationStarts.live(key, (ptyId) => this.managedStartPty(ptyId))) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } await this.assertStructuredAdoptionTerminalWritersExited(record.provider, key.id) } From 762a3bc282784667ed0c1f82bfe1fe7293cabad7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:26:33 -0700 Subject: [PATCH 003/207] fix(terminal): an automatic pane restore never resumes a conversation an open chat continues Resume recipes built from a pane's saved facts (cold restore, deferred and late reattach, sleeping wake, host mobile materialization) carry resumeOrigin 'saved'. The execution host decides before any command is issued: while an open chat continues the conversation, or a chat may be writing it, the pane starts its plain shell with the started-fresh notice and its saved claim is forgotten; with unreadable records only a saved restore is refused and its record kept. An admitted resume registers its managed start in the same step, so the chat's agent start refuses until the terminal reports it or exits. Clearing the last saved record now reaches disk. --- .../agent-hooks/server/server-tab-cleanup.ts | 22 ++ .../ipc/pty-codex-account-attribution.test.ts | 5 +- .../pty-daemon-spawn-agent-home-env.test.ts | 6 +- src/main/ipc/pty-ipc-mock-registry.ts | 13 + src/main/ipc/pty-ipc-suite-environment.ts | 2 + .../pty-spawn-codex-home-unavailable.test.ts | 3 +- ...-spawn-env-codex-resume-provenance.test.ts | 3 +- .../pty-spawn-saved-resume-admission.test.ts | 169 +++++++++++++ .../pty/host-env/saved-resume-admission.ts | 234 ++++++++++++++++++ src/main/ipc/pty/ipc/spawn-commit.ts | 32 ++- src/main/ipc/pty/ipc/spawn-preflight.ts | 9 + src/main/ipc/pty/ipc/spawn-run.ts | 21 ++ src/main/ipc/pty/ipc/spawn-state.ts | 4 + src/main/ipc/pty/ipc/spawn-types.ts | 2 + .../spawn-preflight-saved-resume.test.ts | 123 +++++++++ src/main/ipc/pty/runtime/spawn-preflight.ts | 37 +++ src/main/ipc/pty/runtime/spawn-state.ts | 6 + src/main/ipc/pty/runtime/spawn.ts | 4 + .../terminal/terminal-lifecycle-methods.ts | 1 + .../runtime-pty-controller-contract.ts | 1 + .../runtime/runtime-terminal-contracts.ts | 2 + ...form-mobile-session-pty-records-refresh.ts | 1 + .../launch/mobile-terminal-owned-create.ts | 2 + .../terminal/launch/terminal-create.ts | 1 + .../stop/worktree-sleep-resume-recording.ts | 29 ++- src/preload/api/pty-api.ts | 2 + src/preload/api/pty-bridge-session-control.ts | 2 + .../terminal-pane/ipc-pty-connect-result.ts | 10 +- .../terminal-pane/ipc-pty-connect.ts | 11 +- .../terminal-pane/ipc-pty-spawn-request.ts | 4 + .../terminal-pane/pty-connection-types.ts | 2 + .../pty-connection/apply-reattach-payload.ts | 19 +- .../cold-restore-resume-startup.ts | 1 + .../deferred-session-reattach-connect.ts | 15 +- .../pty-connection/fresh-spawn-start.ts | 24 +- .../pty-connection/fresh-spawn-types.ts | 4 + .../pty-connection/pane-transport-options.ts | 3 + .../pty-connection/reattach-result-handler.ts | 23 +- .../pty-connection/spawn-deferred-sleep.ts | 17 +- .../terminal-pane/pty-transport-types.ts | 5 + .../remote-runtime-pty-transport.ts | 4 + .../ipc-events/agent-status-listeners.ts | 39 ++- .../src/lib/forget-refused-pane-resume.ts | 75 ++++++ .../src/lib/sleeping-agent-session-launch.ts | 1 + .../src/lib/workspace-session-patch.test.ts | 6 +- .../src/lib/workspace-session-patch.ts | 3 +- .../src/store/terminals/terminal-actions.ts | 2 + .../src/store/terminals/terminal-state.ts | 1 + src/shared/agent-status-ipc-payload.ts | 8 + src/shared/agent-status-types.ts | 3 +- .../rpc-contract/terminal-unary-params.ts | 2 + 51 files changed, 965 insertions(+), 53 deletions(-) create mode 100644 src/main/ipc/pty-spawn-saved-resume-admission.test.ts create mode 100644 src/main/ipc/pty/host-env/saved-resume-admission.ts create mode 100644 src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts create mode 100644 src/renderer/src/lib/forget-refused-pane-resume.ts diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 5966d2433687..cfe42b8198f0 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -3,8 +3,30 @@ import { paneCacheKeyMatchesTab } from './server-status-identity' import { AgentHookServerCleanup } from './server-cleanup' import type { EnrichedAgentHookEventPayload } from './server-types' import { releaseAgentExitKey } from './agent-exit-evidence' +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 is its conversation's home: + * forget the pane's claim here, with no resumable remnant, and tell renderers to forget theirs. */ + forgetRefusedPaneResume(paneKey: string, refused: RefusedPaneResume): void { + // A moved pane's row, and the renderer's state for it, live under its owner's key. + 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(ownerKey, { preserveResumeIdentity: false }) + this.emitPaneStatusCleared(clear) + return + } + // 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. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index fd2c3ec3790c..18edf2bb5aa4 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -3,7 +3,8 @@ import { join } from 'node:path' import { readFileSyncMock, recordCodexPaneAccountMock, - forgetCodexPaneAccountMock + forgetCodexPaneAccountMock, + noChatsRuntimeMembers } from './pty-ipc-mock-registry' import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' @@ -291,6 +292,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -361,6 +363,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 0ed742831814..1763e2c44a37 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { piBuildPtyEnvMock } from './pty-ipc-mock-registry' +import { piBuildPtyEnvMock, noChatsRuntimeMembers } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { type DaemonSpawnCall, @@ -127,6 +127,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -518,6 +519,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -552,6 +554,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), @@ -586,6 +589,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupDaemonAdapter() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 4eba8d0b1641..1adcd9237ca5 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() @@ -129,11 +130,23 @@ export const mimoHookServiceModuleMock = () => ({ } }) +/** What a spawn's resume admission reads from a fake runtime: no chats, so every resume runs. */ +export const noChatsRuntimeMembers = () => ({ + openAgentSessionRecordStore: vi.fn(async () => ({ + listRecords: () => [], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) + })), + reserveManagedConversationStart: vi.fn(() => ({ bind: vi.fn(), drop: vi.fn() })), + bindManagedConversationStart: vi.fn() +}) + export const agentHookServerModuleMock = () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, clearPaneState: clearAgentHookPaneStateMock, registerPaneKeyAlias: registerPaneKeyAliasMock, + forgetRefusedPaneResume: forgetRefusedPaneResumeMock, + recordLaunchResumeIdentity: () => {}, 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-codex-home-unavailable.test.ts b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts index a76b37305b0a..b7e7b710a15a 100644 --- a/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts +++ b/src/main/ipc/pty-spawn-codex-home-unavailable.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { readFileSyncMock } from './pty-ipc-mock-registry' +import { readFileSyncMock, noChatsRuntimeMembers } from './pty-ipc-mock-registry' import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures' @@ -87,6 +87,7 @@ describe('registerPtyHandlers Codex launch refusal on an unreadable managed home function makeRuntime() { return { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), createPreAllocatedTerminalHandle: vi.fn(() => null), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index 1cecd3a2b37f..cbe5953f7ceb 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { join } from 'node:path' -import { spawnMock } from './pty-ipc-mock-registry' +import { spawnMock, noChatsRuntimeMembers } from './pty-ipc-mock-registry' import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' @@ -360,6 +360,7 @@ describe('registerPtyHandlers', () => { const daemonSpawn = setupResumeDaemonProvider() const runtime = { setPtyController: vi.fn(), + ...noChatsRuntimeMembers(), registerPty: vi.fn(), noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts new file mode 100644 index 000000000000..2a9a53ac3fcc --- /dev/null +++ b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, vi } from 'vitest' +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' +import { registerPtyHandlers } from './pty/register-handlers' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' + +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()) +) + +// #26417: a pane that saved a chat's conversation must not resume it while the chat is its home. +describe('a pane restore of a conversation a native chat continues', () => { + const { handlers, mainWindow, createMockProc } = setupPtyIpcSuite() + const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) + ) + const C = 'provider-session-alpha-1' + const LEAF_ID = '11111111-1111-4111-8111-111111111111' + + function register(openTabs: string[] | 'unreadable') { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'openAgentSessionRecordStore').mockImplementation(async () => { + if (openTabs === 'unreadable') { + throw new Error('records unavailable') + } + const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.listRecords = () => [chat] + store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: openTabs }) + return store + }) + registerPtyHandlers(mainWindow, runtime) + return runtime + } + + async function spawnResume(resumeOrigin?: 'saved', leafId = LEAF_ID) { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + vi.useFakeTimers() + try { + const result = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + worktreeId: 'wt-1', + tabId: 'tab-floating', + leafId, + command: `claude --resume '${C}'`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: C }, + ...(resumeOrigin ? { resumeOrigin } : {}) + }) + 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 { + refused: 'agentResumeRefused' in reported ? reported.agentResumeRefused : undefined, + notice: 'agentResumeUnavailable' in reported, + input: [env[POSIX_SHELL_STARTUP_COMMAND_ENV] ?? '', ...typed].join('\n') + } + } finally { + vi.useRealTimers() + } + } + + posixOnlyIt( + 'starts the plain shell with the notice and forgets the pane claim while the chat tab is open', + async () => { + register([chat.sessionId]) + const restored = await spawnResume('saved') + expect(restored).toMatchObject({ refused: 'chat-home', notice: true }) + expect(restored.input).not.toContain(C) + expect(forgetRefusedPaneResumeMock).toHaveBeenCalledWith(`tab-floating:${LEAF_ID}`, { + agent: 'claude', + providerSession: { key: 'session_id', id: C } + }) + } + ) + + posixOnlyIt( + 'resumes once the chat tab is closed, and the chat then refuses to start', + async () => { + const runtime = register([]) + const restored = await spawnResume('saved') + expect(restored.refused).toBeUndefined() + expect(restored.input).toContain(`--resume '${C}'`) + await expect(runtime['assertStructuredChatAcquireAdmissible'](chat)).rejects.toMatchObject({ + refusal: { details: { reason: 'conversationHeldElsewhere' } } + }) + } + ) + + posixOnlyIt( + 'with unreadable records refuses only a saved restore, and keeps its record', + async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + register('unreadable') + const restored = await spawnResume('saved') + const asked = await spawnResume(undefined, '22222222-2222-4222-8222-222222222222') + expect(restored.refused).toBe('unverifiable') + expect(restored.input).not.toContain(C) + expect(asked.refused).toBeUndefined() + expect(asked.input).toContain(`--resume '${C}'`) + expect(forgetRefusedPaneResumeMock).not.toHaveBeenCalled() + } + ) + + posixOnlyIt('leaves an explicit resume of a chat at rest alone', async () => { + register([chat.sessionId]) + const asked = await spawnResume() + expect(asked.refused).toBeUndefined() + expect(asked.input).toContain(`--resume '${C}'`) + }) +}) diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts new file mode 100644 index 000000000000..89db109a1059 --- /dev/null +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -0,0 +1,234 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import type { ManagedConversationStart } from '../../../runtime/managed-conversation-starts' +import { + agentSessionLeasePossiblyWriting, + listStructuredProviderSessionOwnership +} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' +import { agentHookServer } from '../../../agent-hooks/server' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { + keysMayMatch, + terminalConversationKey, + type ConversationKey +} from '../../../native-chat/structured-conversation-key' + +export type ResumeSpawnArgs = { + connectionId?: string | null + command?: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: unknown + resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: the resume was built from a pane's saved facts, not asked for by the user. */ + resumeOrigin?: unknown + env?: Record +} + +type AdmissionRuntime = { + openAgentSessionRecordStore(): Promise + reserveManagedConversationStart(keys: readonly ConversationKey[]): ManagedConversationStart +} + +/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: + * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ +export type ResumeSpawnRefusal = { + verdict: 'chat-home' | 'chat-writing' | 'unverifiable' + refused: RefusedPaneResume +} + +export type ResumeSpawnAdmission = + | { verdict: 'admitted'; start: ManagedConversationStart } + | ResumeSpawnRefusal + +/** + * Decided on the execution host before any command is issued. An admitted start registers in the + * same step as the check, so a chat starting meanwhile sees it. Null: nothing here can be a chat's + * (no resume, a remote or WSL pane), so the spawn stays as it was. + */ +export async function admitResumeSpawn( + runtime: AdmissionRuntime | undefined, + args: ResumeSpawnArgs, + wslDistro: string | null +): Promise { + const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) + const agent = args.launchAgent + // Native chats run only natively, so a remote or WSL conversation is never a chat's. + if (!runtime || !providerSession || !agent || args.connectionId || wslDistro !== null) { + return null + } + return judgeResumes(runtime, [{ agent, providerSession }], args.resumeOrigin === 'saved') +} + +/** A reattach that found its session gone returns a fresh shell its window then resumes from the + * pane's saved facts; this decides that resume before the window can deliver it. */ +export async function admitColdRestoreReattach( + runtime: + | (AdmissionRuntime & { getAgentProviderSessionRows(): AgentStatusIpcPayload[] }) + | undefined, + result: { coldRestore?: unknown; isReattach?: boolean }, + pane: { paneKey: string | null; saved: SleepingAgentSessionRecord | undefined }, + wslDistro: string | null +): Promise { + const { paneKey, saved } = pane + if (!runtime || !result.coldRestore || result.isReattach || !paneKey || wslDistro !== null) { + return null + } + const resumes = [ + ...runtime + .getAgentProviderSessionRows() + .flatMap((row) => + row.paneKey === paneKey && !row.connectionId && row.providerSession + ? [{ agent: row.agentType, providerSession: row.providerSession }] + : [] + ), + ...(saved && !saved.connectionId + ? [{ agent: saved.agent, providerSession: saved.providerSession }] + : []) + ] + const [first, ...rest] = resumes + return first ? judgeResumes(runtime, [first, ...rest], true) : null +} + +async function judgeResumes( + runtime: AdmissionRuntime, + resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]], + saved: boolean +): Promise { + const keysOf = ({ agent, providerSession }: RefusedPaneResume) => + [providerSession.id, providerSession.transcriptPath].flatMap((id) => + id?.trim() ? [terminalConversationKey(agent, id)] : [] + ) + const admitted = (): ResumeSpawnAdmission => ({ + verdict: 'admitted', + start: runtime.reserveManagedConversationStart(resumes.flatMap(keysOf)) + }) + let store: AgentSessionRecordStore + try { + store = await runtime.openAgentSessionRecordStore() + } catch (error) { + console.warn( + "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", + error + ) + // Bookkeeping never gates a resume the user asked for. + return saved ? { verdict: 'unverifiable', refused: resumes[0] } : admitted() + } + const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( + (holding) => holding.role === 'head' + ) + const tabs = saved ? store.getVisibleSessionTabIndex() : null + for (const refused of resumes) { + const holders = heads.filter((holding) => + keysOf(refused).some((key) => keysMayMatch(holding.key, key)) + ) + if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { + return { verdict: 'chat-writing', refused } + } + if (tabs && holders.length > 0 && !tabs.present) { + return { verdict: 'unverifiable', refused } + } + if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { + return { verdict: 'chat-home', refused } + } + } + return admitted() +} + +/** A refused resume becomes 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 +} + +/** The admission at a spawn step: a refused resume leaves the spawn its plain shell. */ +export async function applyResumeSpawnAdmission( + runtime: AdmissionRuntime | undefined, + args: ResumeSpawnArgs, + wslDistro: string | null +): Promise { + const admission = await admitResumeSpawn(runtime, args, wslDistro) + if (admission && admission.verdict !== 'admitted') { + dropAgentLaunch(args) + } + return admission +} + +export function resumeSpawnRefusal( + admission: ResumeSpawnAdmission | null +): ResumeSpawnRefusal | null { + return admission && admission.verdict !== 'admitted' ? admission : null +} + +/** An admitted start holds the PTY that runs it; a reattach never ran the launch. */ +export function bindResumeSpawnStart( + runtime: + | { bindManagedConversationStart(start: ManagedConversationStart, ptyId: string | null): void } + | undefined, + admission: ResumeSpawnAdmission | null, + result: { id: string; isReattach?: boolean } +): void { + if (admission?.verdict === 'admitted') { + runtime?.bindManagedConversationStart(admission.start, result.isReattach ? null : result.id) + } +} + +export function dropResumeSpawnStart(admission: ResumeSpawnAdmission | null): void { + if (admission?.verdict === 'admitted') { + admission.start.drop() + } +} + +/** Retires a refused resume's claim at once, so the cleanup never waits on this spawn finishing or + * a pane receiving its result. An unverifiable refusal keeps everything for the next try. */ +export function forgetRefusedResume( + paneKey: string | null, + refusal: ResumeSpawnRefusal | null +): void { + if (!paneKey || !refusal || refusal.verdict === 'unverifiable') { + return + } + try { + agentHookServer.forgetRefusedPaneResume(paneKey, refusal.refused) + } catch (error) { + // The shell still starts, and the next restore refuses and retries the cleanup. + console.warn('[pty] could not forget a resume a native chat holds', error) + } +} + +/** The pane's saved resume on this host, which its window resumes from after a cold restore. */ +export function savedResumeRecord( + store: + | { getWorkspaceSession?(hostId: ExecutionHostId): WorkspaceSessionState | undefined } + | undefined, + paneKey: string | null +): SleepingAgentSessionRecord | undefined { + return paneKey + ? store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.sleepingAgentSessionsByPaneKey?.[ + paneKey + ] + : undefined +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 666fcac2c738..bdf460e07825 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -23,11 +23,28 @@ import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty- import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' +import { resumeSpawnRefusal } from '../host-env/saved-resume-admission' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { return commitPtyWithOpenCodePromptIntent(ctx, () => 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 {} + } + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + return { + ...(ctx.codexResumeLaunch.notifyResumeUnavailable || refusal + ? { agentResumeUnavailable: true as const } + : {}), + ...(refusal ? { agentResumeRefused: refusal.verdict } : {}) + } +} + async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtySpawnOwnership(ctx.deps.runtime, ctx.result) @@ -89,11 +106,10 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -107,6 +108,14 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.terminalRuntimeOptions = target.terminalRuntimeOptions ctx.expectedWslDistro = target.expectedWslDistro ctx.codexSelectionTarget = target.codexSelectionTarget + if (!ctx.preAdoptedStablePane) { + ctx.resumeAdmission = await applyResumeSpawnAdmission( + ctx.deps.runtime, + args, + ctx.expectedWslDistro + ) + ctx.isClaudeLaunch = ctx.isClaudeLaunch && !resumeSpawnRefusal(ctx.resumeAdmission) + } ctx.spawnTiming.mark('preflight') args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) ctx.claudeAuth = diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index fd25968b02dc..f15863cbbdc7 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -19,6 +19,14 @@ import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-ma import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal' +import { + admitColdRestoreReattach, + bindResumeSpawnStart, + dropResumeSpawnStart, + forgetRefusedResume, + resumeSpawnRefusal, + savedResumeRecord +} from '../host-env/saved-resume-admission' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { if (!ctx.agentTeamsLeaderHandle) { @@ -58,6 +66,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } await preparePtyIpcSpawnPreflight(ctx) await assemblePtyIpcSpawnEnv(ctx) + forgetRefusedResume(ctx.stablePaneKey, resumeSpawnRefusal(ctx.resumeAdmission)) await preparePtyIpcQoderCommand(ctx) const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize({ @@ -74,6 +83,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg // evict the team env assembly created for it — exit/close cleanup keys // off handleByPtyId — so every lost race would leak one team forever. releaseAbandonedAgentTeamsLeader(ctx) + dropResumeSpawnStart(ctx.resumeAdmission) return earlyReserved } // Right before the provider spawn, with nothing awaited in between: a closed pane starts nothing. @@ -83,7 +93,17 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg refuseClosedPaneSpawn(deps.store, args.tabId, args.leafId) } await executePtyIpcSpawn(ctx) + if (!ctx.resumeAdmission) { + ctx.resumeAdmission = await admitColdRestoreReattach( + deps.runtime, + ctx.result, + { paneKey: ctx.stablePaneKey, saved: savedResumeRecord(deps.store, ctx.stablePaneKey) }, + ctx.expectedWslDistro + ) + forgetRefusedResume(ctx.stablePaneKey, resumeSpawnRefusal(ctx.resumeAdmission)) + } const committed = await commitPtyIpcSpawn(ctx) + bindResumeSpawnStart(deps.runtime, ctx.resumeAdmission, ctx.result) endPaneAgentLaunch( ctx, ctx.exitedBeforeRegistration ? { exitedDuringStart: true } : { started: true } @@ -91,6 +111,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg return committed } catch (caught) { const err = endPaneAgentLaunch(ctx, { error: caught }) ?? caught + dropResumeSpawnStart(ctx.resumeAdmission) releaseAbandonedAgentTeamsLeader(ctx) if (ctx.preSpawnHiddenMarkId !== null) { ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index 6b24c4d24b45..e7c82b057d19 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 { ResumeSpawnAdmission } from '../host-env/saved-resume-admission' import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' import type { PtySpawnTiming } from '../../pty-spawn-timing' import { createPtySpawnTiming } from '../../pty-spawn-timing' @@ -80,6 +81,8 @@ export type PtyIpcSpawnState = { nativeWindowsConptySpawn: boolean codexSelectionTarget: CodexAccountSelectionTarget codexResumeLaunch: CodexResumeLaunch + /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ + resumeAdmission: ResumeSpawnAdmission | null launchCommand: string | undefined env: Record | undefined selectedCodexHomePath: string | null @@ -156,6 +159,7 @@ export function createPtyIpcSpawnState( nativeWindowsConptySpawn: false, codexSelectionTarget: { runtime: 'host' }, codexResumeLaunch: noCodexResumeLaunch(undefined), + resumeAdmission: null, launchCommand: undefined, env: undefined, selectedCodexHomePath: null, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 4166cbd53998..6107526fb8af 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -32,6 +32,8 @@ export type PtySpawnIpcArgs = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + // Untyped on purpose: only `'saved'` marks a resume built from saved facts. + resumeOrigin?: unknown launchToken?: unknown launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts new file mode 100644 index 000000000000..8a369852db6b --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import { getDefaultSettings } from '../../../../shared/constants' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import { ManagedConversationStarts } from '../../../runtime/managed-conversation-starts' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { finishPtyShutdown } from '../provider/liveness' +import { admitColdRestoreReattach } from '../host-env/saved-resume-admission' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { prepareRuntimePtySpawn } from './spawn-preflight' +import { createRuntimePtySpawnState } from './spawn-state' + +const forgetRefusedPaneResume = vi.hoisted(() => vi.fn()) +vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { forgetRefusedPaneResume } })) + +const C = 'provider-session-alpha-1' +const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) +) + +const store = { + listRecords: () => [chat], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [chat.sessionId] }) +} + +/** A host whose one chat continues C at rest, with its tab open. */ +function chatHost() { + const starts = new ManagedConversationStarts() + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the two members `store` has. + openAgentSessionRecordStore: async () => store as unknown as AgentSessionRecordStore, + reserveManagedConversationStart: vi.fn(starts.reserve.bind(starts)), + forgetRefusedSavedResume: vi.fn(async () => {}), + getAgentProviderSessionRows: () => [ + { paneKey: 'tab-1:leaf', agentType: 'claude', providerSession: { key: 'session_id', id: C } } + ] + } +} + +function deps(runtime: ReturnType): PtyRuntimeControllerDeps { + const noCodexResumeLaunch: PtyRuntimeControllerDeps['noCodexResumeLaunch'] = (command) => ({ + codexResumeHome: null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession: null + }) + return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preflight reads only the admission members `chatHost` provides. + runtime: runtime as unknown as OrcaRuntimeService, + store: undefined, + getSettings: () => getDefaultSettings('/tmp'), + adoptStablePane: async () => null, + getLocalPtyStartupPromise: () => undefined, + getLocalPtyProviderStartupPromise: () => undefined, + prepareCodexResumeHome: () => null, + resolveCodexResumeLaunch: async (command) => noCodexResumeLaunch(command), + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome: async (resumeHome) => resumeHome.codexHomePath, + stripSequencedStartupResumeArgv: (env) => env, + assertWorkspaceAcceptsNewPty: () => undefined, + resolvePtySpawnStartupCwd: (_worktreeId, cwd) => cwd, + requestSerializedBuffer: async () => null, + shutdownProviderAndDetectExit: async () => false, + rememberSyntheticKillExit: () => {}, + rememberRetiredRejectedPty: () => {}, + sendPtyExitToRenderer: () => {}, + sendPtySpawnedToRenderer: () => {}, + finishPtyShutdown, + trustedTerminalHandleEnv: new Set(), + retiredRejectedPtyIds: new Map(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the spawn preflight never touches `mainWindow`. + mainWindow: {} as BrowserWindow + } +} + +describe('host-side saved resumes of a conversation an open chat continues', () => { + it('materializes a phone-restored pane as a plain shell and forgets its saved record', async () => { + const runtime = chatHost() + const ctx = createRuntimePtySpawnState(deps(runtime), { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + command: `claude --resume ${C}`, + launchAgent: 'claude', + resumeProviderSession: { key: 'session_id', id: C }, + resumeOrigin: 'saved' + }) + await prepareRuntimePtySpawn(ctx) + ctx.finishTerminalInstall() + expect(ctx.args.command).toBeUndefined() + expect(ctx.launchCommand ?? '').not.toContain(C) + expect(runtime.forgetRefusedSavedResume).toHaveBeenCalledWith( + 'wt-1', + 'tab-1:11111111-1111-4111-8111-111111111111', + { agent: 'claude', providerSession: { key: 'session_id', id: C } } + ) + }) + + it("refuses a late reattach's cold restore before its window can resume it", async () => { + const runtime = chatHost() + await expect( + admitColdRestoreReattach( + runtime, + { coldRestore: { scrollback: '', cwd: '/tmp' } }, + { paneKey: 'tab-1:leaf', saved: undefined }, + null + ) + ).resolves.toMatchObject({ verdict: 'chat-home' }) + expect(runtime.reserveManagedConversationStart).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 0cb21c8b26a2..fb5b5b9c160b 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -19,6 +19,11 @@ import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import { resolvePtySpawnHostEnv } from '../spawn-host-env' import { ptySpawnPaneFromIds, stampPtySpawnPaneEnv } from '../spawn-pane-env' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' +import { + applyResumeSpawnAdmission, + forgetRefusedResume, + resumeSpawnRefusal +} from '../host-env/saved-resume-admission' import { ensureJcodeRuntimeDir, JCODE_RUNTIME_DIR_ENV_KEY @@ -76,6 +81,9 @@ export async function prepareRuntimePtySpawn( ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride ctx.expectedWslDistro = target.expectedWslDistro ctx.codexSelectionTarget = target.codexSelectionTarget + if (!ctx.preAdoptedStablePane) { + await admitRuntimeResumeSpawn(ctx) + } args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth @@ -152,3 +160,32 @@ export async function prepareRuntimePtySpawn( return null } + +async function admitRuntimeResumeSpawn(ctx: RuntimePtySpawnState): Promise { + const { args } = ctx + ctx.resumeAdmission = await applyResumeSpawnAdmission( + ctx.deps.runtime, + args, + ctx.expectedWslDistro + ) + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + if (!refusal) { + return + } + ctx.isClaudeLaunch = false + const paneKey = ptySpawnPaneFromIds(args)?.paneKey ?? null + forgetRefusedResume(paneKey, refusal) + // A host with no window has no one else to forget the pane's saved record. + if ( + paneKey && + args.worktreeId && + args.resumeOrigin === 'saved' && + refusal.verdict !== 'unverifiable' + ) { + void ctx.deps.runtime + ?.forgetRefusedSavedResume(args.worktreeId, paneKey, refusal.refused) + .catch((error: unknown) => + console.warn('[pty] could not forget a refused saved resume', error) + ) + } +} diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 57a3ef2394a1..4de4db55ca4e 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -13,6 +13,7 @@ import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { AdoptStablePaneResult } from '../ipc/spawn-types' import type { PtyBindingSourceExpectation } from '../../../persistence' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' +import type { ResumeSpawnAdmission } from '../host-env/saved-resume-admission' import type { PtyRuntimeControllerDeps } from './controller-deps' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' @@ -42,6 +43,8 @@ export type RuntimePtySpawnState = { effectiveSessionAppId: string | undefined isNewDaemonSession: boolean expectedWslDistro: string | null + /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ + resumeAdmission: ResumeSpawnAdmission | null codexSelectionTarget: CodexAccountSelectionTarget launchCommand: string | undefined claudeAuth: ClaudeRuntimeAuthPreparation | null @@ -105,6 +108,8 @@ export type RuntimePtySpawnArgs = { env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' connectionId?: string | null worktreeId?: string preAllocatedHandle?: string @@ -167,6 +172,7 @@ export function createRuntimePtySpawnState( effectiveSessionAppId: undefined, isNewDaemonSession: false, expectedWslDistro: null, + resumeAdmission: null, codexSelectionTarget: { runtime: 'host' }, launchCommand: undefined, claudeAuth: null, diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 42e8a2764e54..4758518ab66a 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -16,6 +16,7 @@ import { buildRuntimePtySpawnOptions } from './spawn-options' import { executeRuntimePtySpawn } from './spawn-execute' import { commitRuntimePtySpawn } from './spawn-commit' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import { bindResumeSpawnStart, dropResumeSpawnStart } from '../host-env/saved-resume-admission' import { commitRuntimeSpawnHiddenDelivery, releaseRuntimeSpawnPreSpawnHiddenMark @@ -89,13 +90,16 @@ export async function spawnPtyFromRuntimeController( throw error }) if (earlyReserved) { + dropResumeSpawnStart(ctx.resumeAdmission) return toRuntimeSpawnReply(earlyReserved) } await executeRuntimePtySpawn(ctx) const committed = await commitRuntimePtySpawn(ctx) + bindResumeSpawnStart(deps.runtime, ctx.resumeAdmission, ctx.result) commitRuntimeSpawnHiddenDelivery(ctx) return toRuntimeSpawnReply(committed) } catch (err) { + dropResumeSpawnStart(ctx.resumeAdmission) releaseRuntimeSpawnPreSpawnHiddenMark(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 1cae7585db93..56439e190811 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -67,6 +67,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ ...(params.resumeProviderSession ? { resumeProviderSession: params.resumeProviderSession } : {}), + ...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.terminalKittyKeyboardProtocol === true diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 5cc738eb6558..e29889032ecf 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -68,6 +68,7 @@ export type RuntimePtyController = { env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 289569e0bf07..e293e495a6bf 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -47,6 +47,8 @@ export type TerminalCreateOptions = { envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupAgent?: TuiAgent diff --git a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts index 9a29a1ce2b59..d9cc54d46eb0 100644 --- a/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/session-lifecycle/projection/perform-mobile-session-pty-records-refresh.ts @@ -243,6 +243,7 @@ export abstract class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh exten ...('resumeProviderSession' in agentStartup ? { resumeProviderSession: agentStartup.resumeProviderSession, + resumeOrigin: 'saved' as const, spawnIntent: automatic ? 'automatic-restore' : 'user-resume' } : {}), diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 0dfd6721690b..a78ded3413d2 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -44,6 +44,7 @@ export type OwnedMobileTerminalCreateOptions = { supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' spawnIntent?: SpawnIntent launchRuntime?: AgentLaunchRuntime signal?: AbortSignal @@ -130,6 +131,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ? { resumeProviderSession: opts.resumeProviderSession } : {}), ...(opts.spawnIntent ? { spawnIntent: opts.spawnIntent } : {}), + ...(opts.resumeOrigin ? { resumeOrigin: opts.resumeOrigin } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.launchRuntime ? { launchRuntime: opts.launchRuntime } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index a84dd7c6a0c6..aec82277045e 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -146,6 +146,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi agentTeamsPlan?.envToDelete ), resumeProviderSession: launchOpts.resumeProviderSession, + ...(launchOpts.resumeOrigin ? { resumeOrigin: launchOpts.resumeOrigin } : {}), telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts index bd91aeb9fc5e..5d752821f85e 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts @@ -16,7 +16,11 @@ import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload } from '../../../../shared/agent-status-ipc-payload' -import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { + agentProviderSessionsEqual, + type SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import { parseExecutionHostId } from '../../../../shared/execution-host' import { parsePaneKey } from '../../../../shared/stable-pane-id' @@ -24,6 +28,29 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switc import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRuntimeWithStopTerminalsForWorktree { + /** Forgets a pane's saved resume the host refused because a chat is its conversation's home, + * as a window does for its own panes; a host with no window has no one else to. */ + async forgetRefusedSavedResume( + worktreeId: string, + paneKey: string, + refused: RefusedPaneResume + ): Promise { + await this.commitWorkspaceSessionEdit( + this.getWorkspaceSessionHostIdForWorktree(worktreeId), + (session) => { + const saved = session.sleepingAgentSessionsByPaneKey?.[paneKey] + if ( + saved?.agent !== refused.agent || + !agentProviderSessionsEqual(refused.agent, saved.providerSession, refused.providerSession) + ) { + return null + } + const { [paneKey]: _refused, ...kept } = session.sleepingAgentSessionsByPaneKey ?? {} + return { ...session, sleepingAgentSessionsByPaneKey: kept } + } + ) + } + protected readonly sleepResumeRecords: WorktreeSleepResumeRecorder = { capture: (worktreeId, ptyIds) => captureWorktreeSleepResumeRecords({ diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 80a1d96d48b6..20039e7c0fa2 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -40,6 +40,7 @@ export type PtyApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -88,6 +89,7 @@ export type PtyApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | '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 1bd100352d04..872c560f98c1 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -36,6 +36,7 @@ export const ptySessionControlApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -82,6 +83,7 @@ export const ptySessionControlApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | '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 bcfc0d3f67c6..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 @@ -37,7 +37,10 @@ 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.agentResumeRefused + ? { agentResumeRefused: spawnResult.agentResumeRefused } + : {}) } } if ( @@ -53,7 +56,10 @@ export function projectIpcPtyConnectResult( ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: 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 d7394497f937..90bf58eab65c 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -137,7 +137,16 @@ export async function connectIpcPty( if (!context.isExpectedExitCurrent()) { return } - return { id: spawnResult.id, exitedBeforeAttach: true } + return { + id: spawnResult.id, + exitedBeforeAttach: true, + // 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()) { return diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index 2f622150e710..362eb11ac5e0 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -78,6 +78,7 @@ export async function spawnIpcPty( commandDelivery, launchConfig, resumeProviderSession, + resumeOrigin, launchToken, launchAgent, startupCommandDelivery, @@ -119,6 +120,9 @@ export async function spawnIpcPty( ...((connectOptions.resumeProviderSession ?? resumeProviderSession) ? { resumeProviderSession: connectOptions.resumeProviderSession ?? resumeProviderSession } : {}), + ...(resumesAgent && (connectOptions.resumeOrigin ?? resumeOrigin) + ? { resumeOrigin: connectOptions.resumeOrigin ?? resumeOrigin } + : {}), ...((connectOptions.launchToken ?? launchToken) ? { launchToken: connectOptions.launchToken ?? launchToken } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index e7ce975cce87..c0dd05bf31c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -29,6 +29,8 @@ export type PtyPaneStartup = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: a resume built from saved facts, which the host refuses for a chat's conversation. */ + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ 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..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,16 +263,19 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - const preparedStartup = ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup() + // 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) + 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() - } 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. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 45eb6063920f..30e84152df53 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -109,6 +109,7 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe }, launchConfig: startupPlan.launchConfig, resumeProviderSession: providerSession, + resumeOrigin: 'saved', launchToken: coldRestoreLaunchToken, useLiveEntry: Boolean(useLiveEntry), hasSleepingRecord: Boolean(sleepingRecord), 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 c16085dba048..5b29f012ac5f 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 @@ -117,16 +117,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 db1a271d3514..fb164d978e72 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 @@ -105,7 +105,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { : {}), ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), ...(coldRestoreOverride - ? { resumeProviderSession: coldRestoreOverride.resumeProviderSession } + ? { + resumeProviderSession: coldRestoreOverride.resumeProviderSession, + resumeOrigin: coldRestoreOverride.resumeOrigin + } : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), @@ -180,10 +183,20 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } + // 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() + session.pendingStartupCommand = null + } + 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, { - ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), - ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}) + ...(coldRestore ? { launchToken: coldRestore.launchToken } : {}), + ...(coldRestore ? { launchAgent: coldRestore.agent } : {}) }) } if (resolvedPtyId) { @@ -204,10 +217,11 @@ 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) { + options.onAgentResumeUnavailable?.() + } else if (coldRestore?.hasSleepingRecord) { session.showSessionRestoredBanner() } - 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/fresh-spawn-types.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-types.ts index 4bb06f8dbde4..22dde900bf8b 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,11 +12,15 @@ export type PendingStartupCommand = { export type FreshSpawnOptions = { forceBlankRestoredViewport?: boolean + /** Main started the pane without resuming the requested agent session. */ + onAgentResumeUnavailable?: () => void } export type ColdRestoreAgentResumeStartup = PendingStartupCommand & { agent: ResumableTuiAgent resumeProviderSession: AgentProviderSessionMetadata + /** Built from the pane's saved facts, so the host refuses it for a chat's conversation. */ + resumeOrigin: 'saved' launchConfig: NonNullable>['launchConfig'] launchToken: string useLiveEntry: boolean diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index f11290c6b258..d6171e4b9f60 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -50,6 +50,9 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt ...(session.paneStartup?.resumeProviderSession ? { resumeProviderSession: session.paneStartup.resumeProviderSession } : {}), + ...(session.paneStartup?.resumeOrigin + ? { resumeOrigin: session.paneStartup.resumeOrigin } + : {}), ...((session.paneStartup?.initialAgentStatus?.prompt ?? session.paneStartup?.draftPrompt) ? { agentPrompt: 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..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,10 +95,15 @@ 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 } + // 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') @@ -125,11 +130,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi return false } 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/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn-deferred-sleep.ts index 265a86785d0c..1abfc7ab1a52 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 @@ -149,17 +149,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 a58a0465e30f..f4d0ebbef6b2 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -124,6 +124,9 @@ export type PtyConnectResult = { startupCwdFallback?: { kind: 'worktree'; cwd: string } /** Main declined an unverifiable provider-session resume and launched fresh. */ agentResumeUnavailable?: true + /** Main refused the resume and started the plain shell: a native chat is the conversation's home + * or may be writing it, or no record could be checked (the saved session is kept to retry). */ + agentResumeRefused?: 'chat-home' | 'chat-writing' | '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). */ @@ -187,6 +190,7 @@ export type PtyTransport = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -292,6 +296,7 @@ export type IpcPtyTransportOptions = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' agentPrompt?: string agentPromptDelivery?: AgentPromptDelivery agentArgsOverride?: string | null diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index d8b164f696fd..8619c3ae68aa 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -205,6 +205,7 @@ export function createRemoteRuntimePtyTransport( envToDelete, launchConfig, resumeProviderSession, + resumeOrigin, launchToken, launchAgent, terminalColorQueryReplies, @@ -3100,6 +3101,9 @@ export function createRemoteRuntimePtyTransport( ...(resumeProviderSessionToSend !== undefined ? { resumeProviderSession: resumeProviderSessionToSend } : {}), + ...(resumeProviderSessionToSend && (options.resumeOrigin ?? resumeOrigin) + ? { resumeOrigin: options.resumeOrigin ?? resumeOrigin } + : {}), ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), ...spawnIntentParams, 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 16e50bce1b57..3e6a62c3c073 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -1,16 +1,45 @@ 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, 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' +/** 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 => + 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)) { + 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 @@ -72,6 +101,14 @@ export function registerAgentStatusListeners(args: { if (!('paneKey' in data) || typeof data.paneKey !== 'string') { return } + if (data.refusedResume) { + dropEventsRestoringRefused(data.paneKey, data.refusedResume, { + pendingAgentStatusEvents, + liveAgentStatusBurstQueue + }) + 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.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts new file mode 100644 index 000000000000..3576d463630f --- /dev/null +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -0,0 +1,75 @@ +import type { AppState } from '@/store/types' +import type { RefusedPaneResume } from '../../../shared/agent-status-types' +import { + agentProviderSessionsEqual, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' +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. */ +export function namesRefusedResume( + refused: RefusedPaneResume, + claim: { + agent?: string + providerSession?: AgentProviderSessionMetadata + 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 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, + paneKey: string, + refused: RefusedPaneResume +): void { + const names = (claim: Parameters[1]) => + namesRefusedResume(refused, claim) + const retained = state.retainedAgentsByPaneKey[paneKey] + if (retained && names({ ...retained.entry, agent: retained.agentType })) { + state.dismissRetainedAgent(paneKey) + } + 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 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 || parseLegacyNumericPaneKey(key)?.tabId === 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 && + tab.aiVaultTitle.sessionId === refused.providerSession.id + ) { + state.setAiVaultTabTitle(tab.id, null) + } +} diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index a1062932fe74..197cc670677f 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -81,6 +81,7 @@ export function launchSleepingAgentSession( ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, resumeProviderSession: record.providerSession, + resumeOrigin: 'saved', launchAgent: record.agent, ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), ...(startupPlan.startupCommandDelivery 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 de1d76f30b62..96b28a81c213 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -204,8 +204,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 diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 43c63c9883a6..93c970532853 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -200,6 +200,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null @@ -226,6 +227,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index fae95a4cbbb9..2550334dd91c 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -56,6 +56,7 @@ export type TerminalState = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'saved' launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 95032db5f949..0a6d446af1a6 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -89,6 +89,12 @@ export type AgentStatusCacheIdentity = { stateStartedAt: number } +/** A pane's saved resume the host refused because a native chat is that conversation's home. */ +export type RefusedPaneResume = { + agent: string + providerSession: AgentProviderSessionMetadata +} + /** Wire shape for ordinary pane teardown or a stamped SSH disconnect batch. */ export type AgentStatusClearIpcPayload = | { @@ -96,6 +102,8 @@ export type AgentStatusClearIpcPayload = statusUnavailable?: true /** An OSC 7501 program took back its own row, so even a `done` row goes. */ programStatusRemoved?: true + /** Forget that conversation as this pane's resume target too, not only its status. */ + refusedResume?: RefusedPaneResume } | { transient: true diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 9e9827884fba..70269f0465ed 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' diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index 1b043f70b526..8ff208a1696c 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -220,6 +220,8 @@ export const TerminalCreateParams = z.object({ }) .optional(), resumeProviderSession: ResumeProviderSessionParam.optional(), + // Older hosts drop it and resume as before. + resumeOrigin: z.literal('saved').optional(), launchToken: OptionalString, launchAgent: z.string().refine(isTuiAgent).optional(), terminalKittyKeyboardProtocol: z.boolean().optional(), From 985ffc63c9e859d9bf2343c701ff241708f6a13f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:36:43 -0700 Subject: [PATCH 004/207] Fix saved terminal resume ownership and effective launch results --- .../agent-hooks/server/server-tab-cleanup.ts | 3 + .../ai-vault/structured-session-ownership.ts | 16 +- .../codex-structured-thread-open.test.ts | 41 ++++ .../codex/codex-structured-thread-open.ts | 17 ++ src/main/ipc/pty-ipc-mock-registry.ts | 4 +- .../pty-spawn-saved-resume-admission.test.ts | 22 +- .../pty/host-env/saved-resume-admission.ts | 205 ++++++++++++----- src/main/ipc/pty/ipc/spawn-commit.ts | 13 +- src/main/ipc/pty/ipc/spawn-env-codex.ts | 6 + src/main/ipc/pty/ipc/spawn-execute.ts | 54 ++++- src/main/ipc/pty/ipc/spawn-preflight.ts | 9 - src/main/ipc/pty/ipc/spawn-run.ts | 34 ++- src/main/ipc/pty/pane/stable-owner.ts | 2 + src/main/ipc/pty/runtime/spawn-commit.ts | 8 +- src/main/ipc/pty/runtime/spawn-early.ts | 2 + src/main/ipc/pty/runtime/spawn-execute.ts | 190 ++++++++++++---- .../spawn-preflight-saved-resume.test.ts | 208 ++++++++++++++++-- src/main/ipc/pty/runtime/spawn-preflight.ts | 46 +--- src/main/ipc/pty/runtime/spawn-state.ts | 9 +- src/main/ipc/pty/runtime/spawn.ts | 84 ++++--- ...red-agent-session-failed-create-refusal.ts | 6 +- .../structured-agent-session-failure-text.ts | 27 ++- ...ctured-agent-session-provider-open.test.ts | 23 ++ src/main/providers/pty-spawn-result.ts | 3 +- ...rminal-create-host-session-binding.test.ts | 49 +++++ .../hook-status-resume-sleep-ordering.test.ts | 39 ++++ .../managed-conversation-starts.test.ts | 60 ----- .../runtime/managed-conversation-starts.ts | 79 ------- src/main/runtime/rpc/methods/session-tabs.ts | 36 +-- .../methods/terminal/terminal-send-method.ts | 17 +- .../terminal/terminal-send-resume-starts.ts | 72 ------ .../runtime-pty-controller-contract.ts | 16 +- .../runtime/runtime-terminal-state-records.ts | 7 + .../get-agent-session-execution-namespace.ts | 1 + .../structured-session-cli-writer-host.ts | 87 +++----- .../launch/mobile-terminal-owned-create.ts | 4 +- .../terminal/launch/terminal-create.ts | 63 +++--- .../terminal/launch/terminal-launch-result.ts | 66 ++++++ .../stop/worktree-sleep-resume-recording.ts | 88 ++++++-- src/preload/api/pty-api.ts | 5 + src/preload/api/pty-bridge-session-control.ts | 6 + .../agent-session-failure-words-text.ts | 5 + .../terminal-pane/ipc-pty-connect-result.ts | 11 +- .../terminal-pane/ipc-pty-connect.ts | 2 + .../terminal-pane/pty-connection-types.ts | 2 + .../pty-connection/apply-reattach-payload.ts | 31 ++- .../cold-restore-resume-startup.ts | 17 ++ .../pty-connection/connect-pane-pty.ts | 3 + .../deferred-cold-restore-and-snapshot.ts | 39 +++- .../deferred-session-reattach-connect.ts | 1 + .../pty-connection/fresh-spawn-start.ts | 36 ++- .../pty-connection/fresh-spawn-types.ts | 3 + .../hidden-agent-resume-deferral.ts | 3 + .../pty-connection/pane-serializer-settle.ts | 10 +- .../reattach-payload-context.test.ts | 54 +++++ .../reattach-payload-session.ts | 1 + .../pty-connection/reattach-result-handler.ts | 23 +- .../saved-resume-cancellation.test.ts | 129 +++++++++++ .../saved-resume-startup-cancellation.ts | 102 +++++++++ .../pty-connection/sleeping-record-access.ts | 30 ++- .../pty-connection/spawn-deferred-sleep.ts | 6 +- .../pty-connection/title-spawn-bell.ts | 19 +- .../terminal-pane/pty-transport-types.ts | 3 +- ...-pty-transport-host-session-launch.test.ts | 58 +++++ ...runtime-pty-transport-spawn-intent.test.ts | 48 +++- .../remote-runtime-pty-transport.ts | 70 ++++-- .../ipc-events/agent-status-listeners.ts | 3 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/lib/forget-refused-pane-resume.ts | 63 +++--- .../src/lib/sleeping-agent-session-launch.ts | 13 ++ .../src/store/terminals/terminal-state.ts | 2 + src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 1 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-host-authority.ts | 1 + src/shared/agent-session-refusal-details.ts | 1 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/agent-session-resume.ts | 43 ++++ src/shared/agent-status-ipc-payload.ts | 2 + src/shared/protocol-version.ts | 3 + .../rpc-contract/agent-session-params.ts | 1 + src/shared/runtime-terminal-contracts.ts | 4 +- ...ctured-agent-session-dispatch-rejection.ts | 2 + 89 files changed, 1886 insertions(+), 697 deletions(-) delete mode 100644 src/main/runtime/managed-conversation-starts.test.ts delete mode 100644 src/main/runtime/managed-conversation-starts.ts delete mode 100644 src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts create mode 100644 src/main/runtime/terminal/launch/terminal-launch-result.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index cfe42b8198f0..fb9c9be59691 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -16,6 +16,9 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { const clear = { paneKey: ownerKey, refusedResume: refused } if ( row && + refused.expectedStatus && + row.receivedAt <= refused.expectedStatus.receivedAt && + row.stateStartedAt === refused.expectedStatus.stateStartedAt && row.payload.agentType === refused.agent && agentProviderSessionsEqual(refused.agent, row.providerSession, refused.providerSession) ) { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 2bd9640c3193..73e29edebaf0 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -163,20 +163,13 @@ export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResu } } -/** A terminal resume a command asks for: the chat agent whose conversations it names, and the - * conversation (null: any of that agent's). */ -export type AdmittedResumeInvocation = { provider: StructuredAgentId; target: string | null } - -/** Refuses a command resuming a conversation a chat holds; otherwise `register` runs in the same - * synchronous step as the check, so a chat starting meanwhile sees what it registered. */ -export async function assertLegacyAiVaultResumeCommandAllowed( +export async function assertLegacyAiVaultResumeCommandAllowed( command: string, - ensureHost: () => Promise, - register?: (invocations: AdmittedResumeInvocation[]) => T -): Promise { + ensureHost: () => Promise +): Promise { const invocations = parseResumeInvocations(command) if (invocations.length === 0) { - return undefined + return } // A terminal command is not a chat: with chats refused here there is no ownership to check. await ensureStructuredAgentSessionHostUnlessRefused(ensureHost) @@ -188,7 +181,6 @@ export async function assertLegacyAiVaultResumeCommandAllowed( refuseLegacyWriter(ownership) } } - return register?.(invocations.map(({ provider, target }) => ({ provider, target }))) } /** Any chat whose chain ever held the row a resume or fork names; null when none did. */ diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 0508271d1880..ff3f8b2cfe88 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -1,3 +1,4 @@ +import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { describe, expect, it, vi } from 'vitest' import { CodexAppServerFrameSizeError, @@ -325,3 +326,43 @@ describe('openCodexThread', () => { }) }) }) + +it('keeps an exact active-writer refusal typed through cleanup wrapping', async () => { + const error = new CodexAppServerRequestError( + 'thread/resume', + -32600, + 'thread C already has an active writer' + ) + const request = vi.fn().mockRejectedValue(error) + let refused: unknown + try { + await openCodexThread(connectionFor(request), { cwd: '/workspace', resumeThreadId: 'C' }, 2_000) + } catch (caught) { + refused = caught + } + expect(refused).toMatchObject({ reason: 'conversationOpenElsewhere' }) + expect(providerStartupFailureFact(new AggregateError([refused], 'cleanup failed'))).toMatchObject( + { + kind: 'conversationOpenElsewhere', + detail: { text: error.message, audience: 'log' } + } + ) + expect(request).toHaveBeenCalledOnce() +}) + +it.each([ + ['thread/resume', -32600, 'thread D already has an active writer'], + ['thread/start', -32600, 'thread C already has an active writer'], + ['thread/resume', -32600, 'thread C is archived'], + ['thread/resume', -32600, 'unrelated invalid request'], + ['thread/resume', -1, 'thread C already has an active writer'] +] as const)('does not broaden writer refusal for %s/%s/%s', async (method, code, message) => { + const error = new CodexAppServerRequestError(method, code, message) + await expect( + openCodexThread( + connectionFor(vi.fn().mockRejectedValue(error)), + { cwd: '/workspace', resumeThreadId: 'C' }, + 2_000 + ) + ).rejects.toBe(error) +}) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 2d2489094239..33e61bfa28b2 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -5,6 +5,8 @@ // recording it would make the durable handle chain lie about what this session // actually proved. +import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { isCodexAppServerRequestError, @@ -113,6 +115,21 @@ export async function openCodexThread( ) } catch (error) { throwIfSignalAborted(signal) + if ( + isCodexAppServerRequestError(error) && + error.method === 'thread/resume' && + error.code === -32600 && + /\bthread\s+(\S+)\s+already has an active writer\b/i.exec(error.message)?.[1] === + resumeThreadId + ) { + throw withProviderDiagnostic( + Object.assign( + new AgentSessionAcquisitionRefusal(error.message, 'conversationOpenElsewhere'), + { cause: error } + ), + providerDiagnostic(error.providerDiagnostic?.text ?? error.message, 'log') + ) + } if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error } diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 1adcd9237ca5..b9581a26c1f8 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -135,9 +135,7 @@ export const noChatsRuntimeMembers = () => ({ openAgentSessionRecordStore: vi.fn(async () => ({ listRecords: () => [], getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) - })), - reserveManagedConversationStart: vi.fn(() => ({ bind: vi.fn(), drop: vi.fn() })), - bindManagedConversationStart: vi.fn() + })) }) export const agentHookServerModuleMock = () => ({ diff --git a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts index 2a9a53ac3fcc..7143689445aa 100644 --- a/src/main/ipc/pty-spawn-saved-resume-admission.test.ts +++ b/src/main/ipc/pty-spawn-saved-resume-admission.test.ts @@ -132,16 +132,30 @@ describe('a pane restore of a conversation a native chat continues', () => { } ) + posixOnlyIt('keeps a committed refused shell usable when status cleanup fails', async () => { + register([chat.sessionId]) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + forgetRefusedPaneResumeMock.mockImplementationOnce(() => { + throw new Error('cleanup failed') + }) + try { + const restored = await spawnResume('saved') + expect(restored).toMatchObject({ refused: 'chat-home', notice: true }) + expect(restored.input).not.toContain(C) + expect(warning).toHaveBeenCalled() + } finally { + warning.mockRestore() + } + }) + posixOnlyIt( - 'resumes once the chat tab is closed, and the chat then refuses to start', + 'resumes once the chat tab is closed, with the accepted pre-report interval', async () => { const runtime = register([]) const restored = await spawnResume('saved') expect(restored.refused).toBeUndefined() expect(restored.input).toContain(`--resume '${C}'`) - await expect(runtime['assertStructuredChatAcquireAdmissible'](chat)).rejects.toMatchObject({ - refusal: { details: { reason: 'conversationHeldElsewhere' } } - }) + await expect(runtime['assertStructuredChatAcquireAdmissible'](chat)).resolves.toBeUndefined() } ) diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index 89db109a1059..65296323394c 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -1,6 +1,7 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import { normalizeAgentProviderSession, + agentProviderSessionsEqual, type AgentProviderSessionMetadata, type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord @@ -10,7 +11,6 @@ import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import type { ManagedConversationStart } from '../../../runtime/managed-conversation-starts' import { agentSessionLeasePossiblyWriting, listStructuredProviderSessionOwnership @@ -20,8 +20,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../share import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { keysMayMatch, - terminalConversationKey, - type ConversationKey + terminalConversationKey } from '../../../native-chat/structured-conversation-key' export type ResumeSpawnArgs = { @@ -37,26 +36,27 @@ export type ResumeSpawnArgs = { } type AdmissionRuntime = { + getAgentProviderSessionRows?(): AgentStatusIpcPayload[] + readSavedResumeSource?( + worktreeId: string, + paneKey: string + ): Promise openAgentSessionRecordStore(): Promise - reserveManagedConversationStart(keys: readonly ConversationKey[]): ManagedConversationStart } /** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ export type ResumeSpawnRefusal = { + sourceRecord?: SleepingAgentSessionRecord verdict: 'chat-home' | 'chat-writing' | 'unverifiable' refused: RefusedPaneResume } export type ResumeSpawnAdmission = - | { verdict: 'admitted'; start: ManagedConversationStart } + | { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord } | ResumeSpawnRefusal -/** - * Decided on the execution host before any command is issued. An admitted start registers in the - * same step as the check, so a chat starting meanwhile sees it. Null: nothing here can be a chat's - * (no resume, a remote or WSL pane), so the spawn stays as it was. - */ +/** Saved-home policy only; ordinary terminal starts remain hook-observed. */ export async function admitResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, @@ -65,10 +65,19 @@ export async function admitResumeSpawn( const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent // Native chats run only natively, so a remote or WSL conversation is never a chat's. - if (!runtime || !providerSession || !agent || args.connectionId || wslDistro !== null) { + if ( + args.resumeOrigin !== 'saved' || + !providerSession || + !agent || + args.connectionId || + wslDistro !== null + ) { return null } - return judgeResumes(runtime, [{ agent, providerSession }], args.resumeOrigin === 'saved') + if (!runtime) { + return { verdict: 'unverifiable', refused: { agent, providerSession } } + } + return judgeResumes(runtime, [{ agent, providerSession }]) } /** A reattach that found its session gone returns a fresh shell its window then resumes from the @@ -89,7 +98,11 @@ export async function admitColdRestoreReattach( ...runtime .getAgentProviderSessionRows() .flatMap((row) => - row.paneKey === paneKey && !row.connectionId && row.providerSession + row.paneKey === paneKey && + row.state !== 'done' && + !row.structuredHost && + !row.connectionId && + row.providerSession ? [{ agent: row.agentType, providerSession: row.providerSession }] : [] ), @@ -97,14 +110,13 @@ export async function admitColdRestoreReattach( ? [{ agent: saved.agent, providerSession: saved.providerSession }] : []) ] - const [first, ...rest] = resumes - return first ? judgeResumes(runtime, [first, ...rest], true) : null + const [first] = resumes + return first ? judgeResumes(runtime, [first]) : null } async function judgeResumes( runtime: AdmissionRuntime, - resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]], - saved: boolean + resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]] ): Promise { const keysOf = ({ agent, providerSession }: RefusedPaneResume) => [providerSession.id, providerSession.transcriptPath].flatMap((id) => @@ -112,7 +124,7 @@ async function judgeResumes( ) const admitted = (): ResumeSpawnAdmission => ({ verdict: 'admitted', - start: runtime.reserveManagedConversationStart(resumes.flatMap(keysOf)) + refused: resumes[0] }) let store: AgentSessionRecordStore try { @@ -122,13 +134,12 @@ async function judgeResumes( "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", error ) - // Bookkeeping never gates a resume the user asked for. - return saved ? { verdict: 'unverifiable', refused: resumes[0] } : admitted() + return { verdict: 'unverifiable', refused: resumes[0] } } const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( (holding) => holding.role === 'head' ) - const tabs = saved ? store.getVisibleSessionTabIndex() : null + const tabs = store.getVisibleSessionTabIndex() for (const refused of resumes) { const holders = heads.filter((holding) => keysOf(refused).some((key) => keysMayMatch(holding.key, key)) @@ -136,7 +147,7 @@ async function judgeResumes( if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { return { verdict: 'chat-writing', refused } } - if (tabs && holders.length > 0 && !tabs.present) { + if (holders.length > 0 && (!tabs || !tabs.present)) { return { verdict: 'unverifiable', refused } } if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { @@ -153,7 +164,11 @@ export function dropAgentLaunch(args: ResumeSpawnArgs): void { if (args.env) { args.env = Object.fromEntries( Object.entries(args.env).filter( - ([key, value]) => key !== 'ORCA_AGENT_LAUNCH_TOKEN' && agentEnv[key] !== value + ([key, value]) => + key !== 'ORCA_AGENT_LAUNCH_TOKEN' && + key !== 'ORCA_SEQUENCED_STARTUP_COMMAND' && + key !== 'ORCA_SEQUENCED_STARTUP_SCRIPT' && + agentEnv[key] !== value ) ) } @@ -164,46 +179,20 @@ export function dropAgentLaunch(args: ResumeSpawnArgs): void { delete args.resumeProviderSession } -/** The admission at a spawn step: a refused resume leaves the spawn its plain shell. */ -export async function applyResumeSpawnAdmission( - runtime: AdmissionRuntime | undefined, - args: ResumeSpawnArgs, - wslDistro: string | null -): Promise { - const admission = await admitResumeSpawn(runtime, args, wslDistro) - if (admission && admission.verdict !== 'admitted') { - dropAgentLaunch(args) - } - return admission -} - export function resumeSpawnRefusal( admission: ResumeSpawnAdmission | null ): ResumeSpawnRefusal | null { return admission && admission.verdict !== 'admitted' ? admission : null } -/** An admitted start holds the PTY that runs it; a reattach never ran the launch. */ -export function bindResumeSpawnStart( - runtime: - | { bindManagedConversationStart(start: ManagedConversationStart, ptyId: string | null): void } - | undefined, - admission: ResumeSpawnAdmission | null, - result: { id: string; isReattach?: boolean } -): void { - if (admission?.verdict === 'admitted') { - runtime?.bindManagedConversationStart(admission.start, result.isReattach ? null : result.id) - } -} - -export function dropResumeSpawnStart(admission: ResumeSpawnAdmission | null): void { - if (admission?.verdict === 'admitted') { - admission.start.drop() +/** Unwinds only the claim whose fresh callback has issued no physical work. */ +export class SavedResumeFreshRefusal extends Error { + constructor(readonly admission: ResumeSpawnRefusal) { + super('saved_resume_fresh_refused') } } -/** Retires a refused resume's claim at once, so the cleanup never waits on this spawn finishing or - * a pane receiving its result. An unverifiable refusal keeps everything for the next try. */ +/** Called only after a refused plain shell exists; uncertain evidence retains its recipe. */ export function forgetRefusedResume( paneKey: string | null, refusal: ResumeSpawnRefusal | null @@ -232,3 +221,111 @@ export function savedResumeRecord( ] : undefined } + +/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. */ +export async function chooseSavedResumeFreshLaunch( + runtime: AdmissionRuntime | undefined, + args: ResumeSpawnArgs, + wslDistro: string | null, + options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } +): Promise { + const admission = await admitResumeSpawn(runtime, args, wslDistro) + if (resumeSpawnRefusal(admission)) { + suppressSavedResumeLaunch(args, options) + } + return admission +} + +/** A claim joiner uses the same suppression without reclassifying the settled request. */ +export function suppressSavedResumeLaunch( + args: ResumeSpawnArgs, + options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } +): void { + options.launchConfig = args.launchConfig + dropAgentLaunch(options) + delete options.startupCommandDelivery + delete options.startupIngress + dropAgentLaunch(args) +} + +export function savedResumeResult(admission: ResumeSpawnAdmission | null) { + if (!admission) { + return {} + } + const { agent, providerSession } = admission.refused + return { + savedResumeDecision: { agent, providerSession, verdict: admission.verdict }, + ...(admission.verdict !== 'admitted' + ? { + agentResumeRefused: admission.verdict, + agentResumeUnavailable: true as const, + refusedResume: admission.refused + } + : {}) + } +} + +/** Capture before spawn; later same-conversation status and saved versions belong to later work. */ +export async function captureSavedResumeCleanup( + runtime: AdmissionRuntime | undefined, + store: Parameters[0], + paneKey: string | null, + admission: ResumeSpawnAdmission | null, + worktreeId?: string +): Promise { + if (!paneKey || !admission) { + return + } + let candidate = savedResumeRecord(store, paneKey) + if (runtime?.readSavedResumeSource && worktreeId) { + try { + candidate = await runtime.readSavedResumeSource(worktreeId, paneKey) + } catch (error) { + console.warn('[pty] could not capture saved resume cleanup source', error) + } + } + const saved = + candidate && + candidate.agent === admission.refused.agent && + agentProviderSessionsEqual( + candidate.agent, + candidate.providerSession, + admission.refused.providerSession + ) + ? candidate + : undefined + const row = runtime + ?.getAgentProviderSessionRows?.() + .find( + (entry) => + entry.paneKey === paneKey && + entry.agentType === admission.refused.agent && + agentProviderSessionsEqual( + entry.agentType, + entry.providerSession, + admission.refused.providerSession + ) + ) + admission.sourceRecord = saved + admission.refused = { + ...admission.refused, + ...(row + ? { + expectedStatus: { + paneKey, + receivedAt: row.receivedAt, + stateStartedAt: row.stateStartedAt + } + } + : {}), + ...(saved + ? { + expectedSavedRecord: { + paneKey, + capturedAt: saved.capturedAt, + updatedAt: saved.updatedAt + } + } + : {}) + } +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index bdf460e07825..318915331061 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -23,7 +23,7 @@ import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty- import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' -import { resumeSpawnRefusal } from '../host-env/saved-resume-admission' +import { resumeSpawnRefusal, savedResumeResult } from '../host-env/saved-resume-admission' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) @@ -41,7 +41,7 @@ function resumeDisposition(ctx: PtyIpcSpawnState) { ...(ctx.codexResumeLaunch.notifyResumeUnavailable || refusal ? { agentResumeUnavailable: true as const } : {}), - ...(refusal ? { agentResumeRefused: refusal.verdict } : {}) + ...savedResumeResult(ctx.resumeAdmission) } } @@ -208,6 +208,15 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise ctx.spawnTiming.mark(phase) }) ctx.codexResumeLaunch = hostEnv.codexResumeLaunch + if (hostEnv.codexResumeLaunch.droppedResumeArgv && ctx.effectiveLaunchConfig) { + ctx.effectiveLaunchConfig = { + ...ctx.effectiveLaunchConfig, + agentCommand: hostEnv.launchCommand ?? ctx.effectiveLaunchConfig.agentCommand + } + } ctx.launchCommand = hostEnv.launchCommand ctx.baseEnv = hostEnv.baseEnv ctx.env = hostEnv.env diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index e4b7fe2bb8ce..7832ee122ac7 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -1,3 +1,9 @@ +import { + captureSavedResumeCleanup, + chooseSavedResumeFreshLaunch, + type ResumeSpawnAdmission, + resumeSpawnRefusal +} from '../host-env/saved-resume-admission' import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' import { noteWslDistrosRunning } from '../../../wsl-background-distro-gate' import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner' @@ -9,6 +15,42 @@ import type { PtyIpcSpawnState } from './spawn-state' export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args + const captured: ResumeSpawnAdmission | null = + args.launchAgent && args.resumeProviderSession + ? { + verdict: 'admitted', + refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } + } + : null + if (args.resumeOrigin === 'saved') { + await captureSavedResumeCleanup( + ctx.deps.runtime, + ctx.deps.store, + ctx.stablePaneKey, + captured, + args.worktreeId + ) + } + const chooseFresh = async (): Promise => { + ctx.spawnOptions.env = { ...ctx.spawnOptions.env } + args.launchConfig = ctx.effectiveLaunchConfig + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + ) + if (captured && ctx.resumeAdmission) { + ctx.resumeAdmission.refused = captured.refused + ctx.resumeAdmission.sourceRecord = captured.sourceRecord + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + ctx.baseEnv = ctx.spawnOptions.env + ctx.spawnEnv = ctx.spawnOptions.env + } + } try { if (ctx.preAllocatedHandle) { ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) @@ -56,9 +98,16 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { owner: stablePaneOwnerCandidate, worktreeId: args.worktreeId, connectionId: args.connectionId, - resolveOwner + resolveOwner, + beforeFreshSpawn: chooseFresh }) ctx.result = stablePaneSpawn.result + if (ctx.result.isReattach) { + ctx.resumeAdmission = null + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + ctx.effectiveLaunchConfig = undefined + } ctx.stablePaneOwner = stablePaneSpawn.owner if ( ctx.stablePaneOwner && @@ -68,6 +117,9 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { ) { clearProviderPtyState(ctx.effectiveSessionAppId) } + if (ctx.result.isReattach) { + Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id)) + } ctx.rejectedRegistrationCandidate = ctx.result if (ctx.pendingRegistrationPtyId !== ctx.result.id) { if (ctx.pendingRegistrationPtyId) { diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index a01d0cf0543d..f791190a074c 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -10,7 +10,6 @@ import { getProvider } from '../provider/registry' import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import type { PtyIpcSpawnState } from './spawn-state' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' -import { applyResumeSpawnAdmission, resumeSpawnRefusal } from '../host-env/saved-resume-admission' export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -108,14 +107,6 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis ctx.terminalRuntimeOptions = target.terminalRuntimeOptions ctx.expectedWslDistro = target.expectedWslDistro ctx.codexSelectionTarget = target.codexSelectionTarget - if (!ctx.preAdoptedStablePane) { - ctx.resumeAdmission = await applyResumeSpawnAdmission( - ctx.deps.runtime, - args, - ctx.expectedWslDistro - ) - ctx.isClaudeLaunch = ctx.isClaudeLaunch && !resumeSpawnRefusal(ctx.resumeAdmission) - } ctx.spawnTiming.mark('preflight') args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) ctx.claudeAuth = diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index f15863cbbdc7..9bce2857788f 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -20,9 +20,8 @@ import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { refuseClosedPaneSpawn } from './closed-pane-spawn-refusal' import { + captureSavedResumeCleanup, admitColdRestoreReattach, - bindResumeSpawnStart, - dropResumeSpawnStart, forgetRefusedResume, resumeSpawnRefusal, savedResumeRecord @@ -58,7 +57,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg triggerPtySpawnPushTargetMaterialization(deps, args) const early = await beginPtyIpcSpawn(ctx) if (early) { - return early + return { ...early, ...deps.runtime?.getTerminalLaunchResult?.(early.id) } } // Awaited only for a launch: an ordinary spawn reaches its provider in the same turn as before. if (ctx.args.agentLaunch !== undefined) { @@ -66,7 +65,6 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg } await preparePtyIpcSpawnPreflight(ctx) await assemblePtyIpcSpawnEnv(ctx) - forgetRefusedResume(ctx.stablePaneKey, resumeSpawnRefusal(ctx.resumeAdmission)) await preparePtyIpcQoderCommand(ctx) const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize({ @@ -83,8 +81,7 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg // evict the team env assembly created for it — exit/close cleanup keys // off handleByPtyId — so every lost race would leak one team forever. releaseAbandonedAgentTeamsLeader(ctx) - dropResumeSpawnStart(ctx.resumeAdmission) - return earlyReserved + return { ...earlyReserved, ...deps.runtime?.getTerminalLaunchResult?.(earlyReserved.id) } } // Right before the provider spawn, with nothing awaited in between: a closed pane starts nothing. ctx.paneAgentLaunch?.refuseIfClosed() @@ -100,18 +97,37 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg { paneKey: ctx.stablePaneKey, saved: savedResumeRecord(deps.store, ctx.stablePaneKey) }, ctx.expectedWslDistro ) - forgetRefusedResume(ctx.stablePaneKey, resumeSpawnRefusal(ctx.resumeAdmission)) + await captureSavedResumeCleanup( + deps.runtime, + deps.store, + ctx.stablePaneKey, + ctx.resumeAdmission, + args.worktreeId + ) } const committed = await commitPtyIpcSpawn(ctx) - bindResumeSpawnStart(deps.runtime, ctx.resumeAdmission, ctx.result) + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + forgetRefusedResume(ctx.stablePaneKey, refusal) + if (args.worktreeId && ctx.stablePaneKey && refusal && refusal.verdict !== 'unverifiable') { + void deps.runtime + ?.forgetRefusedSavedResume?.( + args.worktreeId, + ctx.stablePaneKey, + refusal.refused, + refusal.sourceRecord + ) + .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) + } endPaneAgentLaunch( ctx, ctx.exitedBeforeRegistration ? { exitedDuringStart: true } : { started: true } ) + if (!ctx.result.isReattach) { + deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) + } return committed } catch (caught) { const err = endPaneAgentLaunch(ctx, { error: caught }) ?? caught - dropResumeSpawnStart(ctx.resumeAdmission) releaseAbandonedAgentTeamsLeader(ctx) if (ctx.preSpawnHiddenMarkId !== null) { ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index f190e5ab2cd9..ef28cc4bbde3 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -178,6 +178,7 @@ export type StablePaneSpawnContext = { worktreeId?: string connectionId?: string | null resolveOwner?: () => StablePaneOwner | null + beforeFreshSpawn?: () => Promise onFreshSpawn?: (result: PtySpawnResult) => void } @@ -292,6 +293,7 @@ export async function spawnForStablePane( return attached } } + await args.beforeFreshSpawn?.() const result = await args.provider.spawn(args.spawnOptions) args.onFreshSpawn?.(result) return { result, owner: null } diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 6a6bd59ee140..8f8bfef1b120 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -119,11 +119,7 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { ctx.paneSpawnReservation, adoptedResult ) - return { - id: ctx.result.id, - ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), - agentSessionEnsure: ctx.result.agentSessionEnsure - } + return adoptedResult } if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { try { @@ -269,7 +265,7 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { : {}) }) const response = { - id: ctx.result.id, + ...ctx.result, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), ...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle) ? { diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts index 45b8f04a38a2..bddbf5fe406f 100644 --- a/src/main/ipc/pty/runtime/spawn-early.ts +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -43,6 +43,8 @@ export function adoptMaterializedRuntimePtySpawn( throw new Error('terminal_pane_owner_unknown') } ctx.result = { + ...ctx.preAdoptedStablePane.result, + isReattach: true, id: ctx.preAdoptedStablePane.result.id, ...(ctx.preAdoptedStablePane.result.incarnationId ? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 91c2c58ec67e..64133237fc54 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -1,3 +1,12 @@ +import { + suppressSavedResumeLaunch, + captureSavedResumeCleanup, + chooseSavedResumeFreshLaunch, + type ResumeSpawnAdmission, + resumeSpawnRefusal, + SavedResumeFreshRefusal +} from '../host-env/saved-resume-admission' +import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { ptyIncarnationById } from '../provider/ownership-state' @@ -18,6 +27,39 @@ import { markRuntimeSpawnHiddenBeforeSpawn } from './spawn-hidden-delivery' export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { const args = ctx.args + const captured: ResumeSpawnAdmission | null = + args.launchAgent && args.resumeProviderSession + ? { + verdict: 'admitted', + refused: { agent: args.launchAgent, providerSession: args.resumeProviderSession } + } + : null + if (args.resumeOrigin === 'saved') { + await captureSavedResumeCleanup( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + captured, + args.worktreeId + ) + } + const chooseFresh = async (): Promise => { + ctx.spawnOptions.env = { ...ctx.spawnOptions.env } + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + ) + if (captured && ctx.resumeAdmission) { + ctx.resumeAdmission.refused = captured.refused + ctx.resumeAdmission.sourceRecord = captured.sourceRecord + } + if (resumeSpawnRefusal(ctx.resumeAdmission)) { + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + } + } const runtime = ctx.deps.runtime const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn ctx.releaseWorktreeSpawn = acquireWorktreeSpawn @@ -83,57 +125,103 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.pendingRegistrationPtyId = recoveredOwner.ptyId } let providerResult: PtySpawnResult | null = null - const ensured = await agentSessionOwners.ensure({ - claim: args.agentSessionEnsure.claim, - surface: args.agentSessionEnsure.surface, - spawn: async () => { - assertClientStillConnected() - providerResult = await ctx.provider.spawn(ctx.spawnOptions) - ctx.rejectedRegistrationCandidate = providerResult - // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. - ctx.reportPtySpawnCommitted() - assertSpawnReplyWasLive(providerResult) - ctx.deps.runtime?.assertPtyRegistrationAllowed?.( - providerResult.id, - providerResult.incarnationId - ) - if (providerResult.incarnationId) { - // Why: local providers cannot serialize controller claims, so liveness proof - // needs the exact incarnation before the registry promotes the new owner. - ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) - } - const providerEnsure = providerResult.agentSessionEnsure - return { - ptyId: providerResult.id, - ...(providerEnsure - ? { - owner: providerEnsure.owner, - disposition: providerEnsure.disposition - } - : {}) + let ensured: AgentSessionClaimedSpawnResult | undefined + try { + ensured = await agentSessionOwners.ensure({ + claim: args.agentSessionEnsure.claim, + surface: args.agentSessionEnsure.surface, + spawn: async () => { + assertClientStillConnected() + await chooseFresh() + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + if (refusal) { + throw new SavedResumeFreshRefusal(refusal) + } + providerResult = await ctx.provider.spawn(ctx.spawnOptions) + ctx.rejectedRegistrationCandidate = providerResult + // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. + ctx.reportPtySpawnCommitted() + assertSpawnReplyWasLive(providerResult) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.( + providerResult.id, + providerResult.incarnationId + ) + if (providerResult.incarnationId) { + // Why: local providers cannot serialize controller claims, so liveness proof + // needs the exact incarnation before the registry promotes the new owner. + ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + } + const providerEnsure = providerResult.agentSessionEnsure + return { + ptyId: providerResult.id, + ...(providerEnsure + ? { + owner: providerEnsure.owner, + disposition: providerEnsure.disposition + } + : {}) + } + }, + isLive: async (owner) => { + const ownerProvider = tryGetProviderForPty(owner.ptyId) + if (!ownerProvider) { + // Why: an owner on a host this process cannot reach is unknown, never absent. + throw new Error('execution_owner_unavailable') + } + return await isProviderAgentSessionOwnerLive(ownerProvider, owner) + }, + onJoin: () => { + awaitingAnotherStart = true } - }, - isLive: async (owner) => { - const ownerProvider = tryGetProviderForPty(owner.ptyId) - if (!ownerProvider) { - // Why: an owner on a host this process cannot reach is unknown, never absent. - throw new Error('execution_owner_unavailable') - } - return await isProviderAgentSessionOwnerLive(ownerProvider, owner) - }, - onJoin: () => { - awaitingAnotherStart = true + }) + } catch (error) { + if (!(error instanceof SavedResumeFreshRefusal)) { + throw error + } + // The claim reservation rejected before spawn; this shell must never become its owner. + ctx.resumeAdmission = { + ...error.admission, + refused: captured?.refused ?? error.admission.refused, + sourceRecord: captured?.sourceRecord } - }) + suppressSavedResumeLaunch(args, ctx.spawnOptions) + ctx.launchCommand = undefined + ctx.env = ctx.spawnOptions.env + delete args.agentSessionEnsure + ctx.spawnOptions.agentSessionEnsure = undefined + const shell = await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ), + onFreshSpawn: ctx.reportPtySpawnCommitted + }) + providerResult = shell.result + ctx.stablePaneOwner = shell.owner + } awaitingAnotherStart = false - ctx.result = providerResult ?? { - id: ensured.owner.ptyId, - isReattach: true, - // Why: adoption from an authoritative listing must preserve the - // incarnation proof used to reject a delayed exit from an older process. - incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + if (providerResult) { + ctx.result = providerResult + } else if (ensured) { + ctx.result = { + id: ensured.owner.ptyId, + isReattach: true, + // A listing's incarnation rejects delayed exits from an older process. + incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + } + } else { + throw new Error('agent_session_owner_missing') + } + if (ensured) { + ctx.result.agentSessionEnsure = ensured } - ctx.result.agentSessionEnsure = ensured } else { assertClientStillConnected() const stablePaneSpawn = ctx.preAdoptedStablePane @@ -154,6 +242,7 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise args.worktreeId, args.connectionId ), + beforeFreshSpawn: chooseFresh, onFreshSpawn: ctx.reportPtySpawnCommitted }) ctx.result = stablePaneSpawn.result @@ -169,6 +258,13 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ctx.rejectedRegistrationCandidate = ctx.result assertSpawnReplyWasLive(ctx.result) } + if (ctx.result.isReattach || ctx.result.agentSessionEnsure?.disposition === 'adopted') { + ctx.result.isReattach = true + ctx.resumeAdmission = null + } + if (ctx.result.isReattach) { + Object.assign(ctx.result, ctx.deps.runtime?.getTerminalLaunchResult?.(ctx.result.id)) + } ctx.rejectedRegistrationCandidate ??= ctx.result if (ctx.pendingRegistrationPtyId !== ctx.result.id) { if (ctx.pendingRegistrationPtyId) { diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index 8a369852db6b..fd970427e24a 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -1,3 +1,6 @@ +import { localProvider } from '../provider/registry' +import { spawnPtyFromRuntimeController } from './spawn' +import * as owners from '../pane/agent-session-owners' import { describe, expect, it, vi } from 'vitest' import type { BrowserWindow } from 'electron' import { getDefaultSettings } from '../../../../shared/constants' @@ -6,17 +9,32 @@ import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import { ManagedConversationStarts } from '../../../runtime/managed-conversation-starts' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { finishPtyShutdown } from '../provider/liveness' import { admitColdRestoreReattach } from '../host-env/saved-resume-admission' import type { PtyRuntimeControllerDeps } from './controller-deps' -import { prepareRuntimePtySpawn } from './spawn-preflight' +import { executeRuntimePtySpawn } from './spawn-execute' +import { savedResumeResult } from '../host-env/saved-resume-admission' import { createRuntimePtySpawnState } from './spawn-state' const forgetRefusedPaneResume = vi.hoisted(() => vi.fn()) vi.mock('../../../agent-hooks/server', () => ({ agentHookServer: { forgetRefusedPaneResume } })) +vi.mock('./spawn-preflight', () => ({ prepareRuntimePtySpawn: vi.fn(async () => null) })) +vi.mock('./spawn-options', () => ({ + buildRuntimePtySpawnOptions: vi.fn(async (ctx: ReturnType) => { + ctx.spawnIdentityPaneKey = 'tab-1:leaf' + ctx.spawnOptions = { + cols: ctx.args.cols, + rows: ctx.args.rows, + command: ctx.args.command, + env: ctx.args.env, + launchAgent: ctx.args.launchAgent + } + return null + }) +})) + const C = 'provider-session-alpha-1' const chat = agentSessionRecordFixture( agentSessionLeaseFixture({ @@ -34,14 +52,21 @@ const store = { /** A host whose one chat continues C at rest, with its tab open. */ function chatHost() { - const starts = new ManagedConversationStarts() return { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the two members `store` has. openAgentSessionRecordStore: async () => store as unknown as AgentSessionRecordStore, - reserveManagedConversationStart: vi.fn(starts.reserve.bind(starts)), + getTerminalLaunchResult: vi.fn(), forgetRefusedSavedResume: vi.fn(async () => {}), getAgentProviderSessionRows: () => [ - { paneKey: 'tab-1:leaf', agentType: 'claude', providerSession: { key: 'session_id', id: C } } + { + paneKey: 'tab-1:leaf', + agentType: 'claude' as const, + state: 'working' as const, + prompt: '', + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id' as const, id: C } + } ] } } @@ -83,41 +108,178 @@ function deps(runtime: ReturnType): PtyRuntimeControllerDeps { } } -describe('host-side saved resumes of a conversation an open chat continues', () => { - it('materializes a phone-restored pane as a plain shell and forgets its saved record', async () => { - const runtime = chatHost() +describe('actual runtime saved-resume outcomes', () => { + function freshContext(runtime = chatHost()) { const ctx = createRuntimePtySpawnState(deps(runtime), { cols: 80, rows: 24, worktreeId: 'wt-1', tabId: 'tab-1', - leafId: '11111111-1111-4111-8111-111111111111', command: `claude --resume ${C}`, launchAgent: 'claude', resumeProviderSession: { key: 'session_id', id: C }, - resumeOrigin: 'saved' + resumeOrigin: 'saved', + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: { AGENT_ONLY: 'yes' } }, + env: { + WORKSPACE: 'keep', + AGENT_ONLY: 'yes', + ORCA_AGENT_LAUNCH_TOKEN: 'rejected', + ORCA_SEQUENCED_STARTUP_COMMAND: `claude --resume ${C}`, + ORCA_SEQUENCED_STARTUP_SCRIPT: `fallback claude --resume ${C}` + } }) - await prepareRuntimePtySpawn(ctx) - ctx.finishTerminalInstall() - expect(ctx.args.command).toBeUndefined() - expect(ctx.launchCommand ?? '').not.toContain(C) - expect(runtime.forgetRefusedSavedResume).toHaveBeenCalledWith( - 'wt-1', - 'tab-1:11111111-1111-4111-8111-111111111111', - { agent: 'claude', providerSession: { key: 'session_id', id: C } } - ) + ctx.spawnOptions = { + cols: 80, + rows: 24, + command: ctx.args.command, + launchAgent: 'claude', + env: { ...ctx.args.env } + } + return ctx + } + + it('chooses a plain shell only in the fresh callback and does not forget before commit', async () => { + const runtime = chatHost() + const ctx = freshContext(runtime) + const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'plain-shell' }) + try { + expect(ctx.args.command).toContain(C) + await executeRuntimePtySpawn(ctx) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WORKSPACE: 'keep' } + }) + ) + expect(ctx.spawnOptions.command).toBeUndefined() + expect(ctx.args.launchAgent).toBeUndefined() + expect(savedResumeResult(ctx.resumeAdmission)).toMatchObject({ + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true, + savedResumeDecision: { verdict: 'chat-home', providerSession: { id: C } } + }) + expect(forgetRefusedPaneResume).not.toHaveBeenCalled() + expect(runtime.forgetRefusedSavedResume).not.toHaveBeenCalled() + } finally { + spawn.mockRestore() + } + }) + + it('lets the actual lower-owned live result override a tentative fresh refusal', async () => { + const runtime = chatHost() + const ctx = freshContext(runtime) + runtime.getTerminalLaunchResult.mockReturnValue({ launchAgent: 'codex' }) + const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ + id: 'already-live', + isReattach: true, + launchAgent: 'codex' + }) + try { + await executeRuntimePtySpawn(ctx) + expect(ctx.result).toMatchObject({ + id: 'already-live', + isReattach: true, + launchAgent: 'codex' + }) + expect(ctx.resumeAdmission).toBeNull() + expect(runtime.forgetRefusedSavedResume).not.toHaveBeenCalled() + } finally { + spawn.mockRestore() + } }) - it("refuses a late reattach's cold restore before its window can resume it", async () => { + it('checks the selected late cold-shell candidate but never checks a real reattach', async () => { const runtime = chatHost() + const pane = { paneKey: 'tab-1:leaf', saved: undefined } await expect( admitColdRestoreReattach( runtime, { coldRestore: { scrollback: '', cwd: '/tmp' } }, - { paneKey: 'tab-1:leaf', saved: undefined }, + pane, null ) - ).resolves.toMatchObject({ verdict: 'chat-home' }) - expect(runtime.reserveManagedConversationStart).not.toHaveBeenCalled() + ).resolves.toMatchObject({ verdict: 'chat-home', refused: { providerSession: { id: C } } }) + await expect( + admitColdRestoreReattach(runtime, { coldRestore: {}, isReattach: true }, pane, null) + ).resolves.toBeNull() }) }) + +it('unwinds a refused fresh claim before spawning an unclaimed shell', async () => { + const runtime = chatHost() + const ctx = createRuntimePtySpawnState(deps(runtime), { + cols: 80, + rows: 24, + launchAgent: 'claude', + command: `claude --resume ${C}`, + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: C }, + agentSessionEnsure: { + claim: { + agent: 'claude', + digestVersion: 1, + keyId: 'key', + identityDigest: 'C', + worktreeScopeDigest: 'scope' + }, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf', terminalHandle: 'terminal' } + } + }) + const claim = ctx.args.agentSessionEnsure?.claim + if (!claim) { + throw new Error('missing fixture claim') + } + ctx.spawnOptions = { + cols: 80, + rows: 24, + command: ctx.args.command, + agentSessionEnsure: ctx.args.agentSessionEnsure + } + const inventory = vi.spyOn(owners, 'reconcileAgentSessionOwnerListings').mockResolvedValue() + const spawn = vi.spyOn(ctx.provider, 'spawn').mockResolvedValue({ id: 'refused-shell' }) + try { + await executeRuntimePtySpawn(ctx) + expect(spawn).toHaveBeenCalledOnce() + expect(ctx.spawnOptions.command).toBeUndefined() + expect(ctx.result.agentSessionEnsure).toBeUndefined() + expect(owners.agentSessionOwners.find(claim)).toBeNull() + expect(ctx.resumeAdmission).toMatchObject({ verdict: 'chat-home' }) + } finally { + inventory.mockRestore() + spawn.mockRestore() + } +}) + +it('returns the committed runtime shell when exact saved-record persistence fails', async () => { + const runtime = { + ...chatHost(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + forgetRefusedSavedResume: vi.fn(async () => { + throw new Error('saved record disk full') + }) + } + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'cleanup-failed-shell' }) + try { + const result = await spawnPtyFromRuntimeController(deps(runtime), { + cols: 80, + rows: 24, + worktreeId: 'wt-1', + tabId: 'tab-1', + command: `claude --resume ${C}`, + launchAgent: 'claude', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: C } + }) + expect(result).toMatchObject({ id: 'cleanup-failed-shell', agentResumeRefused: 'chat-home' }) + expect(spawn.mock.calls[0]?.[0].command).toBeUndefined() + expect(runtime.registerPty).toHaveBeenCalled() + expect(runtime.forgetRefusedSavedResume).toHaveBeenCalledOnce() + await Promise.resolve() + expect(warning).toHaveBeenCalled() + } finally { + spawn.mockRestore() + warning.mockRestore() + } +}) diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index fb5b5b9c160b..9241e11e6a36 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -19,11 +19,6 @@ import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import { resolvePtySpawnHostEnv } from '../spawn-host-env' import { ptySpawnPaneFromIds, stampPtySpawnPaneEnv } from '../spawn-pane-env' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' -import { - applyResumeSpawnAdmission, - forgetRefusedResume, - resumeSpawnRefusal -} from '../host-env/saved-resume-admission' import { ensureJcodeRuntimeDir, JCODE_RUNTIME_DIR_ENV_KEY @@ -81,9 +76,6 @@ export async function prepareRuntimePtySpawn( ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride ctx.expectedWslDistro = target.expectedWslDistro ctx.codexSelectionTarget = target.codexSelectionTarget - if (!ctx.preAdoptedStablePane) { - await admitRuntimeResumeSpawn(ctx) - } args.env = withClaudeProfileTerminalEnv(args.env, ctx.codexSelectionTarget) ctx.claudeAuth = ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth @@ -128,8 +120,6 @@ export async function prepareRuntimePtySpawn( jcodeRuntimeDir: jcodeEnv?.[JCODE_RUNTIME_DIR_ENV_KEY] }) } - // Why: a dropped resume still applies here, but this controller's result has no field for - // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. const hostEnv = await resolvePtySpawnHostEnv({ launchAgent: args.launchAgent, command: args.command, @@ -150,6 +140,13 @@ export async function prepareRuntimePtySpawn( provider: ctx.provider, deps: ctx.deps }) + ctx.agentResumeUnavailable = hostEnv.codexResumeLaunch.notifyResumeUnavailable + if (hostEnv.codexResumeLaunch.droppedResumeArgv && args.launchConfig) { + args.launchConfig = { + ...args.launchConfig, + agentCommand: hostEnv.launchCommand ?? args.launchConfig.agentCommand + } + } ctx.launchCommand = hostEnv.launchCommand ctx.codexResumeArgvDropped = hostEnv.codexResumeLaunch.droppedResumeArgv ctx.env = hostEnv.env @@ -160,32 +157,3 @@ export async function prepareRuntimePtySpawn( return null } - -async function admitRuntimeResumeSpawn(ctx: RuntimePtySpawnState): Promise { - const { args } = ctx - ctx.resumeAdmission = await applyResumeSpawnAdmission( - ctx.deps.runtime, - args, - ctx.expectedWslDistro - ) - const refusal = resumeSpawnRefusal(ctx.resumeAdmission) - if (!refusal) { - return - } - ctx.isClaudeLaunch = false - const paneKey = ptySpawnPaneFromIds(args)?.paneKey ?? null - forgetRefusedResume(paneKey, refusal) - // A host with no window has no one else to forget the pane's saved record. - if ( - paneKey && - args.worktreeId && - args.resumeOrigin === 'saved' && - refusal.verdict !== 'unverifiable' - ) { - void ctx.deps.runtime - ?.forgetRefusedSavedResume(args.worktreeId, paneKey, refusal.refused) - .catch((error: unknown) => - console.warn('[pty] could not forget a refused saved resume', error) - ) - } -} diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 4de4db55ca4e..90c5a3d6a18d 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -16,7 +16,10 @@ import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-pla import type { ResumeSpawnAdmission } from '../host-env/saved-resume-admission' import type { PtyRuntimeControllerDeps } from './controller-deps' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { + SleepingAgentLaunchConfig, + AgentProviderSessionMetadata +} from '../../../../shared/agent-session-resume' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { AgentSessionExecutionClaim, @@ -63,6 +66,7 @@ export type RuntimePtySpawnState = { requestedAgentTeamsPath: string | undefined selectedCodexHomePath: string | null codexResumeHomeSelected: boolean + agentResumeUnavailable: boolean codexResumeArgvDropped: boolean skipCodexHomeEnv: boolean stripInheritedOrcaCodexHome: boolean @@ -98,6 +102,8 @@ export type RuntimePtySpawnArgs = { cwd?: string command?: string launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: StartupCommandDelivery telemetry?: { @@ -182,6 +188,7 @@ export function createRuntimePtySpawnState( requestedAgentTeamsPath: undefined, selectedCodexHomePath: null, codexResumeHomeSelected: false, + agentResumeUnavailable: false, codexResumeArgvDropped: false, skipCodexHomeEnv: false, stripInheritedOrcaCodexHome: false, diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 4758518ab66a..e75366b4adc6 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -1,4 +1,11 @@ -import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' +import { + captureSavedResumeCleanup, + admitColdRestoreReattach, + savedResumeRecord, + savedResumeResult, + resumeSpawnRefusal, + forgetRefusedResume +} from '../host-env/saved-resume-admission' import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { @@ -16,28 +23,11 @@ import { buildRuntimePtySpawnOptions } from './spawn-options' import { executeRuntimePtySpawn } from './spawn-execute' import { commitRuntimePtySpawn } from './spawn-commit' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' -import { bindResumeSpawnStart, dropResumeSpawnStart } from '../host-env/saved-resume-admission' import { commitRuntimeSpawnHiddenDelivery, releaseRuntimeSpawnPreSpawnHiddenMark } from './spawn-hidden-delivery' -function toRuntimeSpawnReply(result: { - id: string - incarnationId?: string - wslDistro?: string | null - stablePaneOwner?: { handle: string; tabId: string; leafId: string } - agentSessionEnsure?: AgentSessionClaimedSpawnResult -}) { - return { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), - ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } -} - export async function spawnPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, args: RuntimePtySpawnArgs @@ -74,11 +64,11 @@ export async function spawnPtyFromRuntimeController( const materialized = materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise if (materialized) { - return toRuntimeSpawnReply(materialized) + return { ...materialized, ...deps.runtime?.getTerminalLaunchResult?.(materialized.id) } } const earlyAdopt = await prepareRuntimePtySpawn(ctx) if (earlyAdopt) { - return toRuntimeSpawnReply(earlyAdopt) + return { ...earlyAdopt, ...deps.runtime?.getTerminalLaunchResult?.(earlyAdopt.id) } } const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => { restoreProvisionalPtySize({ @@ -90,16 +80,60 @@ export async function spawnPtyFromRuntimeController( throw error }) if (earlyReserved) { - dropResumeSpawnStart(ctx.resumeAdmission) - return toRuntimeSpawnReply(earlyReserved) + return { ...earlyReserved, ...deps.runtime?.getTerminalLaunchResult?.(earlyReserved.id) } } await executeRuntimePtySpawn(ctx) + if (!ctx.resumeAdmission) { + ctx.resumeAdmission = await admitColdRestoreReattach( + deps.runtime, + ctx.result, + { + paneKey: ctx.spawnIdentityPaneKey, + saved: savedResumeRecord(deps.store, ctx.spawnIdentityPaneKey) + }, + ctx.expectedWslDistro + ) + await captureSavedResumeCleanup( + deps.runtime, + deps.store, + ctx.spawnIdentityPaneKey, + ctx.resumeAdmission, + args.worktreeId + ) + } + if (!ctx.result.isReattach) { + Object.assign(ctx.result, savedResumeResult(ctx.resumeAdmission), { + ...(ctx.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}), + launchConfig: args.launchConfig, + launchAgent: args.launchAgent, + launchToken: args.launchToken, + resumeProviderSession: ctx.codexResumeArgvDropped ? undefined : args.resumeProviderSession + }) + } const committed = await commitRuntimePtySpawn(ctx) - bindResumeSpawnStart(deps.runtime, ctx.resumeAdmission, ctx.result) + const refusal = resumeSpawnRefusal(ctx.resumeAdmission) + forgetRefusedResume(ctx.spawnIdentityPaneKey, refusal) + if ( + args.worktreeId && + ctx.spawnIdentityPaneKey && + refusal && + refusal.verdict !== 'unverifiable' + ) { + void deps.runtime + ?.forgetRefusedSavedResume?.( + args.worktreeId, + ctx.spawnIdentityPaneKey, + refusal.refused, + refusal.sourceRecord + ) + .catch((error: unknown) => console.warn('[pty] refused resume cleanup failed', error)) + } commitRuntimeSpawnHiddenDelivery(ctx) - return toRuntimeSpawnReply(committed) + if (!ctx.result.isReattach) { + deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) + } + return committed } catch (err) { - dropResumeSpawnStart(ctx.resumeAdmission) releaseRuntimeSpawnPreSpawnHiddenMark(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index d88997c46c63..b5d591b791cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -24,6 +24,7 @@ import { isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { + startupAcquisitionRefusal, providerExitObserved, structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' @@ -122,8 +123,9 @@ function failedAcquisitionDetails( if (argumentProblem) { return { reason: 'attachFailed', argumentProblem } } - if (error instanceof AgentSessionAcquisitionRefusal) { - return { reason: error.reason, ...(error.account ? { account: error.account } : {}) } + const typed = startupAcquisitionRefusal(error) + if (typed) { + return { reason: typed.reason, ...(typed.account ? { account: typed.account } : {}) } } if (isAgentSessionPreSpawnError(error) && error.reason) { return { reason: error.reason } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index f4438f6e3a2c..82c33a5a467f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -38,7 +38,8 @@ const TYPED_START_REFUSALS = [ 'claudeAccountSetupFailed', 'agentCommandNotRunnable', 'runtimeNotInstalled', - 'conversationOpenInTerminal' + 'conversationOpenInTerminal', + 'conversationOpenElsewhere' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] @@ -71,10 +72,28 @@ export function providerExitObserved(error: unknown, depth = 0): boolean { return providerExitObserved(error.cause, depth + 1) } -/** A start that did not land. A refusal the adapter typed keeps its situation, and an exit the - * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or - * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ +/** Cleanup wrappers retain the provider's typed refusal without changing their exit proof. */ +export function startupAcquisitionRefusal( + error: unknown, + depth = 0 +): AgentSessionAcquisitionRefusal | undefined { + if (depth >= 6 || !(error instanceof Error)) { + return undefined + } + if (error instanceof AgentSessionAcquisitionRefusal) { + return error + } + return ( + startupAcquisitionRefusal(error.cause, depth + 1) ?? + (error instanceof AggregateError + ? error.errors.map((inner) => startupAcquisitionRefusal(inner, depth + 1)).find(Boolean) + : undefined) + ) +} + +/** A failed start keeps its typed refusal or observed exit and private provider diagnostic. */ export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + cause = startupAcquisitionRefusal(cause) ?? cause const codexInstallation = codexInstallationProblemOf(cause) if (codexInstallation) { return agentSessionFailureFact('startFailed', { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 501260915917..4c534eeb4c71 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -1,3 +1,5 @@ +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { expect, it, vi } from 'vitest' import type { StructuredAgentSessionProviderOpen, @@ -56,3 +58,24 @@ it('cancels the open on quit after a second client attach is refused', async () await ended } }) + +it('journals a typed writer refusal and cleans up the real starting child', async () => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('continue C') + await vi.waitFor(() => expect(open).toHaveBeenCalledOnce()) + pending.reject( + new AgentSessionAcquisitionRefusal( + 'thread C already has an active writer', + 'conversationOpenElsewhere' + ) + ) + await vi.waitFor(async () => { + const snapshot = await host.journalSnapshot(HOST_TEST_SESSION) + expect( + snapshot.submissions.some((entry) => entry.rejection?.kind === 'conversationOpenElsewhere') + ).toBe(true) + }) + expect(close).toHaveBeenCalledOnce() + expect(dispatch).not.toHaveBeenCalled() +}) diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 4bfce3084fe4..d4d1068a5db3 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from '../../shared/agent-session-resume' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' @@ -5,7 +6,7 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { TerminalOwner } from '../../shared/terminal-owner' import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' -export type PtySpawnResult = { +export type PtySpawnResult = TerminalLaunchResult & { /** Execution-host probe; absent on older hosts and reattachments. */ openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 192e58c7a683..275b7caa1aad 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -70,3 +70,52 @@ describe('host-initiated terminal creation under an attached window', () => { expect(pty?.runtimeSessionOwned).toBe(true) }) }) + +it.each(['refused', 'adopted', 'fresh-unavailable'] as const)( + 'publishes the effective %s terminal rather than the requested saved recipe', + async (kind) => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + const config = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + spawn.mockResolvedValue({ + id: 'effective-pty', + ...(kind === 'adopted' + ? { isReattach: true, launchAgent: 'codex', launchConfig: config } + : {}), + ...(kind === 'refused' + ? { + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true, + savedResumeDecision: { + agent: 'claude', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + : {}), + ...(kind === 'fresh-unavailable' + ? { + agentResumeUnavailable: true, + launchAgent: 'codex', + launchConfig: config, + launchToken: 'actual-fresh-token' + } + : {}) + }) + const result = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', { + launchAgent: 'claude', + title: 'Rejected C', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: 'C' }, + launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } + }) + expect(result.agentResumeRefused).toBe(kind === 'refused' ? 'chat-home' : undefined) + expect(result.launchAgent).toBe(kind === 'refused' ? undefined : 'codex') + expect(result.launchConfig).toEqual(kind === 'refused' ? undefined : config) + if (kind === 'refused') { + expect(result.title).not.toBe('Rejected C') + } + if (kind === 'fresh-unavailable') { + expect(result.launchToken).toBe('actual-fresh-token') + } + } +) diff --git a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts index 6e03d15180bf..93efd99d7c83 100644 --- a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts +++ b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts @@ -166,3 +166,42 @@ describe('a queued hook status and a workspace sleep', () => { }) }) }) + +it.each([false, true])( + 'orders refused cleanup after queued status and preserves newer same-conversation recipe (%s)', + async (newer) => { + const { runtime, getSession, releaseFirstWrite } = makeRuntimeWithSlowFirstWrite() + runtime.recordHookStatusResume(codexRow()) + await vi.waitFor(() => + expect(getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY]).toBeDefined() + ) + runtime.recordHookStatusResume(codexRow({ state: 'blocked', receivedAt: 11 })) + const sourcePromise = runtime.readSavedResumeSource(TEST_WORKTREE_ID, PANE_KEY) + releaseFirstWrite() + const source = await sourcePromise + if (!source) { + throw new Error('missing captured source') + } + const refused = { + agent: 'codex' as const, + providerSession: SESSION, + expectedStatus: { paneKey: PANE_KEY, receivedAt: 11, stateStartedAt: 10 }, + expectedSavedRecord: { + paneKey: PANE_KEY, + capturedAt: source.capturedAt, + updatedAt: source.updatedAt + } + } + if (newer) { + runtime.recordHookStatusResume(codexRow({ receivedAt: 30, prompt: 'new explicit C' })) + } + await runtime.forgetRefusedSavedResume(TEST_WORKTREE_ID, PANE_KEY, refused, source) + await runtime['hookStatusResumeWrites'] + const record = getSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] + if (newer) { + expect(record).toMatchObject({ prompt: 'new explicit C', updatedAt: 30 }) + } else { + expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({}) + } + } +) diff --git a/src/main/runtime/managed-conversation-starts.test.ts b/src/main/runtime/managed-conversation-starts.test.ts deleted file mode 100644 index 3b15c83d802e..000000000000 --- a/src/main/runtime/managed-conversation-starts.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { terminalConversationKey } from '../native-chat/structured-conversation-key' -import { ManagedConversationStarts, type ManagedStartPty } from './managed-conversation-starts' -import { OrcaRuntimeService } from './orca-runtime' - -const C = 'provider-session-alpha-1' -const key = terminalConversationKey('claude', C) - -describe('managed conversation starts', () => { - it('holds a start until its terminal reports the conversation or its execution ends', () => { - const starts = new ManagedConversationStarts() - const pty: ManagedStartPty = { incarnationId: null, cliConversations: undefined, exited: false } - const read = vi.fn((): ManagedStartPty | null => pty) - const reported = starts.reserve([key]) - expect(starts.live(key, read)).toBe(true) - reported.bind('pty-1', null) - expect(starts.live(key, read)).toBe(true) - pty.cliConversations = { - incarnationId: null, - byKey: new Map([['k', { agent: 'claude', id: C, paneOwned: true }]]) - } - expect(starts.live(key, read)).toBe(false) - - starts.reserve([terminalConversationKey('opencode2', null)]).bind('pty-2', null) - // A targetless resume holds the whole family; a gone PTY ends it. - expect( - starts.live(terminalConversationKey('opencode', 'ses_1'), () => ({ - ...pty, - cliConversations: undefined - })) - ).toBe(true) - expect(starts.live(terminalConversationKey('opencode', 'ses_1'), () => null)).toBe(false) - }) - - it("refuses a chat's agent start while a start holds its conversation, never for its own hook row", async () => { - const runtime = new OrcaRuntimeService() - const record = agentSessionRecordFixture() - const ownRow = { - paneKey: 'chat', - agentType: 'claude', - structuredHost: 'owned', - providerSession: { key: 'claude', id: C } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only these row fields. - const rows = [ownRow as unknown as AgentStatusIpcPayload] - vi.spyOn(runtime, 'getAgentProviderSessionRows').mockReturnValue(rows) - const admit = () => runtime['assertStructuredChatAcquireAdmissible'](record) - await expect(admit()).resolves.toBeUndefined() - - const start = runtime.reserveManagedConversationStart([key]) - await expect(admit()).rejects.toMatchObject({ - refusal: { details: { reason: 'conversationHeldElsewhere' } } - }) - // Bound to a PTY this host doesn't run, the start can't write here. - runtime.bindManagedConversationStart(start, 'pty-missing') - await expect(admit()).resolves.toBeUndefined() - }) -}) diff --git a/src/main/runtime/managed-conversation-starts.ts b/src/main/runtime/managed-conversation-starts.ts deleted file mode 100644 index b2bb8a0ecab6..000000000000 --- a/src/main/runtime/managed-conversation-starts.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { - keysMayMatch, - terminalConversationKey, - type ConversationKey -} from '../native-chat/structured-conversation-key' - -/** A terminal start of a conversation Orca issued itself, which a chat's agent start must not race. */ -export type ManagedConversationStart = { - /** Ties the start to the PTY execution running it; until then it holds unconditionally. */ - bind: (ptyId: string, incarnationId: PtyIncarnationId | null) => void - drop: () => void -} - -export type ManagedStartPty = Pick< - RuntimePtyWorktreeRecord, - 'incarnationId' | 'cliConversations' -> & { - exited: boolean -} - -type Entry = { - keys: readonly ConversationKey[] - pty: { ptyId: string; incarnationId: PtyIncarnationId | null } | null -} - -/** - * In memory only: a host restart loses every entry, and a restored pane then goes through the - * automatic-home guard instead. No timer ends an entry; only re-derived truth does: its terminal's - * hooks reported the conversation (the census takes over), or its PTY execution is gone. - */ -export class ManagedConversationStarts { - private readonly entries = new Set() - - /** Synchronous, so a caller's admission check and this registration are one step. */ - reserve(keys: readonly ConversationKey[]): ManagedConversationStart { - const entry: Entry = { keys, pty: null } - this.entries.add(entry) - return { - bind: (ptyId, incarnationId) => { - if (this.entries.has(entry)) { - entry.pty = { ptyId, incarnationId } - } - }, - drop: () => { - this.entries.delete(entry) - } - } - } - - /** Whether a managed start may still be writing `key`'s conversation; ends those that can't. */ - live(key: ConversationKey, readPty: (ptyId: string) => ManagedStartPty | null): boolean { - let live = false - for (const entry of this.entries) { - if (entry.pty && ended(entry, readPty(entry.pty.ptyId))) { - this.entries.delete(entry) - } else if (entry.keys.some((held) => keysMayMatch(held, key))) { - live = true - } - } - return live - } -} - -function ended(entry: Entry, pty: ManagedStartPty | null): boolean { - if (!pty || pty.exited || pty.incarnationId !== entry.pty?.incarnationId) { - return true - } - const reported = - pty.cliConversations?.incarnationId === pty.incarnationId - ? [...pty.cliConversations.byKey.values()] - : [] - return reported.some((conversation) => - entry.keys.some((held) => - keysMayMatch(terminalConversationKey(conversation.agent, conversation.id), held) - ) - ) -} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 7eec6432b222..11440e01fe1b 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -19,7 +19,6 @@ import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta' import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' -import { terminalConversationKey } from '../../../native-chat/structured-conversation-key' import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params' import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { createSessionTabsTabDeltaEncoder } from '../../../../shared/session-tabs-tab-delta' @@ -58,22 +57,12 @@ export const SESSION_TAB_METHODS = [ params: CreateTerminalTab, handler: async (params, context) => { const { runtime, signal, clientKind, pairedDeviceId, clientCapabilities } = context - // With a resume session the spawn step registers the start; a bare command registers here. - const start = params.command - ? await assertLegacyAiVaultResumeCommandAllowed( - params.command, - () => runtime.ensureStructuredAgentSessionHost(), - params.resumeProviderSession - ? undefined - : (invocations) => - runtime.reserveManagedConversationStart( - invocations.map((invocation) => - terminalConversationKey(invocation.provider, invocation.target) - ) - ) - ) - : undefined - const created = runtime.createMobileSessionTerminal(params.worktree, { + if (params.command) { + await assertLegacyAiVaultResumeCommandAllowed(params.command, () => + runtime.ensureStructuredAgentSessionHost() + ) + } + return runtime.createMobileSessionTerminal(params.worktree, { ...(params.sourceChatFork ? { sourceChatFork: params.sourceChatFork } : {}), terminalImageViewerSupported: terminalImageViewerSupported(context), afterTabId: params.afterTabId, @@ -113,19 +102,6 @@ export const SESSION_TAB_METHODS = [ // of running down the timeout and rolling back a live tab (#7718). signal }) - // A renderer-backed create has no live PTY yet, so its start isn't held (a known gap). - return start - ? created.then( - (result) => { - runtime.bindManagedConversationStartToTerminal(start, result.tab.terminal) - return result - }, - (error: unknown) => { - start.drop() - throw error - } - ) - : created } }), defineStreamingMethod({ diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 5a9e8d29568e..f162192474de 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,11 +1,12 @@ +import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' import { InvalidArgumentError, defineMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { isUntypedTerminalInput } from '../../../../../shared/terminal-untyped-input' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' import { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params' -import { withTerminalSendResumeStarts } from './terminal-send-resume-starts' import { assertTerminalSendExactPtyBinding, + assertTerminalSendTextWithinLimit, commitMobileInputFloorClaim, getTerminalSendGuardRefusedReason, isTerminalInputLockedForClient, @@ -36,6 +37,18 @@ export const TERMINAL_SEND_METHODS = [ replayedMutationReceipt } ) => { + await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + if (params.text) { + await assertLegacyAiVaultResumeCommandAllowed(params.text, () => + runtime.ensureStructuredAgentSessionHost() + ) + } + if (params.resolvedLaunchDraft?.text) { + await assertLegacyAiVaultResumeCommandAllowed(params.resolvedLaunchDraft.text, () => + runtime.ensureStructuredAgentSessionHost() + ) + } const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -291,4 +304,4 @@ export const TERMINAL_SEND_METHODS = [ return { send: result } } }) -].map(withTerminalSendResumeStarts) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts deleted file mode 100644 index f2192e6a38d4..000000000000 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-resume-starts.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' -import { terminalConversationKey } from '../../../../native-chat/structured-conversation-key' -import type { TerminalSend } from '../../../../../shared/rpc-contract/terminal-unary-params' -import type { ManagedConversationStart } from '../../../managed-conversation-starts' -import type { RpcContext, RpcTypedMethod } from '../../core' -import { assertTerminalSendTextWithinLimit } from './terminal-input-delivery' - -/** A send whose text resumes a conversation refuses one a chat holds; otherwise it registers the - * start in the same step, bound to the PTY it writes, and drops it when nothing was written. */ -export function withTerminalSendResumeStarts( - method: RpcTypedMethod> -): RpcTypedMethod> { - const send = method.handler - return { - ...method, - handler: async (params, context) => { - const { runtime } = context - await assertTerminalSendTextWithinLimit(params.text) - await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) - const starts: ManagedConversationStart[] = [] - try { - for (const text of [params.text, params.resolvedLaunchDraft?.text]) { - const start = text - ? await assertLegacyAiVaultResumeCommandAllowed( - text, - () => runtime.ensureStructuredAgentSessionHost(), - (invocations) => - runtime.reserveManagedConversationStart( - invocations.map((invocation) => - terminalConversationKey(invocation.provider, invocation.target) - ), - livePtyId(context, params.terminal) - ) - ) - : undefined - if (start) { - starts.push(start) - } - } - const result = await send(params, context) - if (!sendAccepted(result)) { - starts.forEach((start) => start.drop()) - } - return result - } catch (error) { - starts.forEach((start) => start.drop()) - throw error - } - } - } -} - -// The send itself reports a stale handle; here it only means nothing to bind. -function livePtyId({ runtime }: RpcContext, handle: string): string | null { - try { - return runtime.resolveLiveLeafForHandle(handle)?.ptyId ?? null - } catch { - return null - } -} - -function sendAccepted(result: unknown): boolean { - return ( - typeof result === 'object' && - result !== null && - 'send' in result && - typeof result.send === 'object' && - result.send !== null && - 'accepted' in result.send && - result.send.accepted === true - ) -} diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index e29889032ecf..063463ae7374 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -5,7 +5,6 @@ import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { - AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' @@ -63,6 +62,8 @@ export type RuntimePtyController = { cwd?: string command?: string launchAgent?: TuiAgent + launchConfig?: WorktreeStartupLaunch['launchConfig'] + launchToken?: string commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record @@ -108,14 +109,11 @@ export type RuntimePtyController = { } materialized?: true } - }): Promise<{ - id: string - pid?: number | null - incarnationId?: PtyIncarnationId - wslDistro?: string - stablePaneOwner?: { handle: string; tabId: string; leafId: string } - agentSessionEnsure?: AgentSessionClaimedSpawnResult - }> + }): Promise< + PtySpawnResult & { + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + } + > /** The local terminal owner's automatic-restore state; null when it has no fork gate. */ getAutomaticWorkStatus?(): Promise resumeAutomaticWork?(): Promise diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index ec81bed136c9..2e1fd1a1fc96 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from '../../shared/agent-session-resume' import type { AgentStatus } from '../../shared/agent-detection' import type { AgentStatusState } from '../../shared/agent-status-types' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' @@ -64,6 +65,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { * pty-recorded-surface-topology.ts. */ surfaceRecordedAtGraphSequence: number + savedTerminalLaunchResult?: { + incarnationId: PtyIncarnationId | null + launchConfig: SleepingAgentLaunchConfig | null + launchToken: string | null + result: TerminalLaunchResult + } launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null launchIncarnationId: PtyIncarnationId | null diff --git a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts index cd2748e1cc31..16ef05a52d5c 100644 --- a/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts +++ b/src/main/runtime/session-construction/agent-session/get-agent-session-execution-namespace.ts @@ -164,6 +164,7 @@ export abstract class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends O launchAgent: request.agent, // Why: like every host-built resume, the spawn names its session so readers can address it. resumeProviderSession: identity.providerSession, + ...(request.resumeOrigin ? { resumeOrigin: request.resumeOrigin } : {}), terminalImageViewerSupported: _caller.terminalImageViewerSupported, launchRuntime, terminalKittyKeyboardProtocol: request.terminalKittyKeyboardProtocol, diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index ada5f7b3f2aa..7d753d61c15c 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -1,3 +1,7 @@ +import { + terminalLaunchFacts, + type TerminalLaunchResult +} from '../../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' @@ -18,61 +22,37 @@ import { } from '../../agent-hooks/server/cli-writer-observation' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' -import { - recordConversationKey, - type ConversationKey -} from '../../native-chat/structured-conversation-key' -import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { - ManagedConversationStarts, - type ManagedConversationStart, - type ManagedStartPty -} from '../managed-conversation-starts' +import { recordConversationKey } from '../../native-chat/structured-conversation-key' export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { - private readonly managedConversationStarts = new ManagedConversationStarts() - - /** Registers a terminal start Orca issues of `keys`; `ptyId` binds it now, else its spawn does. */ - reserveManagedConversationStart( - keys: readonly ConversationKey[], - ptyId?: string | null - ): ManagedConversationStart { - const start = this.managedConversationStarts.reserve(keys) - if (ptyId !== undefined) { - this.bindManagedConversationStart(start, ptyId) - } - return start - } - - bindManagedConversationStart(start: ManagedConversationStart, ptyId: string | null): void { - const pty = ptyId ? this.ptysById.get(ptyId) : undefined - // Only this host's own panes write its chats' conversations; a remote or WSL pane's is another's. - if (!pty || pty.connectionId || pty.isWsl) { - start.drop() - return + rememberSavedTerminalLaunchResult(ptyId: string, result: TerminalLaunchResult): void { + const pty = this.ptysById.get(ptyId) + if (pty && result.savedResumeDecision) { + pty.savedTerminalLaunchResult = { + incarnationId: pty.incarnationId, + launchConfig: pty.launchConfig, + launchToken: pty.launchToken, + result: terminalLaunchFacts(result) + } } - start.bind(pty.ptyId, pty.incarnationId) } - bindManagedConversationStartToTerminal( - start: ManagedConversationStart, - handle: string | null | undefined - ): void { - this.bindManagedConversationStart( - start, - (handle && this.getLivePtyForHandle(handle)?.pty.ptyId) || null - ) - } - - private managedStartPty(ptyId: string): ManagedStartPty | null { + getTerminalLaunchResult(ptyId: string): TerminalLaunchResult | undefined { const pty = this.ptysById.get(ptyId) - return pty - ? { - incarnationId: pty.incarnationId, - cliConversations: pty.cliConversations, - exited: this.getPtyLivenessVerdict(ptyId)?.status === 'exited' - } - : null + const saved = pty?.savedTerminalLaunchResult + if (!pty) { + return undefined + } + return terminalLaunchFacts({ + ...(saved?.incarnationId === pty.incarnationId && + saved.launchConfig === pty.launchConfig && + saved.launchToken === pty.launchToken + ? saved.result + : {}), + launchAgent: pty.launchAgent ?? undefined, + launchToken: pty.launchToken ?? undefined, + launchConfig: pty.launchConfig ?? undefined + }) } private cliWriterPtyPane(ptyId: string | null | undefined): CliWriterPane | null { @@ -111,6 +91,9 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca if (!agent || !binding || !pty) { return } + if (event.agentPresence?.process) { + delete pty.savedTerminalLaunchResult + } if (pty.cliConversations?.incarnationId !== pty.incarnationId) { pty.cliConversations = { incarnationId: pty.incarnationId, byKey: new Map() } } @@ -138,12 +121,6 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca if (!key?.id || !adopts) { return } - // A start Orca issued that its terminal's hooks haven't reported yet: the census can't see it. - if (this.managedConversationStarts.live(key, (ptyId) => this.managedStartPty(ptyId))) { - throw agentSessionRefusalError('agent_session_conflict', { - reason: 'conversationHeldElsewhere' - }) - } await this.assertStructuredAdoptionTerminalWritersExited(record.provider, key.id) } diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index a78ded3413d2..0718b0ae46dc 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -228,8 +228,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext incarnationId: livePty.pty.incarnationId, title: terminal.title ?? livePty.pty.title ?? 'Terminal', ...(cwd ? { startupCwd: cwd } : {}), - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), + ...(livePty.pty.launchAgent ? { launchAgent: livePty.pty.launchAgent } : {}), + ...(!terminal.agentResumeRefused && opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index aec82277045e..85b69952849b 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -1,3 +1,4 @@ +import { recordTerminalLaunchMetadata, resolveTerminalLaunchResult } from './terminal-launch-result' import { OrcaRuntimeWithTerminalCreateDeduplication } from './terminal-create-deduplication' import * as dependencies from '../../orca-runtime-create-terminal-dependencies' import { createDesktopTerminal } from '../../orca-runtime-create-terminal-desktop' @@ -138,6 +139,8 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi ? launchOpts.command : (agentTeamsPlan?.command ?? launchOpts.command), launchAgent: launchOpts.launchAgent, + launchConfig: effectiveLaunchConfig, + ...(launchToken ? { launchToken } : {}), commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, env, @@ -193,17 +196,13 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi } restoreHint.consume() // Why here: refused before a handle, snapshot or reveal could pose the live PTY as new. - const adoptedStablePane = dependencies.admitStablePaneAdoption(result, launchOpts) - if (result.agentSessionEnsure) { - const canonicalSurface = result.agentSessionEnsure.owner.surface - preAllocatedHandle = canonicalSurface.terminalHandle - tabId = canonicalSurface.tabId - leafId = canonicalSurface.leafId - paneKey = dependencies.makePaneKey(tabId, leafId) - } else if (result.stablePaneOwner) { - preAllocatedHandle = result.stablePaneOwner.handle - tabId = result.stablePaneOwner.tabId - leafId = result.stablePaneOwner.leafId + const launch = resolveTerminalLaunchResult(result, { + ...launchOpts, + launchConfig: effectiveLaunchConfig, + launchToken + }) + if (launch.surface) { + ;({ terminalHandle: preAllocatedHandle, tabId, leafId } = launch.surface) paneKey = dependencies.makePaneKey(tabId, leafId) } try { @@ -227,34 +226,32 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { pty.runtimeSessionOwned = true - if (!adoptedStablePane) { - if (launchOpts.title) { + if (!launch.adoptedStablePane) { + if (launch.effectiveTitle) { const observedAt = this.nextTitleObservationSequence() - pty.title = launchOpts.title + pty.title = launch.effectiveTitle pty.titleUpdatedAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt) + this.setPtyManagementTitleFromObservedTitle(pty, launch.effectiveTitle, observedAt) } else { pty.title = null pty.titleUpdatedAt = null } - pty.launchConfig = effectiveLaunchConfig - ? dependencies.copySleepingAgentLaunchConfig(effectiveLaunchConfig) - : null - pty.launchToken = launchToken ?? null - pty.launchIncarnationId = launchToken ? pty.incarnationId : null - pty.launchAgent = launchOpts.launchAgent ?? null + } + recordTerminalLaunchMetadata(pty, result, launch) + if (!launch.adoptedStablePane) { + this.rememberSavedTerminalLaunchResult(result.id, result) } recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)) } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle - if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { + if (pty && !launch.adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { tabId, leafId, - title: launchOpts.title ?? null, + title: launch.effectiveTitle ?? null, activate: presentation === 'focused', selectIfNoActiveTab: presentation !== 'background', - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + ...(launch.effectiveViewMode ? { viewMode: launch.effectiveViewMode } : {}), ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } @@ -264,12 +261,12 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi try { await this.notifier.revealTerminalSession(workspace.id, { ptyId: result.id, - title: launchOpts.title ?? null, + title: pty?.title ?? launch.effectiveTitle ?? null, ...(cwd !== workspace.path ? { cwd } : {}), - ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), - ...(launchToken ? { launchToken } : {}), - ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), - ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), + ...(pty?.launchConfig ? { launchConfig: pty.launchConfig } : {}), + ...(pty?.launchToken ? { launchToken: pty.launchToken } : {}), + ...(pty?.launchAgent ? { launchAgent: pty.launchAgent } : {}), + ...(launch.effectiveViewMode ? { viewMode: launch.effectiveViewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), ...dependencies.terminalRevealViewerOptions(opts), @@ -285,19 +282,23 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi warning = dependencies.createTerminalRevealWarning(handle) } return { + ...result, handle, tabId, paneKey, ptyId: result.id, worktreeId: workspace.id, - title: pty?.title ?? launchOpts.title ?? null, + title: pty?.title ?? launch.effectiveTitle ?? null, + launchConfig: pty?.launchConfig ?? undefined, + launchAgent: pty?.launchAgent ?? undefined, + launchToken: pty?.launchToken ?? undefined, ...this.getPtyExecutionHostMetadata(result.id), surface, ...(result.pid ? { processId: result.pid } : {}), ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), - ...(adoptedStablePane ? { isReattach: true as const } : {}), + ...(launch.adoptedStablePane ? { isReattach: true as const } : {}), ...(warning ? { warning } : {}) } } finally { diff --git a/src/main/runtime/terminal/launch/terminal-launch-result.ts b/src/main/runtime/terminal/launch/terminal-launch-result.ts new file mode 100644 index 000000000000..815330ec115c --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-launch-result.ts @@ -0,0 +1,66 @@ +import { admitStablePaneAdoption } from '../../runtime-terminal-pane-identity' +import type { RuntimePtyController } from '../../runtime-pty-controller-contract' +import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' +import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' +import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' + +type TerminalSpawnResult = Awaited>> + +export function resolveTerminalLaunchResult( + result: TerminalSpawnResult, + requested: Pick< + TerminalCreateOptions, + 'launchConfig' | 'launchAgent' | 'launchToken' | 'title' | 'viewMode' | 'requireFreshPane' + > +) { + const stablePaneAdopted = admitStablePaneAdoption(result, requested) + const adoptedStablePane = + stablePaneAdopted || + result.isReattach === true || + result.agentSessionEnsure?.disposition === 'adopted' + const refused = Boolean(result.agentResumeRefused) + const owner = result.stablePaneOwner + const surface = + result.agentSessionEnsure?.owner.surface ?? + (owner ? { terminalHandle: owner.handle, tabId: owner.tabId, leafId: owner.leafId } : undefined) + return { + adoptedStablePane, + surface, + launchAgent: refused + ? undefined + : (result.launchAgent ?? (adoptedStablePane ? undefined : requested.launchAgent)), + launchConfig: refused + ? undefined + : (result.launchConfig ?? (adoptedStablePane ? undefined : requested.launchConfig)), + launchToken: refused + ? undefined + : (result.launchToken ?? (adoptedStablePane ? undefined : requested.launchToken)), + effectiveTitle: refused || adoptedStablePane ? null : requested.title, + effectiveViewMode: refused || adoptedStablePane ? undefined : requested.viewMode + } +} + +export function recordTerminalLaunchMetadata( + pty: RuntimePtyWorktreeRecord, + result: TerminalSpawnResult, + launch: ReturnType +): void { + if (launch.adoptedStablePane) { + // A lower owner can supply facts before this runtime has a launch record. + if (!pty.launchConfig && result.launchConfig) { + pty.launchConfig = copySleepingAgentLaunchConfig(result.launchConfig) + } + pty.launchToken ??= result.launchToken ?? null + pty.launchAgent ??= result.launchAgent ?? null + if (pty.launchToken && !pty.launchIncarnationId) { + pty.launchIncarnationId = pty.incarnationId + } + } else { + pty.launchConfig = launch.launchConfig + ? copySleepingAgentLaunchConfig(launch.launchConfig) + : null + pty.launchToken = launch.launchToken ?? null + pty.launchIncarnationId = launch.launchToken ? pty.incarnationId : null + pty.launchAgent = launch.launchAgent ?? null + } +} diff --git a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts index 5d752821f85e..3c5206d37112 100644 --- a/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts +++ b/src/main/runtime/terminal/stop/worktree-sleep-resume-recording.ts @@ -1,3 +1,4 @@ +import { sleepingRecordsEquivalentIgnoringCaptureTime } from '../../../../shared/agent-resume-record-equivalence' import { OrcaRuntimeWithStopTerminalsForWorktree } from './worktree-terminal-stop' import { applyWorktreeSleepResumeRecords, @@ -28,27 +29,64 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switc import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRuntimeWithStopTerminalsForWorktree { + /** Capture after earlier status writes so their saved copy participates in exact cleanup. */ + readSavedResumeSource( + worktreeId: string, + paneKey: string + ): Promise { + return this.inResumeRecordOrder( + async () => + this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) + ?.sleepingAgentSessionsByPaneKey?.[paneKey] + ) + } + /** Forgets a pane's saved resume the host refused because a chat is its conversation's home, * as a window does for its own panes; a host with no window has no one else to. */ async forgetRefusedSavedResume( worktreeId: string, paneKey: string, - refused: RefusedPaneResume + refused: RefusedPaneResume, + source?: SleepingAgentSessionRecord ): Promise { - await this.commitWorkspaceSessionEdit( - this.getWorkspaceSessionHostIdForWorktree(worktreeId), - (session) => { - const saved = session.sleepingAgentSessionsByPaneKey?.[paneKey] - if ( - saved?.agent !== refused.agent || - !agentProviderSessionsEqual(refused.agent, saved.providerSession, refused.providerSession) - ) { - return null + await this.inResumeRecordOrder(() => + this.commitWorkspaceSessionEdit( + this.getWorkspaceSessionHostIdForWorktree(worktreeId), + (session) => { + const saved = session.sleepingAgentSessionsByPaneKey?.[paneKey] + if ( + !source || + !refused.expectedSavedRecord || + saved?.paneKey !== refused.expectedSavedRecord.paneKey || + saved.capturedAt !== refused.expectedSavedRecord.capturedAt || + saved.updatedAt !== refused.expectedSavedRecord.updatedAt || + saved.connectionId !== source.connectionId || + !sleepingRecordsEquivalentIgnoringCaptureTime(saved, source) || + saved.agent !== refused.agent || + !agentProviderSessionsEqual( + refused.agent, + saved.providerSession, + refused.providerSession + ) + ) { + return null + } + const { [paneKey]: _refused, ...kept } = session.sleepingAgentSessionsByPaneKey ?? {} + return { ...session, sleepingAgentSessionsByPaneKey: kept } } - const { [paneKey]: _refused, ...kept } = session.sleepingAgentSessionsByPaneKey ?? {} - return { ...session, sleepingAgentSessionsByPaneKey: kept } - } + ) ) + const previous = this.previousHookStatusByPaneKey.get(paneKey) + if ( + previous && + refused.expectedStatus && + previous.updatedAt <= refused.expectedStatus.receivedAt && + previous.stateStartedAt === refused.expectedStatus.stateStartedAt && + previous.agentType === refused.agent && + agentProviderSessionsEqual(refused.agent, previous.providerSession, refused.providerSession) + ) { + this.previousHookStatusByPaneKey.delete(paneKey) + } } protected readonly sleepResumeRecords: WorktreeSleepResumeRecorder = { @@ -85,7 +123,10 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu } /** Each pane's last hook status, which a status without a provider session inherits it from. */ - private readonly previousHookStatusByPaneKey = new Map() + private readonly previousHookStatusByPaneKey = new Map< + string, + ResumeRecordStatus & { stateStartedAt?: number } + >() private hookStatusResumeWrites: Promise = Promise.resolve() private readonly paneLaunchConfigOf: PaneLaunchConfigOf = (paneKey, agent) => { @@ -107,7 +148,10 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu if (!status) { return } - this.previousHookStatusByPaneKey.set(row.paneKey, status) + this.previousHookStatusByPaneKey.set(row.paneKey, { + ...status, + stateStartedAt: row.stateStartedAt + }) this.inResumeRecordOrder(() => this.writeHookStatusResume(status)).catch((error) => console.warn('[runtime] could not record an agent resume record', error) ) @@ -127,7 +171,19 @@ export abstract class OrcaRuntimeWithWorktreeSleepResumeRecording extends OrcaRu forgetHookStatusResume(clear: AgentStatusClearIpcPayload): void { for (const [paneKey, status] of this.previousHookStatusByPaneKey) { if ( - 'paneKey' in clear ? paneKey === clear.paneKey : status.connectionId === clear.connectionId + 'paneKey' in clear + ? paneKey === clear.paneKey && + (!clear.refusedResume || + (!!clear.refusedResume.expectedStatus && + status.updatedAt <= clear.refusedResume.expectedStatus.receivedAt && + status.stateStartedAt === clear.refusedResume.expectedStatus.stateStartedAt && + status.agentType === clear.refusedResume.agent && + agentProviderSessionsEqual( + status.agentType, + status.providerSession, + clear.refusedResume.providerSession + ))) + : status.connectionId === clear.connectionId ) { this.previousHookStatusByPaneKey.delete(paneKey) } diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 20039e7c0fa2..afa346330e7a 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -1,3 +1,4 @@ +import type { SavedResumeDecision, TerminalLaunchResult } from '../../shared/agent-session-resume' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { AgentProviderSessionMetadata, @@ -90,6 +91,10 @@ export type PtyApi = { startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + savedResumeDecision?: SavedResumeDecision + refusedResume?: TerminalLaunchResult['refusedResume'] + launchToken?: string + resumeProviderSession?: TerminalLaunchResult['resumeProviderSession'] /** 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 872c560f98c1..0a32ecaae9a2 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,3 +1,4 @@ +import type { SavedResumeDecision, TerminalLaunchResult } from '../../shared/agent-session-resume' import type { SpawnIntent } from '../../shared/host-spawn-admission' import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' @@ -67,6 +68,7 @@ export const ptySessionControlApi = { /** Which lifetime of `id` this reply named; absent when the execution host predates the field. */ incarnationId?: string launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent snapshot?: string snapshotCols?: number snapshotRows?: number @@ -84,6 +86,10 @@ export const ptySessionControlApi = { startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + savedResumeDecision?: SavedResumeDecision + refusedResume?: TerminalLaunchResult['refusedResume'] + launchToken?: string + resumeProviderSession?: TerminalLaunchResult['resumeProviderSession'] /** 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/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index ccfb02113db0..5801e9b82d26 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -142,6 +142,11 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.conversationOpenElsewhere', + COPY.conversationOpenElsewhere + ), conversationOpenInTerminal: (values) => translate( 'components.native-chat.failureWords.conversationOpenInTerminal', 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 4d2df79e4753..ad2e995c3c3e 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 @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { PtyConnectResult } from './pty-transport-types' @@ -6,9 +7,12 @@ type IpcPtySpawnResult = PtyConnectResult & { isReattach?: boolean } export function projectIpcPtyConnectResult( spawnResult: IpcPtySpawnResult ): string | PtyConnectResult { + const facts = terminalLaunchFacts(spawnResult) const launchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) { return { + ...facts, + ...(spawnResult.incarnationId ? { incarnationId: spawnResult.incarnationId } : {}), id: spawnResult.id, ...(spawnResult.isReattach ? { isReattach: true } : {}), ...(launchAgent ? { launchAgent } : {}), @@ -47,9 +51,14 @@ export function projectIpcPtyConnectResult( launchAgent || spawnResult.launchConfig || spawnResult.startupCwdFallback || - spawnResult.agentResumeUnavailable + spawnResult.agentResumeUnavailable || + spawnResult.savedResumeDecision || + spawnResult.refusedResume || + spawnResult.launchToken ) { return { + ...facts, + ...(spawnResult.incarnationId ? { incarnationId: spawnResult.incarnationId } : {}), id: spawnResult.id, ...(launchAgent ? { launchAgent } : {}), ...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}), 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 90bf58eab65c..cc8202c5d135 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { readIpcErrorDetail } from '@/lib/ipc-error' @@ -138,6 +139,7 @@ export async function connectIpcPty( return } return { + ...terminalLaunchFacts(spawnResult), id: spawnResult.id, exitedBeforeAttach: true, // Why kept: a shell that replaced a resume and then exited never resumed the agent, and a diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index c0dd05bf31c4..6d2fb3583ffe 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -1,3 +1,4 @@ +import type { SavedResumeStartupSource } from '../../../../shared/agent-session-resume' import type { WorkspaceAttentionSlice } from '@/store/slices/workspace-attention/workspace-attention-contract' import type { NotificationWorkspaceOwner } from '../../../../shared/notification-source' import type { PtyTransport, PtyTransportRecoveryState } from './pty-transport-types' @@ -31,6 +32,7 @@ export type PtyPaneStartup = { resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: a resume built from saved facts, which the host refuses for a chat's conversation. */ resumeOrigin?: 'saved' + resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ 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 9cefc7b9f62d..9e72b3a717e0 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 @@ -1,3 +1,4 @@ +import { agentProviderSessionsEqual } from '../../../../../shared/agent-session-resume' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { @@ -264,11 +265,33 @@ export function createReattachPayloadHandlers( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) // Why not rebuilt when refused: main started a plain shell instead of that plan. - const preparedStartup = ctx.connectResult.agentResumeRefused - ? null - : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) + let resumeUnavailable = Boolean(ctx.connectResult.agentResumeUnavailable) + let preparedStartup = + ctx.connectResult.agentResumeRefused || resumeUnavailable + ? null + : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) + if (preparedStartup && !ctx.coldRestoreStartup) { + const decision = ctx.connectResult.savedResumeDecision + if ( + !decision || + decision.verdict !== 'admitted' || + decision.agent !== preparedStartup.agent || + !agentProviderSessionsEqual( + decision.agent, + decision.providerSession, + preparedStartup.resumeProviderSession + ) + ) { + resumeUnavailable = true + session.cancelSavedResumeStartup(preparedStartup, { + ...ctx.connectResult, + agentResumeRefused: 'unverifiable' + }) + preparedStartup = null + } + } const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) - if (ctx.connectResult.agentResumeUnavailable) { + if (resumeUnavailable) { // 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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 30e84152df53..f6f11972f8c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -110,6 +110,23 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe launchConfig: startupPlan.launchConfig, resumeProviderSession: providerSession, resumeOrigin: 'saved', + resumeSource: { + cancelled: false, + ...(sleepingRecord && + sleepingRecord.agent === agent && + agentProviderSessionsEqual(agent, sleepingRecord.providerSession, providerSession) + ? { record: sleepingRecord } + : {}), + ...(useLiveEntry && entry + ? { + expectedStatus: { + paneKey: session.cacheKey, + receivedAt: entry.updatedAt, + stateStartedAt: entry.stateStartedAt + } + } + : {}) + }, launchToken: coldRestoreLaunchToken, useLiveEntry: Boolean(useLiveEntry), hasSleepingRecord: Boolean(sleepingRecord), diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 8454831878ee..bd03d602da44 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -150,6 +150,9 @@ export function connectPanePty( // own outer reference, since `deps` here is a shallow copy and our // mutation does not propagate back. session.paneStartup = session.deps.startup ?? null + if (session.paneStartup?.resumeOrigin === 'saved') { + session.paneStartup.resumeSource ??= { cancelled: false } + } session.deps.startup = undefined // Why the session holds it until a spawn request carries it: the pane already dropped every other // reference to this PTY, so the stop is owed until main takes it or dispose kills it. 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 3e7469b60927..928e135bd53c 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 @@ -26,10 +26,16 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio session.applyColdRestoreAgentResumeStartup = ( startup: ColdRestoreAgentResumeStartup | null ): boolean => { - if (!startup) { + if (!startup || startup.resumeSource?.cancelled) { return false } const state = useAppStore.getState() + if (!session.pendingStartupCommand && session.shouldDeliverStartupViaTerminalPaste) { + session.pendingStartupCommand = { + command: startup.command, + resumeSource: startup.resumeSource + } + } state.registerAgentLaunchConfig(session.cacheKey, startup.launchConfig, { agentType: startup.agent, launchToken: startup.launchToken, @@ -74,6 +80,9 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio startup: ColdRestoreAgentResumeStartup | null = session.buildColdRestoreAgentResumeStartup(), options: FreshSpawnOptions = {} ): Promise => { + if (startup?.resumeSource?.cancelled) { + return Promise.resolve(null) + } session.applyColdRestoreAgentResumeStartup(startup) const spawned = session.startFreshSpawn(startup, options) // Switch point: main's layout shows this pane unbound while the start runs. @@ -96,7 +105,10 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio !session.disposed && session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport && session.transport.getPtyId() === ptyId - const runTerminalPasteStartupCommand = async (command: string): Promise => { + const runTerminalPasteStartupCommand = async ( + command: string, + current: () => boolean + ): Promise => { const ptyId = session.transport.getPtyId() const result = await executeTerminalStartupCommandPaste({ command, @@ -109,43 +121,52 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio isWindowsConpty: session.isNativeWindowsConpty }), transport: session.transport, - isTargetCurrent: isStartupPasteTargetCurrent + isTargetCurrent: (target) => current() && isStartupPasteTargetCurrent(target) }) - if (result.status !== 'pasted' || !isStartupPasteTargetCurrent(ptyId)) { + if (result.status !== 'pasted' || !current() || !isStartupPasteTargetCurrent(ptyId)) { return false } return session.transport.sendInput('\r', 'launch') } session.schedulePendingStartupCommandDelivery = (): void => { const startup = session.pendingStartupCommand - if (!startup) { + if (!startup || startup.resumeSource?.cancelled) { return } if (session.startupInjectTimer !== null) { clearTimeout(session.startupInjectTimer) } + const generation = session.transportStreamGeneration + const isCurrent = (): boolean => + !session.disposed && + session.pendingStartupCommand === startup && + !startup.resumeSource?.cancelled && + generation === session.transportStreamGeneration && + (startup.isCurrent?.() ?? true) session.startupInjectTimer = setTimeout(() => { session.startupInjectTimer = null void (async () => { - if (session.pendingStartupCommand !== startup || session.disposed) { + if (!isCurrent()) { return } if (session.shouldDeliverStartupViaTerminalPaste) { await waitForTerminalOutputParsed(session.pane.terminal) } - if (session.pendingStartupCommand !== startup || session.disposed) { + if (!isCurrent()) { return } const command = startup.command const submitted = session.shouldDeliverStartupViaTerminalPaste - ? await runTerminalPasteStartupCommand(command) + ? await runTerminalPasteStartupCommand(command, isCurrent) : session.transport.sendInput(`${command}\r`, 'launch') if (submitted) { session.armStartupDraftReadinessObservation() } else { session.releaseUnattemptedStartupDraftPasteDelivery() } - session.pendingStartupCommand = null + if (session.pendingStartupCommand === startup) { + session.pendingStartupCommand = null + } })() }, 50) } 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 5b29f012ac5f..8d56d0b6f0e5 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 @@ -108,6 +108,7 @@ function connectDeferredSessionReattach( ...(coldRestoreStartup?.resumeProviderSession ? { resumeProviderSession: coldRestoreStartup.resumeProviderSession } : {}), + ...(coldRestoreStartup ? { resumeOrigin: coldRestoreStartup.resumeOrigin } : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), 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 fb164d978e72..b28133297a6e 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 @@ -50,6 +50,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { releaseDeferredCwdFence() return Promise.resolve(null) } + const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride + if (effectiveStartup?.resumeSource?.cancelled) { + releaseDeferredCwdFence() + return Promise.resolve(null) + } session.authoritativeReattachGeneration += 1 // Every fresh connect creates or rebinds a PTY. Do not let a legacy // response that omits `incarnationId` inherit the predecessor's fence. @@ -90,7 +95,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.transportConnectInFlightSince = Date.now() session.pendingSpawnDeferral = null const visibleAtSpawn = session.deps.isVisibleRef.current === true - const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride const outputCallbacks = session.captureTransportOutputCallbacks( session.reportError, toProcessExitStartup(coldRestoreOverride ?? effectiveStartup) @@ -163,7 +167,13 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // unverifiable until a stamped attach result arrives. session.remotePtyIncarnationId = connectResult?.incarnationId ?? null if (connectResult?.isReattach) { - session.pendingStartupCommand = null + if ( + session.pendingStartupCommand?.resumeSource === effectiveStartup?.resumeSource && + (effectiveStartup?.resumeSource || + session.pendingStartupCommand?.command === effectiveStartup?.command) + ) { + session.pendingStartupCommand = null + } const accepted = await session.handleReattachResult( connectResult, null, @@ -184,19 +194,23 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { return accepted ? resolvedPtyId : null } // 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() - session.pendingStartupCommand = null + const rejectedSavedStartup = + connectResult?.agentResumeRefused || + (connectResult?.agentResumeUnavailable && effectiveStartup?.resumeSource) + const coldRestore = rejectedSavedStartup ? null : coldRestoreOverride + if (connectResult && rejectedSavedStartup) { + session.cancelSavedResumeStartup(coldRestoreOverride ?? effectiveStartup, connectResult) } 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 (!(coldRestoreOverride ?? effectiveStartup)?.resumeSource?.record) { + session.keepUnrunResumeHandle(startupOverride) + } } if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(coldRestore ? { launchToken: coldRestore.launchToken } : {}), - ...(coldRestore ? { launchAgent: coldRestore.agent } : {}) + ...(spawnedPtyId.launchToken ? { launchToken: spawnedPtyId.launchToken } : {}), + ...(spawnedPtyId.launchAgent ? { launchAgent: spawnedPtyId.launchAgent } : {}) }) } if (resolvedPtyId) { @@ -229,7 +243,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { // Why: delayed draft/follow-up delivery keys off this launch // registry. If spawn produced no PTY, the launch is no longer a // viable delivery target and must not wait for a future pane. - session.clearRegisteredStartupLaunchConfig() + session.clearRegisteredStartupLaunchConfig(coldRestoreOverride ?? effectiveStartup) } if ( resolvedPtyId && @@ -276,7 +290,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) ) { - session.clearRegisteredStartupLaunchConfig() + session.clearRegisteredStartupLaunchConfig(coldRestoreOverride ?? effectiveStartup) } clearPreSignaledSerializer() return null 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 22dde900bf8b..f360309d4618 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 @@ -1,12 +1,15 @@ import type { AgentProviderSessionMetadata, ResumableTuiAgent, + SavedResumeStartupSource, SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' import type { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup' export type PendingStartupCommand = { command: string + resumeSource?: SavedResumeStartupSource + isCurrent?: () => boolean env?: Record } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts index b633e1fd300e..566aee044a7f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-agent-resume-deferral.ts @@ -36,6 +36,9 @@ export function deferHiddenAgentResume( ptyId: session.transport.getPtyId(), record: recordEntry.record, wake: (): Promise => { + if (startup?.resumeSource?.cancelled) { + return Promise.resolve(null) + } session.hiddenAgentResumeReleased = true return start().then((ptyId) => { // Why the transport: a held reattach resolves before it binds, and the hold must cover its fallback. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts index 0130d1f05198..a257cc3ca9be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-serializer-settle.ts @@ -61,9 +61,10 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { // replacement. Only explicit terminal-paste stays renderer-owned. session.pendingStartupCommand = session.shouldDeliverStartupViaTerminalPaste ? session.paneStartup?.command - ? { command: session.paneStartup.command } + ? { command: session.paneStartup.command, resumeSource: session.paneStartup.resumeSource } : null : null + const startupSource = session.paneStartup?.resumeSource const startupDraftReadyScanner = session.ownsStartupDraftPaste ? createDraftPasteReadyScanner( session.startupDraftAgentConfig?.draftPasteReadySignal ?? @@ -91,6 +92,7 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { const ptyId = session.transport.getPtyId() if ( !ptyId || + startupSource?.cancelled || session.disposed || session.deps.paneTransportsRef.current.get(session.pane.id) !== session.transport ) { @@ -118,6 +120,9 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { // Why: xterm focus reports share this transport queue. Bypassing it can // race CSI I against the draft on ConPTY and expose a literal `[I` prefix. void sendAgentDraftPasteContent(ptyId, session.startupDraftPrompt, 'launch', async (data) => { + if (getStartupDraftPtyId() !== ptyId) { + return false + } const accepted = await writeTerminalPastePtyInput(session.transport, data, 'launch') if (accepted && !startupDraftInputRecorded) { // Why: this transport write bypasses xterm's user-input signal; keep @@ -159,6 +164,9 @@ export function bindSettlePaneSerializer(session: ConnectPanePtySession): void { } startupDraftHardTimer = setTimeout(() => { startupDraftHardTimer = null + if (!getStartupDraftPtyId()) { + return + } if (session.startupDraftAgent === 'codex') { startupDraftPasteSettled = true // A timed-out launch stays consumed across pane disposal and remount. 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..19647f404c59 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,3 +109,57 @@ describe('reattach payload context', () => { expect(context.reattachPayloadApplied).toBe(true) }) }) + +it.each(['missing', 'different', 'admitted'] as const)( + 'authorizes only the checked late saved candidate (%s)', + async (kind) => { + await installTerminalTestGlobals() + try { + const startup: ColdRestoreAgentResumeStartup = { + command: 'codex resume C', + agent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id: 'C' }, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'saved-C', + resumeSource: { cancelled: false }, + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } + const apply = vi.fn((value) => Boolean(value)) + const cancel = vi.fn() + const schedule = vi.fn() + const session = createSession({ + buildColdRestoreAgentResumeStartup: () => startup, + applyColdRestoreAgentResumeStartup: apply, + cancelSavedResumeStartup: cancel, + schedulePendingStartupCommandDelivery: schedule, + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + showSessionRestoredBanner: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + const context = createContext('', 1) + context.connectResult = { + id: 'pty-1', + coldRestore: { scrollback: '', cwd: '/workspace' }, + ...(kind === 'missing' + ? {} + : { + savedResumeDecision: { + agent: 'codex', + providerSession: { key: 'session_id', id: kind === 'different' ? 'D' : 'C' }, + verdict: 'admitted' + } + }) + } + await createReattachPayloadHandlers(session, context).applyReattachPayload() + expect(apply).toHaveBeenCalledWith(kind === 'admitted' ? startup : null) + expect(schedule).toHaveBeenCalledTimes(kind === 'admitted' ? 1 : 0) + expect(cancel).toHaveBeenCalledTimes(kind === 'admitted' ? 0 : 1) + } finally { + await restoreTerminalTestGlobals() + } + } +) 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..51bcec05157a 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 @@ -17,6 +17,7 @@ export type ReattachPayloadSession = Pick< | 'recordRendererOrderedSeq' | 'rememberReattachPayloadAgentSignal' | 'schedulePendingStartupCommandDelivery' + | 'cancelSavedResumeStartup' | 'sendFocusedReattachFocusInAfterReplay' | 'setRestoredSnapshotBaseline' | 'shouldPreserveAgentReattachModes' 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 067ae8c11b66..9e21d8073aef 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 @@ -40,6 +40,7 @@ type ReattachResultSession = ReattachPayloadSession & | 'handleReattachResult' | 'mountFollowsTerminalPark' | 'registerEffectiveLaunchConfig' + | 'cancelSavedResumeStartup' | 'registerPaneSerializerFor' | 'registerSideEffectFactConsumerForPty' | 'reportPanePtyVisibility' @@ -94,6 +95,12 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.remotePtyIncarnationId = null } + if ( + connectResult?.agentResumeRefused || + (connectResult?.agentResumeUnavailable && !connectResult.isReattach) + ) { + session.cancelSavedResumeStartup(coldRestoreStartup ?? sessionBag.paneStartup, connectResult) + } if (connectResult?.exitedBeforeAttach) { if (connectResult.agentResumeUnavailable) { session.showSessionRestoredBanner('resume-unavailable') @@ -103,7 +110,12 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi } // Why: main started a plain shell instead, so nothing of the plan may be registered over it. - const startup = connectResult?.agentResumeRefused ? null : coldRestoreStartup + const startup = + connectResult?.agentResumeRefused || + connectResult?.agentResumeUnavailable || + connectResult?.isReattach + ? null + : coldRestoreStartup const ptyId = connectResult?.id ?? (typeof result === 'string' ? result : session.transport.getPtyId()) const hasExplicitPtyId = Boolean(connectResult?.id || typeof result === 'string') @@ -130,7 +142,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi return false } session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(startup ? { launchToken: startup.launchToken } : {}), + ...(connectResult?.launchToken ? { launchToken: connectResult.launchToken } : {}), ...(connectResult?.launchAgent ? { launchAgent: connectResult.launchAgent } : startup @@ -179,7 +191,10 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi if ( !hasStructuralReplay && connectResult?.isReattach && - hasEmptyReattachRetireEvidence(ptyId, startup) + hasEmptyReattachRetireEvidence( + ptyId, + connectResult.agentResumeRefused ? null : coldRestoreStartup + ) ) { session.transport.disconnect() if (staleSessionId) { @@ -188,7 +203,7 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi } else { session.syncPanePtyLayoutBinding(null) } - session.startFreshColdRestoreAgentResume(startup, { + session.startFreshColdRestoreAgentResume(coldRestoreStartup, { forceBlankRestoredViewport: true }) return false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts new file mode 100644 index 000000000000..4c1d029bc8df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { bindSettlePaneSerializer } from './pane-serializer-settle' +import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' +import { installSleepingRecordAccess } from './sleeping-record-access' + +const fixture = vi.hoisted(() => ({ state: {}, paste: vi.fn(), parsed: vi.fn(async () => {}) })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => fixture.state, setState: vi.fn() } })) +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + waitForTerminalOutputParsed: fixture.parsed +})) +vi.mock('../terminal-startup-command-paste', () => ({ + executeTerminalStartupCommandPaste: fixture.paste +})) +vi.mock('../terminal-paste-runtime', () => ({ resolveTerminalPasteRuntime: () => ({}) })) +vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('./command-inferred-pane-agent', () => ({ installCommandInferredPaneAgent: vi.fn() })) + +afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('a refused saved startup and its delayed paste', () => { + it.each(['original', 'same-C', 'different-D', 'unverifiable'] as const)( + 'cancels only its captured source and keeps newer work (%s)', + async (kind) => { + vi.useFakeTimers() + const record = { + paneKey: 'tab:leaf', + tabId: 'tab', + worktreeId: 'wt', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + connectionId: null, + state: 'done' as const, + prompt: '', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' as const + } + const source = { cancelled: false, record } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + resumeProviderSession: record.providerSession, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const records = kind === 'unverifiable' ? {} : { [record.paneKey]: record } + const clear = vi.fn() + const remember = vi.fn() + fixture.state = { + sleepingAgentSessionsByPaneKey: records, + automaticAgentResumeClaimsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + pendingStartupByTabId: {}, + tabsByWorktree: {}, + registerAgentLaunchConfig: vi.fn(), + clearAgentLaunchConfig: vi.fn(), + consumeTabStartupCommand: vi.fn(), + clearSleepingAgentSession: clear, + rememberSleepingAgentSession: remember + } + const sendInput = vi.fn(() => true) + const transport = { getPtyId: () => 'shell', sendInput } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the installers' accessed session members are supplied here. + const session = { + pane: { id: 1, leafId: 'leaf', terminal: {} }, + manager: {}, + transport, + deps: { + worktreeId: 'wt', + tabId: 'tab', + paneTransportsRef: { current: new Map([[1, transport]]) } + }, + cacheKey: record.paneKey, + paneStartup: startup, + startupInjectTimer: null, + disposed: false, + transportStreamGeneration: 1, + shouldDeliverStartupViaTerminalPaste: true, + releaseUnattemptedStartupDraftPasteDelivery: vi.fn() + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + bindSettlePaneSerializer(session) + bindDeferredColdRestoreAndSnapshot(session) + let finishPaste: (() => void) | undefined + fixture.paste.mockImplementation( + () => + new Promise((resolve) => { + finishPaste = () => resolve({ status: 'pasted' }) + }) + ) + session.schedulePendingStartupCommandDelivery() + await vi.advanceTimersByTimeAsync(50) + expect(fixture.paste).toHaveBeenCalledOnce() + + const newer = { command: kind === 'different-D' ? 'codex resume D' : 'codex resume C' } + session.pendingStartupCommand = newer + if (kind === 'same-C' || kind === 'different-D') { + Object.assign(records, { + [record.paneKey]: { + ...record, + providerSession: { ...record.providerSession, id: kind === 'same-C' ? 'C' : 'D' }, + updatedAt: 20 + } + }) + } + session.cancelSavedResumeStartup(startup, { + id: 'shell', + agentResumeRefused: kind === 'unverifiable' ? 'unverifiable' : 'chat-home' + }) + expect(source.cancelled).toBe(true) + expect(session.pendingStartupCommand).toBe(newer) + finishPaste?.() + await vi.advanceTimersByTimeAsync(0) + expect(sendInput).not.toHaveBeenCalled() + expect(session.pendingStartupCommand).toBe(newer) + if (kind === 'unverifiable') { + expect(remember).toHaveBeenCalledWith(record) + } else if (kind === 'original') { + expect(clear).toHaveBeenCalledWith(record.paneKey) + } else { + expect(clear).not.toHaveBeenCalled() + } + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts new file mode 100644 index 000000000000..ee0c0ed84d5c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts @@ -0,0 +1,102 @@ +import { forgetRefusedPaneResume } from '@/lib/forget-refused-pane-resume' +import { useAppStore } from '@/store' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { PtyPaneStartup } from '../pty-connection-types' +import type { PtyConnectResult } from '../pty-transport-types' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { clearStaleAgentTabTitleOnConfirmedShell } from './confirmed-shell-agent-title' + +export function installSavedResumeStartupCancellation(session: ConnectPanePtySession): void { + session.cancelSavedResumeStartup = ( + startup: ColdRestoreAgentResumeStartup | PtyPaneStartup | null | undefined, + result: PtyConnectResult + ): void => { + const source = startup?.resumeSource + if (!startup || !source) { + return + } + source.cancelled = true + const state = useAppStore.getState() + state.consumeTabStartupCommand(session.deps.tabId, startup) + if (session.pendingStartupCommand?.resumeSource === source) { + session.pendingStartupCommand = null + if (session.startupInjectTimer !== null) { + clearTimeout(session.startupInjectTimer) + } + session.startupInjectTimer = null + } + if (session.deferredSpawn?.startupOverride?.resumeSource === source) { + session.deferredSpawn = null + } + session.clearRegisteredStartupLaunchConfig(startup) + const claim = state.automaticAgentResumeClaimsByTabId[session.deps.tabId] + if ( + session.paneStartup?.resumeSource === source && + claim && + claim === session.savedStartupAutomaticClaim + ) { + useAppStore.setState((current) => { + if (current.automaticAgentResumeClaimsByTabId[session.deps.tabId] !== claim) { + return current + } + const next = { ...current.automaticAgentResumeClaimsByTabId } + delete next[session.deps.tabId] + return { automaticAgentResumeClaimsByTabId: next } + }) + } + if (session.paneStartup?.resumeSource === source) { + const pending = state.pendingStartupByTabId[session.deps.tabId] + const installed = useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + const replaced = pending && pending.resumeSource !== source + if (!replaced && !installed) { + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + if ( + tab?.launchAgent === session.paneStartup.launchAgent && + (tab?.generation ?? 0) === session.tabGeneration + ) { + state.clearTabLaunchAgent(session.deps.tabId) + clearStaleAgentTabTitleOnConfirmedShell(session) + } + } + const seeded = session.savedStartupStatus + if (seeded?.source === source) { + useAppStore.setState((current) => { + if (current.agentStatusByPaneKey[session.cacheKey] !== seeded.entry) { + return current + } + const next = { ...current.agentStatusByPaneKey } + delete next[session.cacheKey] + return { agentStatusByPaneKey: next } + }) + } + session.paneStartup = null + session.startupDraftPrompt = null + session.cleanupStartupDraftPasteTimers?.() + } + if ( + result.agentResumeRefused !== 'unverifiable' && + source.expectedStatus && + startup.resumeProviderSession + ) { + forgetRefusedPaneResume(state, source.expectedStatus.paneKey, { + agent: 'agent' in startup ? startup.agent : (startup.launchAgent ?? ''), + providerSession: startup.resumeProviderSession, + expectedStatus: source.expectedStatus + }) + } + const captured = source.record + if (!captured) { + return + } + const current = state.sleepingAgentSessionsByPaneKey[captured.paneKey] + if (result.agentResumeRefused === 'unverifiable') { + if (!current) { + state.rememberSleepingAgentSession(captured) + } + } else if (current === captured) { + state.clearSleepingAgentSession(captured.paneKey) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 094dd825afca..e58dcc4616d6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -1,3 +1,5 @@ +import { installSavedResumeStartupCancellation } from './saved-resume-startup-cancellation' +import { launchConfigsEqual } from '../../../../../shared/agent-resume-record-equivalence' import { useAppStore } from '@/store' import type { PtyConnectResult } from '../pty-transport-types' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -85,6 +87,9 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi } } } + session.savedStartupAutomaticClaim = session.paneStartup?.resumeSource + ? useAppStore.getState().automaticAgentResumeClaimsByTabId[session.deps.tabId] + : undefined session.launchToken = session.paneStartup?.launchConfig ? (session.paneStartup.launchToken ?? createBrowserUuid()) : undefined @@ -146,6 +151,12 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi tabId: session.deps.tabId, leafId: session.pane.leafId }) + if (session.paneStartup.resumeSource) { + session.savedStartupLaunchRegistration = { + source: session.paneStartup.resumeSource, + entry: useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + } + } } else if (session.paneStartup) { useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) } @@ -188,9 +199,24 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi leafId: session.pane.leafId }) } - session.clearRegisteredStartupLaunchConfig = (): void => { - useAppStore.getState().clearAgentLaunchConfig(session.cacheKey) + session.clearRegisteredStartupLaunchConfig = (expected = session.paneStartup): void => { + const state = useAppStore.getState() + const installed = state.agentLaunchConfigByPaneKey[session.cacheKey] + const captured = session.savedStartupLaunchRegistration + const ownsRegistration = expected?.resumeSource + ? captured?.source === expected.resumeSource && captured.entry === installed + : true + if ( + ownsRegistration && + installed && + expected?.launchConfig && + installed.identity.launchToken === (expected.launchToken ?? session.launchToken) && + launchConfigsEqual(installed.launchConfig, expected.launchConfig) + ) { + state.clearAgentLaunchConfig(session.cacheKey) + } } + installSavedResumeStartupCancellation(session) session.neutralTerminalTitle = (): string => { const state = useAppStore.getState() const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( 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 1abfc7ab1a52..b6582f2121f3 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 @@ -153,8 +153,10 @@ export function installSpawnDeferredSleep(session: ConnectPanePtySession): void session.keepUnrunResumeHandle = ( startupOverride: PendingStartupCommand | null | undefined ): void => { - const record = deferredResumeRecord(session, startupOverride) - if (record) { + const captured = + startupOverride?.resumeSource?.record ?? session.paneStartup?.resumeSource?.record + const record = captured ?? deferredResumeRecord(session, startupOverride) + if (record && !useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]) { // 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) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts index f4d73993fb83..8b443aa5afda 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -75,9 +75,10 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { } session.applyInitialAgentStatus = (terminalTitle?: string): void => { - const initialStatus = session.paneStartup?.initialAgentStatus + const startup = session.paneStartup + const initialStatus = startup?.initialAgentStatus const routing = session.resolveCurrentAgentStatusRouting() - if (!initialStatus || !routing) { + if (!initialStatus || !routing || startup.resumeSource?.cancelled) { return } const statusPayload = { @@ -100,11 +101,17 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { launchConfig: session.paneStartup.launchConfig, ...(session.launchToken ? { launchToken: session.launchToken } : {}) }) - return + } else { + useAppStore + .getState() + .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) + } + if (startup.resumeSource) { + session.savedStartupStatus = { + source: startup.resumeSource, + entry: useAppStore.getState().agentStatusByPaneKey[session.cacheKey] + } } - useAppStore - .getState() - .setAgentStatus(session.cacheKey, statusPayload, terminalTitle, undefined, routing) } session.canApplyCommandCodeOutputStatus = (): boolean => { 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 f4d0ebbef6b2..b39253244082 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from '../../../../shared/agent-session-resume' import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { RestoreSpawnIntent, @@ -90,7 +91,7 @@ export type LocalPtySessionMetadata = { shellOverride?: string } -export type PtyConnectResult = { +export type PtyConnectResult = TerminalLaunchResult & { id: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 4cfd751cb17f..ec1cc1365ded 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -1,3 +1,4 @@ +import { ExplicitEnsure } from '../../../../shared/rpc-contract/agent-session-params' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // These transport fixtures never read abandoned-launch ownership from the app store. const appStore = vi.hoisted(() => ({ @@ -127,6 +128,63 @@ describe('createRemoteRuntimePtyTransport', () => { return payload } + it('round-trips a strict saved ensure and its actual refused shell result', async () => { + const providerSession = { key: 'session_id' as const, id: 'C' } + const decision = { agent: 'codex' as const, providerSession, verdict: 'chat-home' as const } + runtimeCall.mockImplementation(async (args: { method?: string; params?: unknown }) => { + if (args.method === 'status.get') { + return { + ok: true, + result: { + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [ + 'agent-session.host-authority.v1', + 'agent-session.saved-resume-origin.v1' + ] + } + } + } + expect(args.method).toBe('terminal.ensureAgentSession') + expect(ExplicitEnsure.parse(args.params)).toMatchObject({ + resumeOrigin: 'saved', + providerSession + }) + return { + ok: true, + result: { + disposition: 'created', + terminal: { + handle: 'term-remote', + worktreeId: 'repo1::/remote/wt', + title: null, + savedResumeDecision: decision, + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true + } + } + } + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'repo1::/remote/wt', + launchAgent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: providerSession, + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111' + }) + const result = await transport.connect({ url: '', callbacks: {} }) + expect(result).toMatchObject({ + id: 'remote:env-1@@term-remote', + savedResumeDecision: decision, + agentResumeRefused: 'chat-home', + agentResumeUnavailable: true + }) + expect(result).not.toHaveProperty('launchAgent') + transport.disconnect() + }) + it('creates and subscribes to a terminal on the active remote runtime', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onReplayData = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts index 9d55c0318df5..cec19002e953 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-spawn-intent.test.ts @@ -22,7 +22,7 @@ function okResponse(result: unknown): unknown { } describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { - const runtimeCall = vi.fn() + const runtimeCall = vi.fn<(args: RpcCall) => Promise>() let capabilities: string[] = [] let ensureResponse: unknown = null @@ -66,12 +66,16 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { vi.unstubAllGlobals() }) - async function connectResume(callbacks: Record = {}): Promise { + async function connectResume( + callbacks: Record = {}, + saved = false + ): Promise { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'repo1::/remote/wt', launchAgent: 'claude', resumeProviderSession: RESUME, + ...(saved ? { resumeOrigin: 'saved' as const } : {}), tabId: 'tab-1', leafId: LEAF_ID }) @@ -104,6 +108,46 @@ describe('createRemoteRuntimePtyTransport: restore spawn intent', () => { expect(ensureParams()).not.toHaveProperty('spawnIntent') }) + it.each([false, true])( + 'keeps saved-origin negotiation stable through an unknown outcome (capability %s)', + async (modern) => { + if (modern) { + capabilities.push('agent-session.saved-resume-origin.v1') + } + let attempts = 0 + runtimeCall.mockImplementation(async (args: RpcCall) => { + if (args.method === 'status.get') { + return okResponse({ + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities + }) + } + if (args.method === 'terminal.ensureAgentSession' && attempts++ === 0) { + capabilities = [...capabilities, 'agent-session.saved-resume-origin.v1'] + throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), { + code: 'runtime_timeout' + }) + } + return ensureResponse + }) + const onSpawnDeferred = vi.fn() + await connectResume({ onSpawnDeferred }, true) + const requests = runtimeCall.mock.calls + .map(([call]) => call) + .filter((call) => call.method === 'terminal.ensureAgentSession') + expect(requests).toHaveLength(2) + expect(requests[1].params).toEqual(requests[0].params) + expect(requests[0].params).toMatchObject({ kind: 'explicit', providerSession: RESUME }) + if (modern) { + expect(requests[0].params).toHaveProperty('resumeOrigin', 'saved') + } else { + expect(requests[0].params).not.toHaveProperty('resumeOrigin') + } + expect(onSpawnDeferred).toHaveBeenCalledWith('paused') + } + ) + it('reports a deferral to the pane instead of surfacing an error', async () => { capabilities.push('terminal.spawn-intent.v1') const onError = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 8619c3ae68aa..41cb3ca5539d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,3 +1,7 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' @@ -532,6 +536,8 @@ export function createRemoteRuntimePtyTransport( } // Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records. const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}` + let replayLaunch: ((epoch: number) => Promise) | undefined + let savedOriginOptions: Promise<{ resumeOrigin?: 'saved' }> | undefined const terminalCreateMutationId = createBrowserUuid() // Why: reconnect retries must replay one host operation instead of creating // another fresh agent when the first response was lost. @@ -3088,6 +3094,23 @@ export function createRemoteRuntimePtyTransport( if (restoreSpawnIntent && (destroyed || lifecycleEpoch !== connectLifecycleEpoch)) { return } + const originOptions = + (options.resumeOrigin ?? resumeOrigin) === 'saved' + ? await (savedOriginOptions ??= (async () => { + try { + const supported = await runtimeEnvironmentSupportsCapability( + createEnvironmentId, + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY + ) + return supported ? { resumeOrigin: 'saved' as const } : {} + } catch (error) { + if (isRuntimeCompatBlockError(error)) { + throw error + } + return {} + } + })()) + : {} const legacyCreateParams = { worktree: toRuntimeTerminalWorktreeSelector(worktreeId), clientMutationId: terminalCreateMutationId, @@ -3101,9 +3124,7 @@ export function createRemoteRuntimePtyTransport( ...(resumeProviderSessionToSend !== undefined ? { resumeProviderSession: resumeProviderSessionToSend } : {}), - ...(resumeProviderSessionToSend && (options.resumeOrigin ?? resumeOrigin) - ? { resumeOrigin: options.resumeOrigin ?? resumeOrigin } - : {}), + ...originOptions, ...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}), ...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}), ...spawnIntentParams, @@ -3116,7 +3137,7 @@ export function createRemoteRuntimePtyTransport( presentation: 'background' as const, ...(activate === true ? { activate: true } : {}) } - const legacyCreate = () => + const legacyCreate = (epoch = connectLifecycleEpoch) => createWithUnknownOutcomeRecovery( 'terminal', (timeoutMs, reconcileExisting) => @@ -3130,9 +3151,9 @@ export function createRemoteRuntimePtyTransport( timeoutMs ), createEnvironmentId, - connectLifecycleEpoch + epoch ) - const hostAuthorityCreate = async () => { + const hostAuthorityCreate = async (epoch = connectLifecycleEpoch) => { const keyboardOptions = await agentKeyboardOptions(createEnvironmentId) return createWithUnknownOutcomeRecovery( 'agent-session', @@ -3144,6 +3165,7 @@ export function createRemoteRuntimePtyTransport( { kind: 'explicit', ...keyboardOptions, + ...originOptions, worktree: toRuntimeTerminalWorktreeSelector(worktreeId), agent: launchAgentToSend!, providerSession: resumeProviderSessionToSend, @@ -3182,24 +3204,32 @@ export function createRemoteRuntimePtyTransport( timeoutMs ), createEnvironmentId, - connectLifecycleEpoch + epoch ) } const resumeHostAuthorityCapability = resumeProviderSessionToSend ? agentResumeHostAuthorityCapability(launchAgentToSend) : undefined - const created = launchAgentToSend - ? agentSessionRequiresHostAuthorityReplay - ? await hostAuthorityCreate() - : await runRemoteAgentSessionLaunch({ - environmentId: createEnvironmentId, - hostAuthority: hostAuthorityCreate, - ...(resumeHostAuthorityCapability - ? { hostAuthorityCapability: resumeHostAuthorityCapability } - : {}), - legacy: legacyCreate - }) - : await legacyCreate() + const created = replayLaunch + ? await replayLaunch(connectLifecycleEpoch) + : launchAgentToSend + ? agentSessionRequiresHostAuthorityReplay + ? await hostAuthorityCreate() + : await runRemoteAgentSessionLaunch({ + environmentId: createEnvironmentId, + hostAuthority: () => { + replayLaunch = hostAuthorityCreate + return hostAuthorityCreate() + }, + ...(resumeHostAuthorityCapability + ? { hostAuthorityCapability: resumeHostAuthorityCapability } + : {}), + legacy: () => { + replayLaunch = legacyCreate + return legacyCreate() + } + }) + : await (replayLaunch = legacyCreate)() if (!created) { if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) { connecting = false @@ -3267,6 +3297,8 @@ export function createRemoteRuntimePtyTransport( } return { + ...terminalLaunchFacts(createdTerminal), + ...(createdTerminal.coldRestore ? { coldRestore: createdTerminal.coldRestore } : {}), id: remotePtyId, replay: '', ...(authoritativePtyIncarnationId ? { incarnationId: authoritativePtyIncarnationId } : {}), 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 3e6a62c3c073..fe9d365c748d 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -24,6 +24,9 @@ function dropEventsRestoringRefused( } ): void { const restores = (event: AgentStatusIpcPayload): boolean => + !!refused.expectedStatus && + event.receivedAt <= refused.expectedStatus.receivedAt && + event.stateStartedAt === refused.expectedStatus.stateStartedAt && resolveAgentPaneAuthorityKey(event.paneKey) === paneKey && namesRefusedResume(refused, { ...event, agent: event.agentType }) const pending = queues.pendingAgentStatusEvents diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index cfb2f0362c15..be251bdb1857 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2969,6 +2969,7 @@ "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenElsewhere": "Codex still has this conversation open somewhere else. Close it there, then try again.", "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6baed6a4e4fc..ae05c28d041a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18346,6 +18346,7 @@ "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", "agentCommandNotRunnable": "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + "conversationOpenElsewhere": "Codex still has this conversation open somewhere else. Close it there, then try again.", "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6d425fd3aa2b..f232d4a69154 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18060,6 +18060,7 @@ "claudeAccountSetupFailed": "No se pudo configurar la cuenta de Claude seleccionada. Vuelva a intentarlo o elija otra cuenta.", "agentCommandNotRunnable": "{{agent}}: el Comando en Ajustes → Agentes debe ser la ruta o el nombre de un programa que Orca pueda encontrar, sin argumentos ni variables. Cámbielo o restablézcalo con Reiniciar.", "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", + "conversationOpenElsewhere": "Codex aún tiene esta conversación abierta en otro lugar. Ciérrela allí y vuelva a intentarlo.", "conversationOpenInTerminal": "Orca no puede confirmar que {{agent}} haya salido en su terminal. Detenga lo que se esté ejecutando allí o cierre la terminal y vuelva a intentarlo.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index bfcd4eaac1d2..1da9b09e2a39 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18169,6 +18169,7 @@ "claudeAccountSetupFailed": "Le compte Claude sélectionné n'a pas pu être configuré. Réessayez ou choisissez un autre compte.", "agentCommandNotRunnable": "{{agent}} : la Commande dans Paramètres → Agents doit être le chemin ou le nom d'un programme qu'Orca peut trouver, sans arguments ni variables. Modifiez-la ou réinitialisez-la.", "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", + "conversationOpenElsewhere": "Codex garde cette conversation ouverte ailleurs. Fermez-la à cet endroit, puis réessayez.", "conversationOpenInTerminal": "Orca ne peut pas confirmer que {{agent}} a quitté son terminal. Arrêtez ce qui s'y exécute ou fermez le terminal, puis réessayez.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ff8ec483edad..7f5f620fd4cd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18100,6 +18100,7 @@ "claudeAccountSetupFailed": "選択した Claude アカウントを設定できませんでした。もう一度試すか、別のアカウントを選択してください。", "agentCommandNotRunnable": "[設定] → [Agent] にある {{agent}} の [コマンド] には、Orca が見つけられるプログラムのパスまたは名前を、引数や変数なしで指定する必要があります。変更するか、[リセット] してください。", "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", + "conversationOpenElsewhere": "Codex はまだ別の場所でこの会話を開いています。そこで閉じてから、もう一度お試しください。", "conversationOpenInTerminal": "Orca は {{agent}} がターミナルで終了したことを確認できません。そこで実行中のものを停止するかターミナルを閉じてから、もう一度お試しください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a2bf337d92a5..8489acc0e720 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18100,6 +18100,7 @@ "claudeAccountSetupFailed": "선택한 Claude 계정을 설정할 수 없습니다. 다시 시도하거나 다른 계정을 선택하세요.", "agentCommandNotRunnable": "{{agent}}의 설정 → 에이전트 → 명령은 Orca가 찾을 수 있는 프로그램 경로나 이름이어야 하며, 인수나 변수를 포함할 수 없습니다. 변경하거나 재설정하세요.", "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", + "conversationOpenElsewhere": "Codex가 다른 곳에서 이 대화를 열어 두고 있습니다. 그곳에서 닫은 후 다시 시도하세요.", "conversationOpenInTerminal": "Orca에서 {{agent}}이(가) 터미널에서 종료되었는지 확인할 수 없습니다. 그곳에서 실행 중인 항목을 중지하거나 터미널을 닫은 후 다시 시도하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7a8ec0fb5a7c..cbdf29ae07ab 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18065,6 +18065,7 @@ "claudeAccountSetupFailed": "无法设置所选 Claude 账户。请重试或选择其他账户。", "agentCommandNotRunnable": "{{agent}} 在设置 → 智能体中的命令必须是 Orca 能找到的程序路径或名称,且不能包含参数或变量。请修改或重置。", "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", + "conversationOpenElsewhere": "Codex 仍在其他地方打开着此对话。请在那里关闭后重试。", "conversationOpenInTerminal": "Orca 无法确认 {{agent}} 已在其终端中退出。请停止那里正在运行的内容或关闭终端,然后重试。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", diff --git a/src/renderer/src/lib/forget-refused-pane-resume.ts b/src/renderer/src/lib/forget-refused-pane-resume.ts index 3576d463630f..7372577bbcea 100644 --- a/src/renderer/src/lib/forget-refused-pane-resume.ts +++ b/src/renderer/src/lib/forget-refused-pane-resume.ts @@ -1,10 +1,10 @@ +import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { RefusedPaneResume } from '../../../shared/agent-status-types' import { agentProviderSessionsEqual, type AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' -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. */ @@ -34,42 +34,33 @@ export function forgetRefusedPaneResume( paneKey: string, refused: RefusedPaneResume ): void { - const names = (claim: Parameters[1]) => - namesRefusedResume(refused, claim) - const retained = state.retainedAgentsByPaneKey[paneKey] - if (retained && names({ ...retained.entry, agent: retained.agentType })) { - state.dismissRetainedAgent(paneKey) - } - 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 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 || parseLegacyNumericPaneKey(key)?.tabId === 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. + const expected = refused.expectedSavedRecord + const saved = expected && state.sleepingAgentSessionsByPaneKey[expected.paneKey] if ( - tab?.aiVaultTitle?.agent === refused.agent && - tab.aiVaultTitle.sessionId === refused.providerSession.id + expected && + saved && + saved.capturedAt === expected.capturedAt && + saved.updatedAt === expected.updatedAt && + namesRefusedResume(refused, saved) ) { - state.setAiVaultTabTitle(tab.id, null) + state.clearSleepingAgentSession(expected.paneKey) + } + const identity = refused.expectedStatus + if (!identity) { + return } + useAppStore.setState((current) => { + const live = current.agentStatusByPaneKey[paneKey] + if ( + !live || + live.updatedAt > identity.receivedAt || + live.stateStartedAt !== identity.stateStartedAt || + !namesRefusedResume(refused, { ...live, agent: live.agentType }) + ) { + return current + } + const next = { ...current.agentStatusByPaneKey } + delete next[paneKey] + return { agentStatusByPaneKey: next } + }) } diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 197cc670677f..39c057f67b0a 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -82,6 +82,19 @@ export function launchSleepingAgentSession( launchConfig: startupPlan.launchConfig, resumeProviderSession: record.providerSession, resumeOrigin: 'saved', + resumeSource: { + cancelled: false, + record, + ...(state.agentStatusByPaneKey[record.paneKey] + ? { + expectedStatus: { + paneKey: record.paneKey, + receivedAt: state.agentStatusByPaneKey[record.paneKey].updatedAt, + stateStartedAt: state.agentStatusByPaneKey[record.paneKey].stateStartedAt + } + } + : {}) + }, launchAgent: record.agent, ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), ...(startupPlan.startupCommandDelivery diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 2550334dd91c..4c27bff02c43 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -1,3 +1,4 @@ +import type { SavedResumeStartupSource } from '../../../../shared/agent-session-resume' import type { ClosedTerminalTabTombstonesByTabId } from '../../../../shared/closed-terminal-tab-tombstones' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -57,6 +58,7 @@ export type TerminalState = { launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata resumeOrigin?: 'saved' + resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent /** The agent launch the pane's own spawn starts, instead of `command`. */ diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index d0043aa466bc..b9b0f34ed90a 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -76,6 +76,8 @@ export const AGENT_SESSION_FAILURE_COPY = { "{{agent}}'s Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it.", runtimeNotInstalled: "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", + conversationOpenElsewhere: + 'Codex still has this conversation open somewhere else. Close it there, then try again.', conversationOpenInTerminal: "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index a6dc78b30004..ae538f21ff7f 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -183,6 +183,7 @@ const FAILURE_SENTENCES = { agentCommandNotRunnable: namingAgent('agentCommandNotRunnable'), runtimeNotInstalled: namingAgent('runtimeNotInstalled'), conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'), + conversationOpenElsewhere: (_context, _fact, _surface, say) => say('conversationOpenElsewhere'), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 3237bbad826f..36957513964f 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -39,6 +39,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'agentCommandNotRunnable', 'runtimeNotInstalled', 'conversationOpenInTerminal', + 'conversationOpenElsewhere', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index 571cda3d6fb6..3f9eeed00bad 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -105,6 +105,7 @@ export type RuntimeEnsureAgentSessionRequest = } | { kind: 'explicit' + resumeOrigin?: 'saved' worktree: string agent: ResumableTuiAgent providerSession: AgentProviderSessionMetadata diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index d4e756e0ef6f..882b147257b4 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -88,6 +88,7 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'runtimeNotInstalled', /** A terminal may still be writing the chat's conversation, so its agent did not start. */ 'conversationOpenInTerminal', + 'conversationOpenElsewhere', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 8819bb4e7855..c00f0c995e3c 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -120,6 +120,7 @@ const REASON_WORDS = { agentCommandNotRunnable: { fact: 'agentCommandNotRunnable', action: 'actFirst' }, runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, + conversationOpenElsewhere: { fact: 'conversationOpenElsewhere', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 515108693af1..91d3d75ae879 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -1,4 +1,5 @@ import type { AgentHookSource } from './agent-hook-relay' +import type { AgentStatusCacheIdentity, RefusedPaneResume } from './agent-status-ipc-payload' import type { AgentStatusState } from './agent-status-types' import type { AgentMainAgentStatus } from './main-agent-status' import type { TuiAgent } from './tui-agent' @@ -290,3 +291,45 @@ export function extractAgentProviderSession( // Re-exported so the 18 existing call sites keep one import path. export { getAgentForkArgv, getAgentResumeArgv } from './agent-resume-argv' + +/** A host home check for this exact saved continuation, not a terminal writer claim. */ +export type SavedResumeDecision = { + agent: string + providerSession: AgentProviderSessionMetadata + verdict: 'admitted' | 'chat-home' | 'chat-writing' | 'unverifiable' +} + +/** Effective facts of a terminal launch; a refused shell carries no rejected agent metadata. */ +export type TerminalLaunchResult = { + savedResumeDecision?: SavedResumeDecision + agentResumeUnavailable?: true + agentResumeRefused?: 'chat-home' | 'chat-writing' | 'unverifiable' + refusedResume?: RefusedPaneResume + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent + launchToken?: string + resumeProviderSession?: AgentProviderSessionMetadata +} + +/** Runtime-only source shared by copies of one saved startup. Never persisted or sent to a host. */ +export type SavedResumeStartupSource = { + cancelled: boolean + record?: SleepingAgentSessionRecord + expectedStatus?: AgentStatusCacheIdentity +} + +/** Forward only effective launch facts through each existing terminal envelope. */ +export function terminalLaunchFacts(result: TerminalLaunchResult): TerminalLaunchResult { + return { + ...(result.savedResumeDecision ? { savedResumeDecision: result.savedResumeDecision } : {}), + ...(result.agentResumeUnavailable + ? { agentResumeUnavailable: result.agentResumeUnavailable } + : {}), + ...(result.agentResumeRefused ? { agentResumeRefused: result.agentResumeRefused } : {}), + ...(result.refusedResume ? { refusedResume: result.refusedResume } : {}), + ...(result.launchConfig ? { launchConfig: result.launchConfig } : {}), + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + ...(result.launchToken ? { launchToken: result.launchToken } : {}), + ...(result.resumeProviderSession ? { resumeProviderSession: result.resumeProviderSession } : {}) + } +} diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 0a6d446af1a6..0efbc6d7f7a9 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -91,6 +91,8 @@ export type AgentStatusCacheIdentity = { /** A pane's saved resume the host refused because a native chat is that conversation's home. */ export type RefusedPaneResume = { + expectedStatus?: AgentStatusCacheIdentity + expectedSavedRecord?: { paneKey: string; capturedAt: number; updatedAt: number } agent: string providerSession: AgentProviderSessionMetadata } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 1decc304744b..5624d9ad3327 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,3 +1,5 @@ +export const SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY = + 'agent-session.saved-resume-origin.v1' as const import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from './agent-session-current-requests-capability' import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation' import { PROGRAM_STATUS_RUNTIME_CAPABILITY } from './program-status/protocol' @@ -245,6 +247,7 @@ export const RUNTIME_CAPABILITIES = [ MANAGED_RUNTIME_INSTALL_RUNTIME_CAPABILITY, MANAGED_RUNTIME_REMOVE_RUNTIME_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/agent-session-params.ts b/src/shared/rpc-contract/agent-session-params.ts index b93eaccfdea5..15a98d809e67 100644 --- a/src/shared/rpc-contract/agent-session-params.ts +++ b/src/shared/rpc-contract/agent-session-params.ts @@ -129,6 +129,7 @@ export const AutomaticEnsure = z export const ExplicitEnsure = z .object({ kind: z.literal('explicit'), + resumeOrigin: z.literal('saved').optional(), worktree: WorktreeSelector, agent: z.enum(RESUMABLE_TUI_AGENTS), providerSession: ProviderSession, diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 09ae886bf593..42fd21cbde3b 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { TerminalLaunchResult } from './agent-session-resume' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -271,7 +272,8 @@ export type RuntimeTerminalCreateRequestPayload = source: 'runtime-session' }) -export type RuntimeTerminalCreate = { +export type RuntimeTerminalCreate = TerminalLaunchResult & { + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string | null diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index d38686cc4c23..8eac0a500805 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -98,6 +98,7 @@ const KIND_CATEGORY = { agentCommandNotRunnable: 'startFailed', runtimeNotInstalled: 'startFailed', conversationOpenInTerminal: 'startFailed', + conversationOpenElsewhere: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -132,6 +133,7 @@ const KIND_VERDICT = { agentCommandNotRunnable: 'failure', runtimeNotInstalled: 'failure', conversationOpenInTerminal: 'failure', + conversationOpenElsewhere: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', From 3fb0bd6b242e784c630a7164188ba12cdaed9802 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:57:46 -0700 Subject: [PATCH 005/207] Preserve conversation case and clear refused cold restore identity --- .../spawn-preflight-saved-resume.test.ts | 42 +++++++- .../structured-conversation-key.ts | 7 +- .../deferred-cold-restore-and-snapshot.ts | 6 ++ .../saved-resume-cancellation.test.ts | 95 +++++++++++++++++++ .../saved-resume-startup-cancellation.ts | 12 ++- 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index fd970427e24a..b25197d6a90d 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -8,10 +8,11 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' -import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { finishPtyShutdown } from '../provider/liveness' -import { admitColdRestoreReattach } from '../host-env/saved-resume-admission' +import { admitColdRestoreReattach, admitResumeSpawn } from '../host-env/saved-resume-admission' +import { listStructuredProviderSessionOwnership } from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' import type { PtyRuntimeControllerDeps } from './controller-deps' import { executeRuntimePtySpawn } from './spawn-execute' import { savedResumeResult } from '../host-env/saved-resume-admission' @@ -283,3 +284,40 @@ it('returns the committed runtime shell when exact saved-record persistence fail warning.mockRestore() } }) + +it('preserves case-distinct conversation heads and saved homes across the shared OpenCode family', async () => { + const ids = ['ses_000000000001AAAAAAAAAAAAAA', 'ses_000000000001aaaaaaaaaaaaaa'] + const record = { + ...chat, + provider: 'opencode' as const, + providerHandleChain: ids.map((nativeId, index) => ({ + ...chat.providerHandleChain[0]!, + linkId: `case-${index}`, + handle: { transport: 'acp' as const, agent: 'opencode' as const, nativeId } + })) + } + const opencodeStore: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + opencodeStore.listRecords = () => [record] + opencodeStore.getVisibleSessionTabIndex = () => ({ + present: true, + sessionIds: [record.sessionId] + }) + const runtime = { openAgentSessionRecordStore: async () => opencodeStore } + expect(listStructuredProviderSessionOwnership([record]).map((holding) => holding.role)).toEqual([ + 'earlier', + 'head' + ]) + for (const [index, id] of ids.entries()) { + await expect( + admitResumeSpawn( + runtime, + { + launchAgent: 'opencode2', + resumeOrigin: 'saved', + resumeProviderSession: { key: 'session_id', id } + }, + null + ) + ).resolves.toMatchObject({ verdict: index === 0 ? 'admitted' : 'chat-home' }) + } +}) diff --git a/src/main/native-chat/structured-conversation-key.ts b/src/main/native-chat/structured-conversation-key.ts index ac1a7c3823e4..64dcc18d69de 100644 --- a/src/main/native-chat/structured-conversation-key.ts +++ b/src/main/native-chat/structured-conversation-key.ts @@ -48,7 +48,7 @@ export function terminalConversationKey(agent: string, id: string | null): Conve export function keysMayMatch(left: ConversationKey, right: ConversationKey): boolean { return ( left.family === right.family && - (left.id === null || right.id === null || sameConversationId(left.id, right.id)) && + (left.id === null || right.id === null || sameConversationId(left.family, left.id, right.id)) && (left.home === null || right.home === null || agentSessionAccountHomesEqual(left.home, right.home)) @@ -56,7 +56,10 @@ export function keysMayMatch(left: ConversationKey, right: ConversationKey): boo } // A session-file agent's terminal may name the file; Session History lists its file-name id. -function sameConversationId(left: string, right: string): boolean { +function sameConversationId(family: string, left: string, right: string): boolean { + if (family === 'opencode') { + return left === right + } const normalize = (id: string) => (/[\\/]/.test(id) ? sessionIdFromFileName(id) : id).toLowerCase() return normalize(left) === normalize(right) 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 928e135bd53c..6679f5d9a8dc 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 @@ -42,6 +42,12 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio tabId: session.deps.tabId, leafId: session.pane.leafId }) + if (startup.resumeSource) { + session.savedStartupLaunchRegistration = { + source: startup.resumeSource, + entry: useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] + } + } return true } session.clearSleepingRecordAfterColdRestoreSpawn = ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts index 4c1d029bc8df..71e6164598b8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts @@ -1,4 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentLaunchConfigRegistryEntry, + AgentLaunchConfigRegistrationMetadata +} from '@/store/slices/agent-status-contract' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { ConnectPanePtySession } from './connect-pane-pty-session' import { bindSettlePaneSerializer } from './pane-serializer-settle' import { bindDeferredColdRestoreAndSnapshot } from './deferred-cold-restore-and-snapshot' @@ -127,3 +132,93 @@ describe('a refused saved startup and its delayed paste', () => { } ) }) + +it.each(['refused', 'same-C', 'different-D'] as const)( + 'clears the real generated cold/wake registration and conditional tab identity only while owned (%s)', + (kind) => { + const cacheKey = 'tab:leaf' + const source = { cancelled: false } + const startup: ColdRestoreAgentResumeStartup = { + agent: 'codex', + command: 'codex resume C', + launchToken: 'saved-token', + launchConfig: { agentCommand: 'codex', agentArgs: 'resume C', agentEnv: {} }, + resumeProviderSession: { key: 'session_id', id: 'C' }, + resumeOrigin: 'saved', + resumeSource: source, + useLiveEntry: false, + hasSleepingRecord: false, + sleepingRecordEntry: null + } + const registry: Record = {} + const pending: Record = {} + const tab = { id: 'tab', title: 'Codex', launchAgent: 'codex', generation: 1 } + const clearTabLaunchAgent = vi.fn() + const registerAgentLaunchConfig = ( + paneKey: string, + launchConfig: AgentLaunchConfigRegistryEntry['launchConfig'], + identity: AgentLaunchConfigRegistrationMetadata + ) => { + registry[paneKey] = { launchConfig, identity, registeredAt: 1 } + } + fixture.state = { + agentLaunchConfigByPaneKey: registry, + pendingStartupByTabId: pending, + automaticAgentResumeClaimsByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, + tabsByWorktree: { wt: [tab] }, + registerAgentLaunchConfig, + clearAgentLaunchConfig: (key: string) => { + delete registry[key] + }, + consumeTabStartupCommand: vi.fn(), + clearTabLaunchAgent + } + const updateTabTitle = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real installers/cancellation access only the supplied session members in this contract. + const session = { + cacheKey, + paneStartup: null, + pane: { id: 1, leafId: 'leaf' }, + manager: { getActivePane: () => ({ id: 1 }) }, + deps: { tabId: 'tab', worktreeId: 'wt', setRuntimePaneTitle: vi.fn(), updateTabTitle }, + tabGeneration: 1, + startupInjectTimer: null, + transport: {}, + shouldDeliverStartupViaTerminalPaste: false + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + bindDeferredColdRestoreAndSnapshot(session) + expect(session.applyColdRestoreAgentResumeStartup(startup)).toBe(true) + const saved = registry[cacheKey] + expect(saved?.identity.launchToken).toBe('saved-token') + if (kind !== 'refused') { + const command = kind === 'same-C' ? 'resume C' : 'resume D' + registerAgentLaunchConfig( + cacheKey, + { ...startup.launchConfig, agentArgs: command }, + { + agentType: 'codex', + launchToken: 'explicit-token' + } + ) + pending.tab = { command } + } + const current = registry[cacheKey] + const newer = pending.tab + session.cancelSavedResumeStartup(startup, { id: 'shell', agentResumeRefused: 'chat-home' }) + session.registerEffectiveLaunchConfig(undefined, {}) + expect(source.cancelled).toBe(true) + expect(session.paneStartup).toBeNull() + if (kind === 'refused') { + expect(registry[cacheKey]).toBeUndefined() + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab') + expect(updateTabTitle).toHaveBeenCalledWith('tab', 'Terminal') + } else { + expect(registry[cacheKey]).toBe(current) + expect(pending.tab).toBe(newer) + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + expect(updateTabTitle).not.toHaveBeenCalled() + } + } +) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts index ee0c0ed84d5c..5a14db5662bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts @@ -28,6 +28,12 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes if (session.deferredSpawn?.startupOverride?.resumeSource === source) { session.deferredSpawn = null } + const ownsPaneStartup = session.paneStartup?.resumeSource === source + const registered = session.savedStartupLaunchRegistration + const ownsRegistration = + registered?.source === source && + registered.entry !== undefined && + registered.entry === state.agentLaunchConfigByPaneKey[session.cacheKey] session.clearRegisteredStartupLaunchConfig(startup) const claim = state.automaticAgentResumeClaimsByTabId[session.deps.tabId] if ( @@ -44,7 +50,7 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes return { automaticAgentResumeClaimsByTabId: next } }) } - if (session.paneStartup?.resumeSource === source) { + if (ownsPaneStartup || ownsRegistration) { const pending = state.pendingStartupByTabId[session.deps.tabId] const installed = useAppStore.getState().agentLaunchConfigByPaneKey[session.cacheKey] const replaced = pending && pending.resumeSource !== source @@ -53,13 +59,15 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes (entry) => entry.id === session.deps.tabId ) if ( - tab?.launchAgent === session.paneStartup.launchAgent && + tab?.launchAgent === ('agent' in startup ? startup.agent : startup.launchAgent) && (tab?.generation ?? 0) === session.tabGeneration ) { state.clearTabLaunchAgent(session.deps.tabId) clearStaleAgentTabTitleOnConfirmedShell(session) } } + } + if (ownsPaneStartup) { const seeded = session.savedStartupStatus if (seeded?.source === source) { useAppStore.setState((current) => { From ef3282e0e6f481eee07e7006a5efb19a5893df20 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:31:59 -0700 Subject: [PATCH 006/207] fix: publish effective terminal launch results on attachment --- ...rminal-create-host-session-binding.test.ts | 99 +++++++ ...stream-extraction-characterization.test.ts | 3 + .../terminal-multiplex-initial-snapshot.ts | 19 +- .../terminal-multiplex-subscribe-frame.ts | 3 +- .../rpc/terminal-multiplex-test-harness.ts | 2 + .../recovery/resolve-terminal-pane.ts | 2 + .../pty-connection/apply-reattach-payload.ts | 27 +- .../deferred-session-reattach-connect.ts | 6 +- .../pty-connection/fresh-spawn-start.ts | 39 ++- .../reattach-payload-session.ts | 3 + ...result-handler-park-reveal-verdict.test.ts | 14 +- .../pty-connection/reattach-result-handler.ts | 33 +-- .../reattach-success-settle.test.ts | 14 +- .../saved-resume-startup-cancellation.ts | 14 +- .../terminal-launch-result-policy.ts | 100 +++++++ .../transport-output-callbacks.ts | 21 +- .../terminal-pane/pty-transport-types.ts | 2 + .../remote-runtime-pty-transport.ts | 35 ++- ...mote-runtime-saved-result-consumer.test.ts | 269 ++++++++++++++++++ ...mote-runtime-terminal-multiplexer-types.ts | 5 +- ...te-runtime-terminal-response-controller.ts | 7 +- src/shared/runtime-terminal-contracts.ts | 2 +- .../terminal-attachment-launch-result.ts | 56 ++++ .../workspace-session-sleeping-agents.ts | 2 +- 24 files changed, 697 insertions(+), 80 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts create mode 100644 src/shared/terminal-attachment-launch-result.ts diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 275b7caa1aad..2091b9c8579f 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -2,6 +2,12 @@ * durable binding and runtime ownership keep graph sync from pruning the tab * out from under a live agent — including when a window is attached. */ import { describe, expect, it, vi } from 'vitest' +import './rpc/unused-default-rpc-methods.test-fixture' +import { + startDesktopMultiplexSubscribe, + sendDesktopMultiplexSubscribe +} from './rpc/terminal-multiplex-test-harness' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { OrcaRuntimeService } from './orca-runtime' vi.mock('electron', () => ({ @@ -119,3 +125,96 @@ it.each(['refused', 'adopted', 'fresh-unavailable'] as const)( } } ) + +it.each(['unchanged', 'hook', 'config', 'incarnation', 'handle'] as const)( + 'samples the actual created PTY after snapshot awaits and revalidates %s on reconnect', + async (change) => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + const providerSession = { key: 'session_id' as const, id: 'C' } + spawn.mockResolvedValue({ + id: 'effective-pty', + incarnationId: 'i1', + agentResumeUnavailable: true, + agentResumeRefused: 'chat-home', + savedResumeDecision: { agent: 'claude', providerSession, verdict: 'chat-home' } + }) + const terminal = await runtime.createTerminal('id:repo-1::/tmp/wt-cli', { + tabId: 'saved-tab', + leafId: '11111111-1111-4111-8111-111111111111', + launchAgent: 'claude', + resumeOrigin: 'saved', + resumeProviderSession: providerSession + }) + let snapshotStarted = false + let finishSnapshot = () => {} + let handle = terminal.handle + const harness = startDesktopMultiplexSubscribe({ + resolveLiveLeafForHandle: () => runtime.resolveLiveLeafForHandle(handle), + getPtyExecutionHostMetadata: runtime.getPtyExecutionHostMetadata.bind(runtime), + getTerminalLaunchResult: runtime.getTerminalLaunchResult.bind(runtime), + serializeTerminalBuffer: () => + new Promise((resolve) => { + snapshotStarted = true + finishSnapshot = () => resolve({ data: 'shell$ ', cols: 80, rows: 24 }) + }) + }) + try { + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => expect(snapshotStarted).toBe(true)) + if (change === 'hook') { + runtime.recordCliWriterConversation({ + paneKey: 'saved-tab:11111111-1111-4111-8111-111111111111', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + providerSession, + payload: { state: 'done', prompt: '', agentType: 'claude' }, + agentPresence: { + agent: 'claude', + process: { pid: 12, platform: 'darwin', startTime: 's' } + } + }) + } else if (change === 'config') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime owns this PTY map; mutate its actual record to model a replacement launch. + const records = (runtime as unknown as { ptysById: Map }) + .ptysById + const pty = records.get('effective-pty')! + pty.launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + } else if (change === 'incarnation') { + runtime.registerPty('effective-pty', 'repo-1::/tmp/wt-cli', null, { + tabId: 'saved-tab', + leafId: '11111111-1111-4111-8111-111111111111', + incarnationId: 'i2' + }) + } else if (change === 'handle') { + handle = 'stale-handle' + } + finishSnapshot() + const events = () => harness.messages.map((message) => JSON.parse(message).result) + const stale = change === 'handle' || change === 'incarnation' + await vi.waitFor(() => + expect(events().some((event) => event?.type === (stale ? 'end' : 'subscribed'))).toBe(true) + ) + if (stale) { + expect(events().some((event) => event?.type === 'subscribed')).toBe(false) + } else { + const result = events().find((event) => event?.type === 'subscribed').launchResult + expect(result).toMatchObject({ ptyId: 'effective-pty', incarnationId: 'i1' }) + expect(result.agentResumeRefused).toBe(change === 'unchanged' ? 'chat-home' : undefined) + harness.messages.splice(0) + snapshotStarted = false + sendDesktopMultiplexSubscribe(harness.handlers) + await vi.waitFor(() => expect(snapshotStarted).toBe(true)) + finishSnapshot() + await vi.waitFor(() => + expect(events().some((event) => event?.type === 'subscribed')).toBe(true) + ) + expect(events().find((event) => event?.type === 'subscribed').launchResult).toEqual(result) + } + } finally { + harness.registry.cleanupSubscription('terminal-multiplex:conn-desktop-first-paint') + await harness.dispatchPromise + } + } +) diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts index 041c4465d812..97a4b1100b65 100644 --- a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -121,6 +121,9 @@ describe('terminal stream extraction characterization', () => { outputBatcher: { push: vi.fn(), flush: vi.fn() } } as unknown as TerminalMultiplexStream const runtime = { + getPtyExecutionHostMetadata: () => ({}), + getTerminalLaunchResult: () => undefined, + resolveLiveLeafForHandle: () => ({ ptyId: stream.ptyId }), readTerminal: vi.fn(async () => { readCount += 1 if (readCount === 2) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index f027d5498dd1..e4ff1463d31d 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -20,7 +20,8 @@ export type MultiplexPublishedInitialState = { export async function publishMultiplexInitialSnapshot( state: TerminalMultiplexConnection, request: MultiplexSubscribeRequest, - stream: TerminalMultiplexStream + stream: TerminalMultiplexStream, + incarnationId = state.runtime.getPtyExecutionHostMetadata(stream.ptyId).incarnationId ): Promise { const { runtime, emit } = state const { ptyId } = stream @@ -54,7 +55,23 @@ export async function publishMultiplexInitialSnapshot( const layoutSeq = runtime.getLayout(ptyId)?.seq // Why: layout versions and output offsets are different sequence domains. const snapshotOutputSeq = serialized?.seq + // Snapshot awaits may outlive the handle binding or the physical shell. + if ( + runtime.resolveLiveLeafForHandle(request.terminal).ptyId !== ptyId || + runtime.getPtyExecutionHostMetadata(ptyId).incarnationId !== incarnationId + ) { + throw new Error('terminal_handle_stale') + } + let launchResult: ReturnType + try { + launchResult = runtime.getTerminalLaunchResult(ptyId) + } catch (error) { + console.warn('Terminal launch result unavailable', error) + } emit({ + ...(incarnationId && launchResult + ? { launchResult: { ...launchResult, ptyId, incarnationId } } + : {}), type: 'subscribed', streamId: request.streamId, terminal: request.terminal, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts index 3800fa08a056..345203f00ce5 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -21,6 +21,7 @@ export function installMultiplexSubscribeFrame(state: TerminalMultiplexConnectio if (!ptyId) { return } + const incarnationId = state.runtime.getPtyExecutionHostMetadata(ptyId).incarnationId let stream: TerminalMultiplexStream | null = null let installedStream: TerminalMultiplexStream | null = null try { @@ -46,7 +47,7 @@ export function installMultiplexSubscribeFrame(state: TerminalMultiplexConnectio return } try { - const published = await publishMultiplexInitialSnapshot(state, request, stream) + const published = await publishMultiplexInitialSnapshot(state, request, stream, incarnationId) if (!published) { return } diff --git a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts index c85e962d198a..b72a5295a3ba 100644 --- a/src/main/runtime/rpc/terminal-multiplex-test-harness.ts +++ b/src/main/runtime/rpc/terminal-multiplex-test-harness.ts @@ -25,6 +25,8 @@ export function stubRuntime(overrides: Partial = {}): OrcaRu // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. return { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({}), + getTerminalLaunchResult: () => undefined, subscribeToPtyExit: vi.fn(() => vi.fn()), // Why: every multiplex stream registers as a remote view subscriber for // Phase-5 query-authority suppression (terminal-query-authority.md). diff --git a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts index 65f2ff657ff6..6c82cebce155 100644 --- a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts +++ b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import type { OrcaRuntimeWithCreateTerminal } from '../launch/terminal-create' import { OrcaRuntimeWithGetTerminalInteractiveWait } from '../wait/terminal-interactive-wait' import type { @@ -160,6 +161,7 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith leafId: parsed.leafId, focus: false }).then((terminal) => ({ + ...terminalLaunchFacts(terminal), handle: terminal.handle, tabId: parsed.tabId, leafId: parsed.leafId, 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 9e72b3a717e0..49eb05fede87 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 @@ -1,4 +1,7 @@ -import { agentProviderSessionsEqual } from '../../../../../shared/agent-session-resume' +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { waitForTerminalReplayWritesParsed } from '../replay-guard' import { @@ -271,22 +274,14 @@ export function createReattachPayloadHandlers( ? null : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) if (preparedStartup && !ctx.coldRestoreStartup) { - const decision = ctx.connectResult.savedResumeDecision - if ( - !decision || - decision.verdict !== 'admitted' || - decision.agent !== preparedStartup.agent || - !agentProviderSessionsEqual( - decision.agent, - decision.providerSession, - preparedStartup.resumeProviderSession - ) - ) { + const rejected = applyTerminalLaunchResult( + session, + ctx.connectResult, + captureTerminalLaunchContext(session, preparedStartup), + 'candidate' + ) + if (rejected) { resumeUnavailable = true - session.cancelSavedResumeStartup(preparedStartup, { - ...ctx.connectResult, - agentResumeRefused: 'unverifiable' - }) preparedStartup = null } } 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 8d56d0b6f0e5..cdb084d84a79 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 @@ -91,7 +91,8 @@ function connectDeferredSessionReattach( } session.reportError(message) }, - toProcessExitStartup(coldRestoreStartup ?? session.paneStartup) + toProcessExitStartup(coldRestoreStartup ?? session.paneStartup), + coldRestoreStartup ?? session.paneStartup ) session.beginReattachLiveDataDeferral(outputCallbacks.generation) session.transportConnectInFlightSince = Date.now() @@ -190,7 +191,8 @@ function connectDeferredSessionReattach( result, deferredReattachSessionId, coldRestoreStartup, - outputCallbacks.generation + outputCallbacks.generation, + outputCallbacks.launchContext ) session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise 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 b28133297a6e..1073be09d973 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 @@ -1,3 +1,4 @@ +import { applyTerminalLaunchResult } from './terminal-launch-result-policy' import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { useAppStore } from '@/store' @@ -97,7 +98,8 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { const visibleAtSpawn = session.deps.isVisibleRef.current === true const outputCallbacks = session.captureTransportOutputCallbacks( session.reportError, - toProcessExitStartup(coldRestoreOverride ?? effectiveStartup) + toProcessExitStartup(coldRestoreOverride ?? effectiveStartup), + coldRestoreOverride ?? effectiveStartup ) const spawnedRaw = session.transport.connect({ url: '', @@ -178,7 +180,8 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { connectResult, null, coldRestoreOverride, - outputCallbacks.generation + outputCallbacks.generation, + outputCallbacks.launchContext ) session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise @@ -193,25 +196,20 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } return accepted ? resolvedPtyId : null } - // Why: main started a plain shell instead, so nothing of the plan may stay registered over it. - const rejectedSavedStartup = - connectResult?.agentResumeRefused || - (connectResult?.agentResumeUnavailable && effectiveStartup?.resumeSource) + const rejectedSavedStartup = connectResult + ? applyTerminalLaunchResult( + session, + connectResult, + outputCallbacks.launchContext, + 'creator' + ) + : false const coldRestore = rejectedSavedStartup ? null : coldRestoreOverride - if (connectResult && rejectedSavedStartup) { - session.cancelSavedResumeStartup(coldRestoreOverride ?? effectiveStartup, connectResult) - } - if (connectResult?.agentResumeRefused === 'unverifiable') { - // Why: the saved session may have been consumed before this spawn; keep it for the next try. - if (!(coldRestoreOverride ?? effectiveStartup)?.resumeSource?.record) { - session.keepUnrunResumeHandle(startupOverride) - } - } - if (spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId) { - session.registerEffectiveLaunchConfig(spawnedPtyId.launchConfig, { - ...(spawnedPtyId.launchToken ? { launchToken: spawnedPtyId.launchToken } : {}), - ...(spawnedPtyId.launchAgent ? { launchAgent: spawnedPtyId.launchAgent } : {}) - }) + if ( + connectResult?.agentResumeRefused === 'unverifiable' && + !(coldRestoreOverride ?? effectiveStartup)?.resumeSource?.record + ) { + session.keepUnrunResumeHandle(startupOverride) } if (resolvedPtyId) { if ( @@ -230,7 +228,6 @@ 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/reattach-payload-session.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-session.ts index 51bcec05157a..eecd3b9f04f6 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 @@ -2,6 +2,9 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' export type ReattachPayloadSession = Pick< ConnectPanePtySession, + | 'cacheKey' + | 'paneStartup' + | 'registerEffectiveLaunchConfig' | 'applyColdRestoreAgentResumeStartup' | 'applySnapshotKittyKeyboardModes' | 'buildColdRestoreAgentResumeStartup' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts index 73bb4ebba3f3..7d38b77b4864 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts @@ -24,7 +24,12 @@ const REMOTE_PTY_ID = 'remote:env-1@@pty-1' const HOST_IMAGE = { data: 'PROMPT $ ', cols: 80, rows: 24, seq: 7, source: 'headless' as const } const mocks = vi.hoisted(() => { - const state: Record = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + const state: Record = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } const capturedContexts: ReattachPayloadContext[] = [] const callOrder: string[] = [] return { state, capturedContexts, callOrder } @@ -150,7 +155,12 @@ function decidedContext(): ReattachPayloadContext { beforeEach(() => { vi.clearAllMocks() - mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.state = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } mocks.capturedContexts = [] mocks.callOrder = [] }) 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 9e21d8073aef..125ac464e057 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 @@ -1,3 +1,8 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext, + type TerminalLaunchContext +} from './terminal-launch-result-policy' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport-types' @@ -65,7 +70,11 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi result: PtyConnectResult | string | void, staleSessionId?: string | null, coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, - attemptGeneration = session.transportStreamGeneration + attemptGeneration = session.transportStreamGeneration, + launchContext: TerminalLaunchContext = captureTerminalLaunchContext( + sessionBag, + coldRestoreStartup ?? sessionBag.paneStartup + ) ): Promise => { if (session.disposed) { return false @@ -95,16 +104,10 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.remotePtyIncarnationId = null } - if ( - connectResult?.agentResumeRefused || - (connectResult?.agentResumeUnavailable && !connectResult.isReattach) - ) { - session.cancelSavedResumeStartup(coldRestoreStartup ?? sessionBag.paneStartup, connectResult) + if (connectResult) { + applyTerminalLaunchResult(sessionBag, connectResult, launchContext, 'creator') } 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 } @@ -141,14 +144,6 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi }) return false } - session.registerEffectiveLaunchConfig(connectResult?.launchConfig, { - ...(connectResult?.launchToken ? { launchToken: connectResult.launchToken } : {}), - ...(connectResult?.launchAgent - ? { launchAgent: connectResult.launchAgent } - : startup - ? { launchAgent: startup.agent } - : {}) - }) if (connectResult?.sessionExpired) { if (staleSessionId) { session.clearExitedPanePtyLayoutBinding(staleSessionId) @@ -183,10 +178,6 @@ 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 && diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts index efcbfda352a5..7e893b7f008b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-success-settle.test.ts @@ -15,7 +15,12 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' * the positive one. */ const mocks = vi.hoisted(() => ({ - state: { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + state: { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } })) vi.mock('@/store', () => ({ @@ -92,7 +97,12 @@ async function driveReattach( beforeEach(() => { vi.clearAllMocks() - mocks.state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + mocks.state = { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + agentLaunchConfigByPaneKey: {}, + agentStatusByPaneKey: {} + } }) describe('handleReattachResult recovery settle', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts index 5a14db5662bd..f995fadd0f2a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-startup-cancellation.ts @@ -17,7 +17,6 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes } source.cancelled = true const state = useAppStore.getState() - state.consumeTabStartupCommand(session.deps.tabId, startup) if (session.pendingStartupCommand?.resumeSource === source) { session.pendingStartupCommand = null if (session.startupInjectTimer !== null) { @@ -29,6 +28,11 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes session.deferredSpawn = null } const ownsPaneStartup = session.paneStartup?.resumeSource === source + if (ownsPaneStartup) { + session.paneStartup = null + session.startupDraftPrompt = null + } + state.consumeTabStartupCommand(session.deps.tabId, startup) const registered = session.savedStartupLaunchRegistration const ownsRegistration = registered?.source === source && @@ -36,11 +40,7 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes registered.entry === state.agentLaunchConfigByPaneKey[session.cacheKey] session.clearRegisteredStartupLaunchConfig(startup) const claim = state.automaticAgentResumeClaimsByTabId[session.deps.tabId] - if ( - session.paneStartup?.resumeSource === source && - claim && - claim === session.savedStartupAutomaticClaim - ) { + if (ownsPaneStartup && claim && claim === session.savedStartupAutomaticClaim) { useAppStore.setState((current) => { if (current.automaticAgentResumeClaimsByTabId[session.deps.tabId] !== claim) { return current @@ -79,8 +79,6 @@ export function installSavedResumeStartupCancellation(session: ConnectPanePtySes return { agentStatusByPaneKey: next } }) } - session.paneStartup = null - session.startupDraftPrompt = null session.cleanupStartupDraftPasteTimers?.() } if ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts new file mode 100644 index 000000000000..82e3ab6a4a87 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts @@ -0,0 +1,100 @@ +import { + agentProviderSessionsEqual, + type TerminalLaunchResult +} from '../../../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { PtyPaneStartup } from '../pty-connection-types' + +type LaunchResultSession = Pick< + ConnectPanePtySession, + | 'cacheKey' + | 'paneStartup' + | 'cancelSavedResumeStartup' + | 'registerEffectiveLaunchConfig' + | 'showSessionRestoredBanner' +> + +type SavedStartup = ColdRestoreAgentResumeStartup | PtyPaneStartup + +export function captureTerminalLaunchContext(session: LaunchResultSession, startup: SavedStartup) { + const state = useAppStore.getState() + return { + startup, + registration: state.agentLaunchConfigByPaneKey[session.cacheKey], + status: state.agentStatusByPaneKey[session.cacheKey], + paneStartup: session.paneStartup + } +} + +export type TerminalLaunchContext = ReturnType + +export function applyTerminalLaunchResult( + session: LaunchResultSession, + result: TerminalLaunchResult & { isReattach?: boolean }, + context: TerminalLaunchContext, + kind: 'creator' | 'observer' | 'candidate' +): boolean { + const startup = context.startup + const decision = result.savedResumeDecision + const agent = startup && ('agent' in startup ? startup.agent : startup.launchAgent) + const matches = Boolean( + startup && + decision && + decision.agent === agent && + agentProviderSessionsEqual( + decision.agent, + decision.providerSession, + startup.resumeProviderSession + ) + ) + const candidateRejected = kind === 'candidate' && (!matches || decision?.verdict !== 'admitted') + const rejected = Boolean( + result.agentResumeRefused || result.agentResumeUnavailable || candidateRejected + ) + const state = useAppStore.getState() + const currentStartup = session.paneStartup + const currentRegistration = state.agentLaunchConfigByPaneKey[session.cacheKey] + const superseded = + (kind === 'observer' && startup && decision && !matches) || + (currentStartup && + currentStartup !== context.paneStartup && + currentStartup.resumeSource !== startup?.resumeSource) || + (currentRegistration && currentRegistration !== context.registration) || + (state.agentStatusByPaneKey[session.cacheKey] && + state.agentStatusByPaneKey[session.cacheKey] !== context.status) + try { + if ( + rejected && + (!result.isReattach || result.agentResumeRefused) && + (kind !== 'observer' || matches) + ) { + session.cancelSavedResumeStartup( + startup, + candidateRejected ? { ...result, agentResumeRefused: 'unverifiable' } : result + ) + } + } catch (error) { + console.warn('Terminal saved startup cancellation failed', error) + } + try { + if (!superseded && kind !== 'candidate') { + session.registerEffectiveLaunchConfig(result.launchConfig, { + launchToken: result.launchToken, + launchAgent: + result.launchAgent ?? + (!rejected && !result.isReattach && startup && 'agent' in startup + ? startup.agent + : undefined) + }) + } + } catch (error) { + // A bound shell remains usable even when exact-source bookkeeping fails. + console.warn('Terminal launch result bookkeeping failed', error) + } + if (rejected && !superseded) { + session.showSessionRestoredBanner('resume-unavailable') + } + return rejected +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts index a217b2b66c49..99b594ed556d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/transport-output-callbacks.ts @@ -1,3 +1,9 @@ +import { + applyTerminalLaunchResult, + captureTerminalLaunchContext +} from './terminal-launch-result-policy' +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' +import type { TerminalAttachmentLaunchResult } from '../../../../../shared/terminal-attachment-launch-result' import type { SpawnDeferralReason } from '../../../../../shared/host-spawn-admission' import { createTerminalStartupTiming } from '../terminal-startup-timing' import type { PtyReplayDataMeta, PtyTransportRecoveryState } from '../pty-transport-types' @@ -10,8 +16,10 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySession): void { session.captureTransportOutputCallbacks = ( onError: (message: string) => void, - startup: PtyPaneStartup + startup: PtyPaneStartup, + savedStartup: ColdRestoreAgentResumeStartup | PtyPaneStartup = session.paneStartup ) => { + const launchContext = captureTerminalLaunchContext(session, savedStartup) // Why: a new stream generation cannot inherit an old replay's pending // destination-grid fit or keep its live-data waiter open. session.pendingHiddenSnapshotFit?.cancel() @@ -39,7 +47,18 @@ export function bindCaptureTransportOutputCallbacks(session: ConnectPanePtySessi }) return { generation, + launchContext, callbacks: { + onLaunchResult: (result: TerminalAttachmentLaunchResult): void => { + if ( + isCurrent() && + (!session.remotePtyIncarnationId || + session.remotePtyIncarnationId === result.incarnationId) + ) { + session.remotePtyIncarnationId = result.incarnationId + applyTerminalLaunchResult(session, result, launchContext, 'observer') + } + }, onReattachDetermined: (): void => { if (isCurrent()) { session.beginReattachLiveDataDeferralIfUnowned(generation) 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 b39253244082..b14f471f2394 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,3 +1,4 @@ +import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' import type { TerminalLaunchResult } from '../../../../shared/agent-session-resume' import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { @@ -135,6 +136,7 @@ export type PtyConnectResult = TerminalLaunchResult & { } type PtyCallbacks = { + onLaunchResult?: (result: TerminalAttachmentLaunchResult) => void /** Called before an adopted PTY can publish buffered/live bytes. */ onReattachDetermined?: () => void onConnect?: () => void diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 41cb3ca5539d..0926193fe9f0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1,3 +1,4 @@ +import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' @@ -1511,6 +1512,7 @@ export function createRemoteRuntimePtyTransport( if (!expiredHandle || !tabId || !leafId || !worktreeId || recoveringPaneHandle) { return } + const recoverySubscriptionGeneration = subscriptionGeneration recoveringPaneHandle = expiredHandle connected = false clearPendingViewportClaim() @@ -1547,6 +1549,22 @@ export function createRemoteRuntimePtyTransport( onPtyRebind?.(remotePtyId, replacedPtyId) } } + const launchResult = parseTerminalAttachmentLaunchResult({ + ...terminalLaunchFacts(terminal), + ptyId: terminal.ptyId, + incarnationId: terminal.incarnationId + }) + if ( + launchResult && + recoverySubscriptionGeneration === subscriptionGeneration && + currentPaneRevision() === getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId) + ) { + try { + storedCallbacks.onLaunchResult?.(launchResult) + } catch (error) { + console.warn('Terminal launch result bookkeeping failed', error) + } + } await subscribeToHandle() }) .catch((error) => { @@ -2654,8 +2672,23 @@ export function createRemoteRuntimePtyTransport( storedCallbacks.onPausedOutputDropped?.() } }, - onSubscribeAcknowledged: () => { + onSubscribeAcknowledged: (result) => { if (isCurrentSubscription()) { + if ( + result && + (!authoritativePtyIncarnationId || + result.incarnationId === authoritativePtyIncarnationId) + ) { + authoritativePtyIncarnationId = result.incarnationId + try { + storedCallbacks.onLaunchResult?.(result) + } catch (error) { + console.warn('Terminal launch result bookkeeping failed', error) + } + } + if (!isCurrentSubscription()) { + return + } inputReadySubscription = generation if ( installedStream && diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts new file mode 100644 index 000000000000..77b54143df2e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' +import type { ConnectPanePtySession } from './pty-connection/connect-pane-pty-session' +import type { AgentLaunchConfigRegistryEntry } from '@/store/slices/agent-status-contract' +import { bindCaptureTransportOutputCallbacks } from './pty-connection/transport-output-callbacks' +import { bindSettlePaneSerializer } from './pty-connection/pane-serializer-settle' +import { applyTerminalLaunchResult } from './pty-connection/terminal-launch-result-policy' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { installSavedResumeStartupCancellation } from './pty-connection/saved-resume-startup-cancellation' +import { + createRemoteRuntimeTransportMocks, + type MultiplexSubscriptionCallbacks +} from './remote-runtime-pty-transport-test-harness' + +const fixture = vi.hoisted(() => ({ state: {} })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => fixture.state } })) +let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null +let resolvedPaneHandle = 'terminal-1' +const { + runtimeCall, + subscriptionSendBinary, + latestSubscribePayload, + emitSnapshot, + inputFrameTexts, + resetRemoteRuntimeTransport +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) +beforeEach(resetRemoteRuntimeTransport) +afterEach(() => vi.clearAllMocks()) + +it.each([ + 'refused', + 'adopted', + 'unavailable', + 'legacy', + 'malformed', + 'bookkeeping-error', + 'new-C', + 'new-D', + 'stale-generation' +] as const)( + 'delivers the actual mirror %s result to the pane without replaying or clearing newer work', + async (kind) => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + const record = { + paneKey: 'web-terminal-host-tab-1:pane:1', + tabId: 'web-terminal-host-tab-1', + worktreeId: 'wt-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + connectionId: null, + state: 'done' as const, + prompt: '', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' as const + } + const source = { cancelled: false, record } + const startup = { + command: 'codex resume C', + launchAgent: 'codex' as const, + resumeProviderSession: record.providerSession, + resumeOrigin: 'saved' as const, + resumeSource: source + } + const registry: Record = {} + const records = { [record.paneKey]: record } + const clearRecord = vi.fn((key: string) => { + delete records[key] + }) + fixture.state = { + agentLaunchConfigByPaneKey: registry, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: records, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {}, + tabsByWorktree: {}, + consumeTabStartupCommand: vi.fn(() => { + if (kind === 'bookkeeping-error') { + throw new Error('bookkeeping failed') + } + }), + clearSleepingAgentSession: clearRecord + } + const note = vi.fn() + const register = vi.fn() + const callbacksReady = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real callback and cancellation installers use the supplied session members. + const session = { + transport, + pane: { id: 1, leafId: 'pane:1', container: { dataset: {} }, terminal: {} }, + deps: { + worktreeId: 'wt-1', + tabId: record.tabId, + isVisibleRef: { current: true }, + onShowSessionRestoredBanner: note, + paneTransportsRef: { current: new Map([[1, transport]]) } + }, + cacheKey: record.paneKey, + paneStartup: kind === 'observer-only' ? null : startup, + pendingStartupCommand: startup, + shouldDeliverStartupViaTerminalPaste: true, + startupInjectTimer: null, + transportStreamGeneration: 0, + disposed: false, + createProcessExitState: () => ({ detector: { observe: vi.fn() } }), + clearRegisteredStartupLaunchConfig: vi.fn(), + registerEffectiveLaunchConfig: register, + showSessionRestoredBanner: note, + reportRemoteRendererSerializerReady: callbacksReady, + syncHiddenRendererPtyDelivery: vi.fn(), + handleRemoteOutputPauseChanged: vi.fn(), + markHiddenOutputRestoreNeeded: vi.fn(), + beginReattachLiveDataDeferralIfUnowned: vi.fn(), + replayDataCallback: vi.fn(), + dataCallback: vi.fn() + } as unknown as ConnectPanePtySession + installSavedResumeStartupCancellation(session) + bindSettlePaneSerializer(session) + session.reportRemoteRendererSerializerReady = callbacksReady + bindCaptureTransportOutputCallbacks(session) + const output = session.captureTransportOutputCallbacks(vi.fn(), null) + const actualConfig = { agentCommand: 'codex', agentArgs: '--model actual', agentEnv: {} } + const launchResult: TerminalAttachmentLaunchResult = { + ptyId: 'physical-1', + incarnationId: 'incarnation-1', + ...(kind === 'adopted' + ? {} + : { + savedResumeDecision: { + agent: 'codex', + providerSession: record.providerSession, + verdict: kind === 'unavailable' ? 'admitted' : 'chat-home' + }, + agentResumeUnavailable: true, + ...(kind === 'unavailable' ? {} : { agentResumeRefused: 'chat-home' }) + }), + ...(kind === 'adopted' || kind === 'unavailable' + ? { + launchAgent: 'codex', + launchToken: 'actual-token', + launchConfig: actualConfig + } + : {}) + } + subscriptionSendBinary.mockImplementation((bytes) => { + if (decodeTerminalStreamFrame(bytes)?.opcode !== TerminalStreamOpcode.Subscribe) { + return + } + if (kind === 'new-C' || kind === 'new-D') { + const newer = { ...startup, resumeSource: { cancelled: false } } + session.paneStartup = newer + session.pendingStartupCommand = newer + registry[record.paneKey] = { launchConfig: actualConfig, identity: {}, registeredAt: 20 } + records[record.paneKey] = { ...record, updatedAt: 20 } + } + if (kind === 'stale-generation') { + session.transportStreamGeneration += 1 + } + const { streamId, terminal } = latestSubscribePayload() + // Arrive synchronously while subscribeTerminal is still installing the stream. + if (kind === 'ackless') { + return + } + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'subscribed', + streamId, + terminal, + ...(kind === 'legacy' + ? {} + : { + launchResult: + kind === 'malformed' + ? { ...launchResult, savedResumeDecision: { agent: 'codex' } } + : launchResult + }) + } + }) + }) + try { + transport.attach({ existingPtyId: 'remote:env-1@@terminal-1', callbacks: output.callbacks }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(callbacksReady).not.toHaveBeenCalled() + const { streamId } = latestSubscribePayload() + const resolvesBeforeSnapshot = runtimeCall.mock.calls.filter( + ([request]) => request.method === 'terminal.resolvePane' + ).length + transport.sendInput('ordinary-input', 'driving') + if (kind === 'refused' || kind === 'bookkeeping-error' || kind === 'unavailable') { + expect(session.pendingStartupCommand).toBeNull() + } + emitSnapshot(streamId, 'shell$ ') + await vi.waitFor(() => expect(inputFrameTexts().join('')).toContain('ordinary-input')) + if (kind !== 'stale-generation') { + expect(callbacksReady).toHaveBeenCalledTimes(1) + } + const refused = + kind === 'refused' || kind === 'bookkeeping-error' || kind === 'new-C' || kind === 'new-D' + expect(source.cancelled).toBe(refused || kind === 'unavailable') + expect(note).toHaveBeenCalledTimes( + kind === 'refused' || + kind === 'bookkeeping-error' || + kind === 'observer-only' || + kind === 'unavailable' + ? 1 + : 0 + ) + expect(clearRecord).toHaveBeenCalledTimes( + kind === 'refused' || kind === 'unavailable' ? 1 : 0 + ) + if (kind === 'new-C' || kind === 'new-D') { + expect(session.paneStartup?.resumeSource?.cancelled).toBe(false) + expect(session.pendingStartupCommand).toBe(session.paneStartup) + expect(records[record.paneKey].updatedAt).toBe(20) + expect(register).not.toHaveBeenCalled() + } else if (kind === 'adopted' || kind === 'unavailable') { + expect(register).toHaveBeenCalledWith(actualConfig, { + launchAgent: 'codex', + launchToken: 'actual-token' + }) + } else if (kind === 'refused' || kind === 'observer-only') { + expect(session.pendingStartupCommand).toBeNull() + expect(session.paneStartup).toBeNull() + expect(register).toHaveBeenCalledWith(undefined, { + launchAgent: undefined, + launchToken: undefined + }) + } else if (kind !== 'bookkeeping-error') { + expect(register).not.toHaveBeenCalled() + } + if (kind === 'refused' || kind === 'unavailable') { + // The returning creator repeats the acknowledgment; existing note idempotence survives. + applyTerminalLaunchResult(session, launchResult, output.launchContext, 'creator') + expect(note).toHaveBeenCalledTimes(1) + } + expect(inputFrameTexts().join('')).not.toContain('codex resume C') + expect( + runtimeCall.mock.calls.filter(([request]) => request.method === 'terminal.resolvePane') + ).toHaveLength(resolvesBeforeSnapshot) + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.send')).toBe( + false + ) + } finally { + transport.disconnect() + } + } +) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index 9a6af8dc9c9b..3e652559679b 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -1,3 +1,4 @@ +import type { TerminalAttachmentLaunchResult } from '../../../shared/terminal-attachment-launch-result' import type { TerminalSnapshotUnavailableReason } from '../../../shared/terminal-snapshot-unavailability' import type { TerminalStreamEndVerdict } from '../../../shared/terminal-stream-end-verdict' import type { TerminalInputAckKind } from '../../../shared/terminal-stream-protocol' @@ -12,6 +13,8 @@ export type TerminalMultiplexEvent = | { type: 'ready' } | { type: 'subscribed' + terminal?: string + launchResult?: unknown streamId: number streamGeneration?: string capabilities?: { @@ -58,7 +61,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { ) => void onSubscribed?: () => void /** The host installed the stream and named its capabilities; its initial snapshot is still on the way. */ - onSubscribeAcknowledged?: () => void + onSubscribeAcknowledged?: (result?: TerminalAttachmentLaunchResult) => void onOutputPauseCapability?: () => void /** The host discarded output while this stream was paused (negotiated streams only). */ onPausedOutputDropped?: () => void diff --git a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts index 6a32666c261a..6ecea138c939 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-response-controller.ts @@ -1,3 +1,4 @@ +import { parseTerminalAttachmentLaunchResult } from '../../../shared/terminal-attachment-launch-result' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR } from '../../../shared/terminal-multiplex-flow-control' import { parseTerminalStreamEndVerdict } from '../../../shared/terminal-stream-end-verdict' @@ -99,7 +100,11 @@ export abstract class RemoteRuntimeTerminalResponseController extends RemoteRunt stream.callbacks.onOutputPauseCapability?.() } if (!stream.initialSnapshotReceived) { - stream.callbacks.onSubscribeAcknowledged?.() + stream.callbacks.onSubscribeAcknowledged?.( + event.terminal === stream.terminal + ? parseTerminalAttachmentLaunchResult(event.launchResult) + : undefined + ) } } else if (event.type === 'end') { disposeRemoteTerminalStreamState(stream, 'Remote terminal stream ended.') diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 42fd21cbde3b..30f161c9219e 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -300,7 +300,7 @@ export type RuntimeTerminalSplit = { leafId?: string } -export type RuntimeTerminalResolvePane = { +export type RuntimeTerminalResolvePane = TerminalLaunchResult & { handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string | null diff --git a/src/shared/terminal-attachment-launch-result.ts b/src/shared/terminal-attachment-launch-result.ts new file mode 100644 index 000000000000..21a050d9b523 --- /dev/null +++ b/src/shared/terminal-attachment-launch-result.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import type { TerminalLaunchResult } from './agent-session-resume' +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' +import { ResumeProviderSessionParam } from './rpc-contract/terminal-unary-params' +import { sleepingAgentLaunchConfigBaseSchema } from './workspace-session-sleeping-agents' + +export type TerminalAttachmentLaunchResult = TerminalLaunchResult & { + ptyId: string + incarnationId: string +} + +const launchResultSchema = z.object({ + ptyId: z.string().min(1), + incarnationId: z.string().min(1), + savedResumeDecision: z + .object({ + agent: z.string().min(1), + providerSession: ResumeProviderSessionParam, + verdict: z.enum(['admitted', 'chat-home', 'chat-writing', 'unverifiable']) + }) + .optional(), + agentResumeUnavailable: z.literal(true).optional(), + agentResumeRefused: z.enum(['chat-home', 'chat-writing', 'unverifiable']).optional(), + refusedResume: z + .object({ + agent: z.string().min(1), + providerSession: ResumeProviderSessionParam, + expectedStatus: z + .object({ + paneKey: z.string().min(1), + receivedAt: z.number().finite(), + stateStartedAt: z.number().finite() + }) + .optional(), + expectedSavedRecord: z + .object({ + paneKey: z.string().min(1), + capturedAt: z.number().finite(), + updatedAt: z.number().finite() + }) + .optional() + }) + .optional(), + launchConfig: sleepingAgentLaunchConfigBaseSchema.optional(), + launchAgent: z.custom(isTuiAgent).optional(), + launchToken: z.string().min(1).optional(), + resumeProviderSession: ResumeProviderSessionParam.optional() +}) + +export function parseTerminalAttachmentLaunchResult( + value: unknown +): TerminalAttachmentLaunchResult | undefined { + const result = launchResultSchema.safeParse(value) + return result.success ? result.data : undefined +} diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index f27b7c53823d..1f17e1e89d97 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -63,7 +63,7 @@ const sleepingAgentLaunchEnvSchema = z.preprocess( z.record(z.string(), z.string()) ) -const sleepingAgentLaunchConfigBaseSchema = z.object({ +export const sleepingAgentLaunchConfigBaseSchema = z.object({ agentCommand: z.string().optional(), agentArgs: z.string(), agentEnv: sleepingAgentLaunchEnvSchema, From 772cd377f85a2d0fa50e525e3ca5a8fb996a7249 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:42:11 -0700 Subject: [PATCH 007/207] fix: preserve terminal launch callback and candidate ownership --- ...rminal-create-host-session-binding.test.ts | 9 ++ .../terminal-multiplex-initial-snapshot.ts | 2 +- .../terminal-launch-result-policy.ts | 2 +- .../remote-runtime-pty-transport.ts | 28 +++- ...mote-runtime-saved-result-consumer.test.ts | 131 +++++++++++++++++- 5 files changed, 159 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 2091b9c8579f..9a87b49c1cae 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -9,6 +9,11 @@ import { } from './rpc/terminal-multiplex-test-harness' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { OrcaRuntimeService } from './orca-runtime' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }, @@ -198,6 +203,10 @@ it.each(['unchanged', 'hook', 'config', 'incarnation', 'handle'] as const)( ) if (stale) { expect(events().some((event) => event?.type === 'subscribed')).toBe(false) + const error = harness.binaryFrames + .map(decodeTerminalStreamFrame) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Error) + expect(error && decodeTerminalStreamText(error.payload)).toBe('terminal_handle_stale') } else { const result = events().find((event) => event?.type === 'subscribed').launchResult expect(result).toMatchObject({ ptyId: 'effective-pty', incarnationId: 'i1' }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index e4ff1463d31d..92a4966923ab 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -57,7 +57,7 @@ export async function publishMultiplexInitialSnapshot( const snapshotOutputSeq = serialized?.seq // Snapshot awaits may outlive the handle binding or the physical shell. if ( - runtime.resolveLiveLeafForHandle(request.terminal).ptyId !== ptyId || + runtime.resolveLiveLeafForHandle(request.terminal)?.ptyId !== ptyId || runtime.getPtyExecutionHostMetadata(ptyId).incarnationId !== incarnationId ) { throw new Error('terminal_handle_stale') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts index 82e3ab6a4a87..487be5006014 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts @@ -68,7 +68,7 @@ export function applyTerminalLaunchResult( if ( rejected && (!result.isReattach || result.agentResumeRefused) && - (kind !== 'observer' || matches) + (matches || kind === 'candidate' || (kind === 'creator' && !result.isReattach)) ) { session.cancelSavedResumeStartup( startup, diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 0926193fe9f0..8fdfd760e6e4 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1513,6 +1513,10 @@ export function createRemoteRuntimePtyTransport( return } const recoverySubscriptionGeneration = subscriptionGeneration + const recoveryLifecycleEpoch = lifecycleEpoch + const recoveryCallbacks = storedCallbacks + const ownsRecoveryLifecycle = (): boolean => + recoveryLifecycleEpoch === lifecycleEpoch && recoveryCallbacks === storedCallbacks recoveringPaneHandle = expiredHandle connected = false clearPendingViewportClaim() @@ -1524,7 +1528,7 @@ export function createRemoteRuntimePtyTransport( expectedTerminal: expiredHandle }) .then(async ({ terminal }) => { - if (destroyed || handle !== expiredHandle) { + if (destroyed || handle !== expiredHandle || !ownsRecoveryLifecycle()) { return } const previousIncarnationId = authoritativePtyIncarnationId @@ -1549,6 +1553,9 @@ export function createRemoteRuntimePtyTransport( onPtyRebind?.(remotePtyId, replacedPtyId) } } + if (!ownsRecoveryLifecycle()) { + return + } const launchResult = parseTerminalAttachmentLaunchResult({ ...terminalLaunchFacts(terminal), ptyId: terminal.ptyId, @@ -1556,19 +1563,22 @@ export function createRemoteRuntimePtyTransport( }) if ( launchResult && + ownsRecoveryLifecycle() && recoverySubscriptionGeneration === subscriptionGeneration && currentPaneRevision() === getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId) ) { try { - storedCallbacks.onLaunchResult?.(launchResult) + recoveryCallbacks.onLaunchResult?.(launchResult) } catch (error) { console.warn('Terminal launch result bookkeeping failed', error) } } - await subscribeToHandle() + if (ownsRecoveryLifecycle()) { + await subscribeToHandle() + } }) .catch((error) => { - if (!destroyed && handle === expiredHandle) { + if (!destroyed && handle === expiredHandle && ownsRecoveryLifecycle()) { surfaceErrorMessage(runtimeTerminalErrorMessage(error)) } }) @@ -2585,6 +2595,10 @@ export function createRemoteRuntimePtyTransport( } const subscribedHandle = handle const subscribedPtyId = remotePtyId + const subscribedLifecycleEpoch = lifecycleEpoch + const subscribedCallbacks = storedCallbacks + const ownsSubscriptionLifecycle = (): boolean => + subscribedLifecycleEpoch === lifecycleEpoch && subscribedCallbacks === storedCallbacks const generation = ++subscriptionGeneration setAttachmentReady(false) let transportClosed = false @@ -2597,6 +2611,7 @@ export function createRemoteRuntimePtyTransport( const isCurrentSubscription = (): boolean => !transportClosed && generation === subscriptionGeneration && + ownsSubscriptionLifecycle() && (expectedRecoveryEpoch === undefined || recovery.ownsEpoch(expectedRecoveryEpoch)) && isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId) if (currentPaneRevision() !== getRuntimeEnvironmentRevision(currentRuntimeEnvironmentId)) { @@ -2681,7 +2696,7 @@ export function createRemoteRuntimePtyTransport( ) { authoritativePtyIncarnationId = result.incarnationId try { - storedCallbacks.onLaunchResult?.(result) + subscribedCallbacks.onLaunchResult?.(result) } catch (error) { console.warn('Terminal launch result bookkeeping failed', error) } @@ -2818,7 +2833,7 @@ export function createRemoteRuntimePtyTransport( }, onTransportClose: ({ recoverable, retryWithBackoff }) => { transportClosed = true - if (generation !== subscriptionGeneration) { + if (generation !== subscriptionGeneration || !ownsSubscriptionLifecycle()) { return } if (!isCurrentSubscription()) { @@ -2849,6 +2864,7 @@ export function createRemoteRuntimePtyTransport( }) if ( transportClosed || + !ownsSubscriptionLifecycle() || generation !== subscriptionGeneration || (expectedRecoveryEpoch !== undefined && !recovery.ownsEpoch(expectedRecoveryEpoch)) || destroyed || diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts index 77b54143df2e..1418390a5989 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-saved-result-consumer.test.ts @@ -42,8 +42,11 @@ afterEach(() => vi.clearAllMocks()) it.each([ 'refused', 'adopted', + 'adopted-D', + 'observer-only', 'unavailable', 'legacy', + 'ackless', 'malformed', 'bookkeeping-error', 'new-C', @@ -114,7 +117,7 @@ it.each([ }, cacheKey: record.paneKey, paneStartup: kind === 'observer-only' ? null : startup, - pendingStartupCommand: startup, + pendingStartupCommand: kind === 'observer-only' ? null : startup, shouldDeliverStartupViaTerminalPaste: true, startupInjectTimer: null, transportStreamGeneration: 0, @@ -145,13 +148,14 @@ it.each([ : { savedResumeDecision: { agent: 'codex', - providerSession: record.providerSession, + providerSession: + kind === 'adopted-D' ? { key: 'session_id', id: 'D' } : record.providerSession, verdict: kind === 'unavailable' ? 'admitted' : 'chat-home' }, agentResumeUnavailable: true, ...(kind === 'unavailable' ? {} : { agentResumeRefused: 'chat-home' }) }), - ...(kind === 'adopted' || kind === 'unavailable' + ...(kind === 'adopted' || kind === 'adopted-D' || kind === 'unavailable' ? { launchAgent: 'codex', launchToken: 'actual-token', @@ -164,11 +168,19 @@ it.each([ return } if (kind === 'new-C' || kind === 'new-D') { - const newer = { ...startup, resumeSource: { cancelled: false } } + const providerSession = { + key: 'session_id' as const, + id: kind === 'new-D' ? 'D' : 'C' + } + const newer = { + ...startup, + resumeProviderSession: providerSession, + resumeSource: { cancelled: false } + } session.paneStartup = newer session.pendingStartupCommand = newer registry[record.paneKey] = { launchConfig: actualConfig, identity: {}, registeredAt: 20 } - records[record.paneKey] = { ...record, updatedAt: 20 } + records[record.paneKey] = { ...record, providerSession, updatedAt: 20 } } if (kind === 'stale-generation') { session.transportStreamGeneration += 1 @@ -205,6 +217,11 @@ it.each([ ([request]) => request.method === 'terminal.resolvePane' ).length transport.sendInput('ordinary-input', 'driving') + if (kind === 'ackless') { + expect(inputFrameTexts().join('')).not.toContain('ordinary-input') + } else { + await vi.waitFor(() => expect(inputFrameTexts().join('')).toContain('ordinary-input')) + } if (kind === 'refused' || kind === 'bookkeeping-error' || kind === 'unavailable') { expect(session.pendingStartupCommand).toBeNull() } @@ -247,6 +264,22 @@ it.each([ } else if (kind !== 'bookkeeping-error') { expect(register).not.toHaveBeenCalled() } + if (kind === 'adopted-D') { + applyTerminalLaunchResult( + session, + { ...launchResult, isReattach: true }, + output.launchContext, + 'creator' + ) + expect(source.cancelled).toBe(false) + expect(session.pendingStartupCommand?.resumeSource).toBe(source) + expect(records[record.paneKey]).toBe(record) + expect(clearRecord).not.toHaveBeenCalled() + expect(register).toHaveBeenCalledWith(actualConfig, { + launchAgent: 'codex', + launchToken: 'actual-token' + }) + } if (kind === 'refused' || kind === 'unavailable') { // The returning creator repeats the acknowledgment; existing note idempotence survives. applyTerminalLaunchResult(session, launchResult, output.launchContext, 'creator') @@ -267,3 +300,91 @@ it.each([ } } ) + +it.each(['subscription', 'recovery'] as const)( + 'rejects predecessor %s facts while a successor connect owns the callback bundle', + async (operation) => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + const predecessorResult = vi.fn() + const successorResult = vi.fn() + const launchResult: TerminalAttachmentLaunchResult = { + ptyId: 'physical-1', + incarnationId: 'incarnation-1', + agentResumeRefused: 'chat-home', + savedResumeDecision: { + agent: 'codex', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + let finishRecovery: (value: unknown) => void = () => {} + const recoveryResult = new Promise((resolve) => { + finishRecovery = resolve + }) + let finishActivation: (value: unknown) => void = () => {} + const activationResult = new Promise((resolve) => { + finishActivation = resolve + }) + try { + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + callbacks: { onLaunchResult: predecessorResult } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId, terminal } = latestSubscribePayload() + const defaultCall = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation((request: { method: string }) => { + if (request.method === 'terminal.recoverPane') { + return recoveryResult + } + if (request.method === 'session.tabs.activate') { + return activationResult + } + return defaultCall?.(request) + }) + if (operation === 'recovery') { + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'error', streamId, message: 'SSH_SESSION_EXPIRED: relay changed' } + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.recoverPane' }) + ) + ) + } + const successor = transport.connect({ + url: '', + callbacks: { onLaunchResult: successorResult } + }) + await vi.waitFor(() => + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.activate' }) + ) + ) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'subscribed', streamId, terminal, launchResult } + }) + finishRecovery({ + ok: true, + result: { terminal: { handle: terminal, ...launchResult } } + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(predecessorResult).not.toHaveBeenCalled() + expect(successorResult).not.toHaveBeenCalled() + transport.disconnect() + finishActivation({ ok: true, result: {} }) + await successor + } finally { + transport.disconnect() + finishRecovery({ ok: true, result: {} }) + finishActivation({ ok: true, result: {} }) + } + } +) From 08a9b6ae83e4fe411d611fa7b365e051ae6cf5f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:34:54 -0700 Subject: [PATCH 008/207] feat(native-chat): host op and claim for switching a Codex chat to its terminal in place One enablement source (Codex) drives same-id terminal resume, the per-agent switch capability and History. agentSession.switchToTerminal admits only an idle chat, registers the switch claim in the chat's ordered step, stops the agent with proven exit while the tab stays, and returns the resume recipe. The claim refuses a chat start until the switch terminal's own spawn binds it; the bound proof ends only when the agent that terminal ran is proven exited and the pane is shell alone. --- src/main/ai-vault/structured-chat-cli-fork.ts | 42 ++++- .../structured-session-ownership.test.ts | 41 +++-- .../ai-vault/structured-session-ownership.ts | 8 +- .../pty/host-env/resume-spawn-judgement.ts | 136 +++++++++++++++ .../pty/host-env/saved-resume-admission.ts | 119 +++---------- src/main/ipc/pty/ipc/spawn-run.ts | 3 + src/main/ipc/pty/runtime/spawn-state.ts | 2 +- src/main/ipc/pty/runtime/spawn.ts | 5 +- .../structured-agent-session-acquisition.ts | 6 +- .../structured-agent-session-adapter.ts | 1 + ...red-agent-session-conversation-lifetime.ts | 13 +- .../structured-agent-session-failure-text.ts | 3 +- .../structured-agent-session-host.ts | 2 + .../structured-agent-session-idle-sweep.ts | 2 +- ...ructured-agent-session-switch-admission.ts | 43 +++++ .../structured-provider-session-ownership.ts | 5 +- src/main/runtime/rpc/methods/session-tabs.ts | 1 + ...ession-gate-classification.test-fixture.ts | 1 + ...ctured-agent-session-switch-to-terminal.ts | 65 +++++++ .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 4 +- .../runtime-pty-controller-contract.ts | 2 +- .../runtime/runtime-terminal-contracts.ts | 2 +- .../runtime/runtime-terminal-state-records.ts | 3 + .../structured-session-cli-writer-host.ts | 93 ++++++---- .../structured-session-switch-claims.ts | 162 ++++++++++++++++++ .../structured-session-switch-observation.ts | 68 ++++++++ .../terminal/launch/mobile-terminal-create.ts | 1 + .../launch/mobile-terminal-owned-create.ts | 2 +- .../launch/mobile-terminal-renderer-create.ts | 48 +++--- .../agent-session-failure-words-text.ts | 5 + src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 2 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/protocol-version.ts | 2 + .../rpc-params-catalog.generated.ts | 1 + .../session-tabs-schemas-params.ts | 9 + ...uctured-agent-cli-conversation-adoption.ts | 19 ++ ...ctured-agent-session-dispatch-rejection.ts | 2 + 41 files changed, 743 insertions(+), 188 deletions(-) create mode 100644 src/main/ipc/pty/host-env/resume-spawn-judgement.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts create mode 100644 src/main/runtime/session-tabs/structured-session-switch-claims.ts create mode 100644 src/main/runtime/session-tabs/structured-session-switch-observation.ts diff --git a/src/main/ai-vault/structured-chat-cli-fork.ts b/src/main/ai-vault/structured-chat-cli-fork.ts index de35351f6479..a7935d8cf0a2 100644 --- a/src/main/ai-vault/structured-chat-cli-fork.ts +++ b/src/main/ai-vault/structured-chat-cli-fork.ts @@ -70,13 +70,13 @@ export function isStructuredChatCliForkSourceCurrent( ) } -/** Prepares the record's current conversation on its execution host, without a History inventory. */ -export async function prepareStructuredChatCliFork( - args: AiVaultPrepareChatForkArgs, - prepare: AiVaultSessionResumePreparation -): Promise { +/** The chat's current conversation on this host, as its CLI would open it; `fork` keeps its copy rules. */ +export async function resolveStructuredChatCliSource( + sourceChat: AiVaultPrepareChatForkArgs['sourceChat'], + fork: boolean +) { const host = getStructuredAgentSessionHost() - const record = host?.deps.store.getRecord(args.sourceChat.sessionId) + const record = host?.deps.store.getRecord(sourceChat.sessionId) const registration = record && structuredAgentRuntimeRegistration(record.provider) const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain) if ( @@ -84,13 +84,13 @@ export async function prepareStructuredChatCliFork( !record || !registration || !head || - record.location.workspaceId !== args.sourceChat.workspaceId || + record.location.workspaceId !== sourceChat.workspaceId || record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || record.location.wslDistro !== null || // A typed locator has no CODEX_HOME path; forking would silently use the default account. (record.provider === 'codex' && !isLegacyAgentSessionAccountHome(record.accountHome)) || !isAiVaultAgent(record.provider) || - !agentForksConversations(record.provider) + (fork && !agentForksConversations(record.provider)) ) { throw new Error('This chat has no saved conversation available on this host.') } @@ -105,6 +105,16 @@ export async function prepareStructuredChatCliFork( throw new Error('This chat has no saved conversation to copy.') } assertNativeConversationContent(await host.journalSnapshot(record.sessionId)) + return { host, record, registration, head, agent, cwd, providerSessionId } +} + +/** Prepares the record's current conversation on its execution host, without a History inventory. */ +export async function prepareStructuredChatCliFork( + args: AiVaultPrepareChatForkArgs, + prepare: AiVaultSessionResumePreparation +): Promise { + const source = await resolveStructuredChatCliSource(args.sourceChat, true) + const { record, registration, agent, cwd, providerSessionId } = source if (agentForksThroughChat(agent)) { const result = await copyStructuredChatRecordConversation(record, providerSessionId) const copy = result.forkedSession @@ -124,6 +134,17 @@ export async function prepareStructuredChatCliFork( } } } + return prepareStructuredChatCliTranscript(source, prepare, true) +} + +/** The transcript a terminal resume (or `fork`) opens, under the account home the host prepared. + * A same-id resume refuses a substituted home: another home is another conversation. */ +export async function prepareStructuredChatCliTranscript( + source: Awaited>, + prepare: AiVaultSessionResumePreparation, + fork: boolean +): Promise { + const { host, record, registration, head, agent, cwd, providerSessionId } = source const importer = registration.transcriptImport if (!importer) { throw new Error('This chat cannot be opened in the CLI.') @@ -162,10 +183,13 @@ export async function prepareStructuredChatCliFork( filePath, codexHome, executionHostId: LOCAL_EXECUTION_HOST_ID, - fork: true + ...(fork ? { fork: true } : {}) }) const preparedHome = result.useRealCodexHome ? null : (result.substituteCodexHome ?? codexHome) if (codexHome && (result.useRealCodexHome || result.substituteCodexHome)) { + if (!fork) { + throw new Error('This conversation is saved under a different Codex account here.') + } filePath = join( preparedHome ?? getSystemCodexHomePath(), 'sessions', diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 53367be93765..4b9ea5e46296 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -4,7 +4,6 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { assertLegacyAiVaultResumeAllowed, assertLegacyAiVaultResumeCommandAllowed, - findResumeOwnership, OPENS_EVERY_STRUCTURED_CHAT, projectStructuredAiVaultSearchResponse, projectStructuredAiVaultSessions @@ -111,37 +110,43 @@ describe('structured AI Vault ownership', () => { } ) - it('names the chat continuing a conversation first, while every link still refuses a terminal', async () => { + it('lets a terminal resume a Codex conversation once no chat may be writing it', async () => { const movedOn = codexChatHolding('session-moved', [PROVIDER_SESSION, OTHER_SESSION]) const owned = (records: ReturnType[]) => { installChats(records) return projectStructuredAiVaultSessions(listResult(), OPENS_EVERY_STRUCTURED_CHAT).sessions[0] ?.structuredSession?.sessionId } + const resume = { + agent: 'codex' as const, + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + executionHostId: 'local' + } expect(owned([movedOn, codexChatHolding('session-now', [PROVIDER_SESSION])])).toBe( 'session-now' ) - expect(owned([movedOn])).toBe('session-moved') - expect( - findResumeOwnership({ agent: 'codex', sessionId: PROVIDER_SESSION, filePath: '' }) - ).toMatchObject({ - sessionId: 'session-moved', - role: 'earlier' - }) - expect(() => - assertLegacyAiVaultResumeAllowed({ - agent: 'codex', - filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, - codexHome: null, - executionHostId: 'local' - }) - ).toThrow('agent_session_conflict') + // A link the chat moved on from is the terminal's: no owner row, and the resume is admitted. + expect(owned([movedOn])).toBeUndefined() + expect(() => assertLegacyAiVaultResumeAllowed(resume)).not.toThrow() + // The head refuses while its lease may admit a writer, and admits once its agent provably exited. + installChats([codexChatHolding('session-now', [PROVIDER_SESSION])]) + expect(() => assertLegacyAiVaultResumeAllowed(resume)).toThrow('agent_session_conflict') + const exited = codexChatHolding('session-now', [PROVIDER_SESSION]) + exited.lease = { + ...exited.lease, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'switched to a terminal', observedAt: 1 } + } + installChats([exited]) await expect( assertLegacyAiVaultResumeCommandAllowed( `codex resume ${PROVIDER_SESSION}`, async () => undefined ) - ).rejects.toThrow('agent_session_conflict') + ).resolves.toBeUndefined() }) it('derives typed refusals from the single writer predicate for live and proving leases', async () => { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 73e29edebaf0..7ae239743c47 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -9,6 +9,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' import { listStructuredProviderSessionOwnership, + sameIdTerminalResumeEnabled, terminalResumeBlocked, type StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' @@ -117,7 +118,8 @@ function ownedRow(rowAgent: string, ownership: StructuredProviderSessionOwnershi } /** One index per projection: a list holds hundreds of rows, each once searched every record. A row - * names the chat continuing its conversation now, else one whose chain held it earlier. */ + * names the chat continuing its conversation now, else (agents not yet switching in place) one + * whose chain held it earlier. */ function ownershipLookup(): | ((row: { agent: string; sessionId: string }) => StructuredProviderSessionOwnership | null) | null { @@ -136,7 +138,9 @@ function ownershipLookup(): const found = historyRowOwners(row.agent).flatMap( (owner) => byProviderSession.get(`${owner}\0${row.sessionId}`) ?? [] ) - return found.find((ownership) => ownership.role === 'head') ?? found[0] ?? null + // An agent that switches in place lists a conversation a chat held earlier as the terminal's. + const earlier = found.find((ownership) => !sameIdTerminalResumeEnabled(ownership.provider)) + return found.find((ownership) => ownership.role === 'head') ?? earlier ?? null } } diff --git a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts new file mode 100644 index 000000000000..31cd3075a894 --- /dev/null +++ b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts @@ -0,0 +1,136 @@ +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig, + SleepingAgentSessionRecord +} from '../../../../shared/agent-session-resume' +import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import type { SwitchClaim } from '../../../runtime/session-tabs/structured-session-switch-claims' +import { + agentSessionLeasePossiblyWriting, + listStructuredProviderSessionOwnership +} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' +import { + keysMayMatch, + terminalConversationKey +} from '../../../native-chat/structured-conversation-key' + +export type ResumeSpawnArgs = { + connectionId?: string | null + command?: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: unknown + resumeProviderSession?: AgentProviderSessionMetadata + /** `saved`: built from a pane's saved facts; `switch`: the terminal half of a chat's switch. */ + resumeOrigin?: unknown + env?: Record +} + +export type AdmissionRuntime = { + getAgentProviderSessionRows?(): AgentStatusIpcPayload[] + readSavedResumeSource?( + worktreeId: string, + paneKey: string + ): Promise + openAgentSessionRecordStore(): Promise + acceptSwitchStart?(agent: string, id: string): SwitchClaim + settleSwitchStart?(claim: SwitchClaim, ptyId: string | null): void +} + +/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: + * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ +export type ResumeSpawnRefusal = { + sourceRecord?: SleepingAgentSessionRecord + verdict: 'chat-home' | 'chat-writing' | 'unverifiable' + refused: RefusedPaneResume +} + +export type ResumeSpawnAdmission = + | { + verdict: 'admitted' + refused: RefusedPaneResume + sourceRecord?: SleepingAgentSessionRecord + /** The switch claim this spawn settles: bound to its PTY, or dropped when none started. */ + switchClaim?: SwitchClaim + } + | ResumeSpawnRefusal + +export async function judgeResumes( + runtime: AdmissionRuntime, + resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]], + /** A switch's own start: its chat tab is still open on purpose, so only a writing chat refuses. */ + switchStart = false +): Promise { + const keysOf = ({ agent, providerSession }: RefusedPaneResume) => + [providerSession.id, providerSession.transcriptPath].flatMap((id) => + id?.trim() ? [terminalConversationKey(agent, id)] : [] + ) + const admitted = (): ResumeSpawnAdmission => ({ + verdict: 'admitted', + refused: resumes[0] + }) + let store: AgentSessionRecordStore + try { + store = await runtime.openAgentSessionRecordStore() + } catch (error) { + console.warn( + "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", + error + ) + return { verdict: 'unverifiable', refused: resumes[0] } + } + const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( + (holding) => holding.role === 'head' + ) + const tabs = store.getVisibleSessionTabIndex() + for (const refused of resumes) { + const holders = heads.filter((holding) => + keysOf(refused).some((key) => keysMayMatch(holding.key, key)) + ) + if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { + return { verdict: 'chat-writing', refused } + } + if (switchStart) { + continue + } + if (holders.length > 0 && (!tabs || !tabs.present)) { + return { verdict: 'unverifiable', refused } + } + if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { + return { verdict: 'chat-home', refused } + } + } + return admitted() +} + +/** The terminal half of a chat's switch: refused into a plain shell where Orca can't judge the pane, + * or while a chat may be writing the conversation; otherwise its claim settles with this spawn. */ +export async function admitSwitchResumeSpawn( + runtime: AdmissionRuntime | undefined, + args: ResumeSpawnArgs, + wslDistro: string | null +): Promise { + const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) + const agent = args.launchAgent + if (!providerSession || !agent) { + throw new Error('switch_start_missing_conversation') + } + const refused = { agent, providerSession } + if (args.connectionId || wslDistro !== null || !runtime?.acceptSwitchStart) { + return { verdict: 'unverifiable', refused } + } + // Before any await: a chat start whose census ends after this point sees the claim. + const switchClaim = runtime.acceptSwitchStart(agent, providerSession.id) + const admission = await judgeResumes(runtime, [refused], true) + if (admission.verdict !== 'admitted') { + runtime.settleSwitchStart?.(switchClaim, null) + return admission + } + return { ...admission, switchClaim } +} diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index 65296323394c..ddb73a900854 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -1,67 +1,36 @@ -import type { TuiAgent } from '../../../../shared/tui-agent' import { normalizeAgentProviderSession, agentProviderSessionsEqual, - type AgentProviderSessionMetadata, - type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import type { - AgentStatusIpcPayload, - RefusedPaneResume -} from '../../../../shared/agent-status-types' -import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import { - agentSessionLeasePossiblyWriting, - listStructuredProviderSessionOwnership -} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import { agentHookServer } from '../../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { - keysMayMatch, - terminalConversationKey -} from '../../../native-chat/structured-conversation-key' - -export type ResumeSpawnArgs = { - connectionId?: string | null - command?: string - launchAgent?: TuiAgent - launchConfig?: SleepingAgentLaunchConfig - launchToken?: unknown - resumeProviderSession?: AgentProviderSessionMetadata - /** `saved`: the resume was built from a pane's saved facts, not asked for by the user. */ - resumeOrigin?: unknown - env?: Record -} - -type AdmissionRuntime = { - getAgentProviderSessionRows?(): AgentStatusIpcPayload[] - readSavedResumeSource?( - worktreeId: string, - paneKey: string - ): Promise - openAgentSessionRecordStore(): Promise -} - -/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: - * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ -export type ResumeSpawnRefusal = { - sourceRecord?: SleepingAgentSessionRecord - verdict: 'chat-home' | 'chat-writing' | 'unverifiable' - refused: RefusedPaneResume -} + admitSwitchResumeSpawn, + judgeResumes, + type AdmissionRuntime, + type ResumeSpawnAdmission, + type ResumeSpawnArgs, + type ResumeSpawnRefusal +} from './resume-spawn-judgement' -export type ResumeSpawnAdmission = - | { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord } - | ResumeSpawnRefusal +export type { + ResumeSpawnAdmission, + ResumeSpawnArgs, + ResumeSpawnRefusal +} from './resume-spawn-judgement' -/** Saved-home policy only; ordinary terminal starts remain hook-observed. */ +/** Saved-home policy and a switch's own start; other terminal starts remain hook-observed. */ export async function admitResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, wslDistro: string | null ): Promise { + if (args.resumeOrigin === 'switch') { + return admitSwitchResumeSpawn(runtime, args, wslDistro) + } const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent // Native chats run only natively, so a remote or WSL conversation is never a chat's. @@ -114,49 +83,6 @@ export async function admitColdRestoreReattach( return first ? judgeResumes(runtime, [first]) : null } -async function judgeResumes( - runtime: AdmissionRuntime, - resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]] -): Promise { - const keysOf = ({ agent, providerSession }: RefusedPaneResume) => - [providerSession.id, providerSession.transcriptPath].flatMap((id) => - id?.trim() ? [terminalConversationKey(agent, id)] : [] - ) - const admitted = (): ResumeSpawnAdmission => ({ - verdict: 'admitted', - refused: resumes[0] - }) - let store: AgentSessionRecordStore - try { - store = await runtime.openAgentSessionRecordStore() - } catch (error) { - console.warn( - "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", - error - ) - return { verdict: 'unverifiable', refused: resumes[0] } - } - const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( - (holding) => holding.role === 'head' - ) - const tabs = store.getVisibleSessionTabIndex() - for (const refused of resumes) { - const holders = heads.filter((holding) => - keysOf(refused).some((key) => keysMayMatch(holding.key, key)) - ) - if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { - return { verdict: 'chat-writing', refused } - } - if (holders.length > 0 && (!tabs || !tabs.present)) { - return { verdict: 'unverifiable', refused } - } - if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { - return { verdict: 'chat-home', refused } - } - } - return admitted() -} - /** A refused resume becomes 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 { @@ -179,6 +105,17 @@ export function dropAgentLaunch(args: ResumeSpawnArgs): void { delete args.resumeProviderSession } +/** Every exit of a switch spawn settles its claim: on the PTY it produced, or gone with none. */ +export function settleSwitchSpawn( + runtime: AdmissionRuntime | undefined, + admission: ResumeSpawnAdmission | null, + ptyId: string | null +): void { + if (admission?.verdict === 'admitted' && admission.switchClaim) { + runtime?.settleSwitchStart?.(admission.switchClaim, ptyId) + } +} + export function resumeSpawnRefusal( admission: ResumeSpawnAdmission | null ): ResumeSpawnRefusal | null { diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 9bce2857788f..9e5eda9b3dc5 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -23,6 +23,7 @@ import { captureSavedResumeCleanup, admitColdRestoreReattach, forgetRefusedResume, + settleSwitchSpawn, resumeSpawnRefusal, savedResumeRecord } from '../host-env/saved-resume-admission' @@ -125,8 +126,10 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg if (!ctx.result.isReattach) { deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) } + settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, committed.id) return committed } catch (caught) { + settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, ctx.result.id) const err = endPaneAgentLaunch(ctx, { error: caught }) ?? caught releaseAbandonedAgentTeamsLeader(ctx) if (ctx.preSpawnHiddenMarkId !== null) { diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 90c5a3d6a18d..5ca761ba57ab 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -115,7 +115,7 @@ export type RuntimePtySpawnArgs = { envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' connectionId?: string | null worktreeId?: string preAllocatedHandle?: string diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index e75366b4adc6..fc49c3e8fcee 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -4,7 +4,8 @@ import { savedResumeRecord, savedResumeResult, resumeSpawnRefusal, - forgetRefusedResume + forgetRefusedResume, + settleSwitchSpawn } from '../host-env/saved-resume-admission' import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' @@ -132,8 +133,10 @@ export async function spawnPtyFromRuntimeController( if (!ctx.result.isReattach) { deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) } + settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, committed.id) return committed } catch (err) { + settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, ctx.result.id) releaseRuntimeSpawnPreSpawnHiddenMark(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 5a2028e82fc3..09ba39159c36 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,5 +1,6 @@ import { isDeepStrictEqual } from 'node:util' import { abortSignalReason } from '../../../shared/abort-signal-reason' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionProcessIdentity, AgentSessionRecord @@ -48,9 +49,12 @@ export async function acquireOwner( try { await input.assertNoTerminalWriter?.(record) } catch (error) { + const moving = + isAgentSessionRefusalError(error) && + error.refusal.details?.reason === 'conversationMovingToTerminal' // Not a code: a code message would travel as the census's own conflict, not this reason. throw new AgentSessionPreSpawnError(error, { - reason: 'conversationOpenInTerminal', + reason: moving ? 'conversationMovingToTerminal' : 'conversationOpenInTerminal', message: 'a terminal may still be writing this conversation' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 6af0c62364e2..58bcc6a02091 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -133,6 +133,7 @@ export type AgentSessionPreSpawnReason = Extract< | 'agentCommandNotRunnable' | 'runtimeNotInstalled' | 'conversationOpenInTerminal' + | 'conversationMovingToTerminal' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index d9bab20d37ca..de5aaec41424 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -40,6 +40,7 @@ import { } from './structured-agent-session-failure-text' import { recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import * as switchAdmission from './structured-agent-session-switch-admission' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -330,6 +331,16 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) - } + }, + /** Close's body behind an idle-only admission with no effect of its own; `admit` registers the + * switch before the stop, in the same step. */ + switchToTerminal: (id: string, admit: () => T): Promise => + serialize(id, () => + switchAdmission.stopIdleStructuredAgentSessionForSwitch( + { session: sessions.get(id), delivering: host.deliveryActive(id), idleSweep, admit }, + () => holdStopClose(id, () => stopAgent(id, { cause: 'user-close' })), + () => deps().store.getRecord(id) + ) + ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 82c33a5a467f..bdcac83f0ecb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -39,7 +39,8 @@ const TYPED_START_REFUSALS = [ 'agentCommandNotRunnable', 'runtimeNotInstalled', 'conversationOpenInTerminal', - 'conversationOpenElsewhere' + 'conversationOpenElsewhere', + 'conversationMovingToTerminal' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b770dc4b6fcc..ea282faeba2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -212,6 +212,8 @@ export class StructuredAgentSessionHost { * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) + switchToTerminal: StructuredAgentSessionConversationLifetime['switchToTerminal'] = (id, admit) => + this.lifetime.switchToTerminal(id, admit) /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 6ab6e0aedccc..f8bfa9a3dbfe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -161,7 +161,7 @@ export class StructuredAgentSessionIdleSweep { /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the * handle, and a leftover prompt or turn row is only history. */ - private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { + owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { return ( this.queuedOrDelivering(sessionId, session) || structuredAgentSessionChildHasOpenWork({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts new file mode 100644 index 000000000000..2e1d44763137 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts @@ -0,0 +1,43 @@ +// The chat's half of a switch to its terminal. Unlike a tab close, a switch never cuts work short +// (the user stops a reply first), so it is admitted only when the chat is idle, and its admission +// has no effect of its own: no start is aborted and nothing is stopped before it passes. + +import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' + +/** In the chat's ordered step: refuses unless nothing is running, starting, queued or owed, then + * admits, stops through close's body, and proves the agent gone before anything starts elsewhere. */ +export async function stopIdleStructuredAgentSessionForSwitch( + idle: { + session: StructuredAgentSessionHostSession | undefined + delivering: boolean + idleSweep: { + owesWork: (sessionId: string, session: StructuredAgentSessionHostSession) => boolean + } + admit: () => T + }, + stop: () => Promise, + record: () => AgentSessionRecord | null +): Promise { + const { session } = idle + if ( + idle.delivering || + (session !== undefined && + (session.child?.phase === 'starting' || + session.journal.submissions().some(isQueuedAgentJournalSubmission) || + (session.child !== null && + idle.idleSweep.owesWork(session.params.envelope.sessionId, session)))) + ) { + throw agentSessionRefusalError('agent_session_operation_invalid', { reason: 'turnActive' }) + } + const admitted = idle.admit() + await stop() + const lease = record()?.lease + if (!lease || agentSessionLeaseOwnerVerdict(lease) !== 'exited') { + throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }) + } + return admitted +} diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts index a7f34eb73fd0..4d7be05fe2ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -2,6 +2,7 @@ import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agen import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' import { structuredAgentRuntimeMetadata } from '../../runtime/structured-agent-runtime-metadata' +import { inPlaceSwitchEnabled } from '../../../shared/structured-agent-cli-conversation-adoption' import { keysMayMatch, recordConversationKey, @@ -58,8 +59,8 @@ export function agentSessionLeasePossiblyWriting(lease: AgentSessionLease): bool } /** Agents whose chats let a terminal resume the same conversation once the chat stopped writing. */ -export function sameIdTerminalResumeEnabled(_provider: StructuredAgentId): boolean { - return false +export function sameIdTerminalResumeEnabled(provider: StructuredAgentId): boolean { + return inPlaceSwitchEnabled(provider) } /** Whether a terminal resume of this link's conversation is refused. */ diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 11440e01fe1b..0cb9a50b4619 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -81,6 +81,7 @@ export const SESSION_TAB_METHODS = [ ? { resumeProviderSession: params.resumeProviderSession } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.resumeOrigin ? { resumeOrigin: params.resumeOrigin } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, select: params.select, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index f0e68d1ca2f1..9410bc5b055d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -98,5 +98,6 @@ export const WORK_METHODS = [ { method: 'agentSession.subscribe', params: { sessionId: SESSION } }, { method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } }, { method: 'agentSession.reveal', params: { sessionId: SESSION } }, + { method: 'agentSession.switchToTerminal', params: { sessionId: SESSION } }, { method: 'agentSession.subscribeStatus', params: null } ] as const diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts new file mode 100644 index 000000000000..d3ece1c07326 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts @@ -0,0 +1,65 @@ +// `agentSession.switchToTerminal` — the chat's half of moving its conversation into a terminal on +// the same id. In the chat's ordered step it refuses unless idle, registers the switch's claim, +// stops the agent with proven exit while the tab stays, and answers the facts a terminal resume +// needs. The claim refuses a chat start from any view until the terminal it names can be judged. +// Gated per agent by `agent-session.structured.switch.v1:`; a client never falls back to close. + +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +import type { AiVaultChatForkSource } from '../../../../shared/ai-vault-resume-preparation' +import { + prepareStructuredChatCliTranscript, + resolveStructuredChatCliSource +} from '../../../ai-vault/structured-chat-cli-fork' +import type { SwitchClaim } from '../../session-tabs/structured-session-switch-claims' +import { defineMethod } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredCapability, + requireStructuredSessionHost +} from './structured-agent-session-gate' +import { OptionsParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ + defineMethod({ + name: 'agentSession.switchToTerminal', + permission: 'workspace', + params: OptionsParams, + handler: async (params, ctx): Promise<{ source: AiVaultChatForkSource }> => { + requireStructuredCapability(ctx) + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredSessionHost(ctx, params.sessionId) + const record = host.deps.store.getRecord(params.sessionId) + if (!record || !inPlaceSwitchEnabled(record.provider)) { + throw new Error('This chat cannot switch to a terminal on this host.') + } + // Reads only: a chat that can't be resumed in a terminal is refused before anything stops. + const source = await resolveStructuredChatCliSource( + { sessionId: record.sessionId, workspaceId: record.location.workspaceId }, + false + ) + // A switch terminal whose agent provably ended no longer holds the conversation. + await ctx.runtime.releaseEndedSwitchStarts(source.agent, source.providerSessionId) + let claim: SwitchClaim | undefined + try { + await host.switchToTerminal(params.sessionId, () => { + claim = ctx.runtime.registerSwitchClaim(source.agent, source.providerSessionId) + }) + const prepared = await prepareStructuredChatCliTranscript( + source, + (args) => ctx.runtime.prepareAiVaultSessionResume(args), + false + ) + if (!prepared.cliForkSource) { + throw new Error('This chat has no saved conversation available on this host.') + } + return { source: prepared.cliForkSource } + } catch (error) { + // No recipe left this host, so nothing can start under the claim. + if (claim) { + ctx.runtime.dropSwitchClaim(claim) + } + throw error + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 535817b6a63f..f3b88b73c03f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(38) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(39) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 1fe96d81d0b9..516c0bfad40b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -52,6 +52,7 @@ import { STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS } from './structured-a import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent-session-thread-goal' import { STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS } from './structured-agent-session-conversation-outline' import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from './structured-agent-session-options-read' +import { STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS } from './structured-agent-session-switch-to-terminal' import { AttachParams, CancelParams, @@ -299,5 +300,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ ...STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS, ...STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS, ...STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS, - ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS + ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS, + ...STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS ] diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 063463ae7374..36093a85c70a 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -69,7 +69,7 @@ export type RuntimePtyController = { env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e293e495a6bf..1767dfb42e0e 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -48,7 +48,7 @@ export type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupAgent?: TuiAgent diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 2e1fd1a1fc96..3bc5ba90cc66 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -16,6 +16,7 @@ import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' import type { TerminalTailWaitState } from './terminal-wait-tail-state' import type { TerminalCommandPaint } from './terminal-command-paint' import type { CliWriterConversation } from './structured-agent-cli-writers' +import type { SwitchStartProof } from './session-tabs/structured-session-switch-claims' import type { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' @@ -81,6 +82,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { incarnationId: PtyIncarnationId | null byKey: Map } + /** The switch's own start of a conversation in this execution; ends only on an observed exit. */ + switchStart?: SwitchStartProof foregroundAgent: TerminalAgent | null connected: boolean disconnectedAt: number | null diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 7d753d61c15c..59553952fcb1 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -4,7 +4,9 @@ import { } from '../../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { OrcaRuntimeWithStructuredSessionSwitchClaims } from './structured-session-switch-claims' +import { switchStartProcessVerdict } from './structured-session-switch-observation' import { getProviderForPty } from '../../ipc/pty/provider/registry' import { LocalPtyProvider } from '../../providers/local-pty-provider' import { inspectPtyProviderProcess } from '../../providers/pty-process-inspection' @@ -24,7 +26,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' import { recordConversationKey } from '../../native-chat/structured-conversation-key' -export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { +export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithStructuredSessionSwitchClaims { rememberSavedTerminalLaunchResult(ptyId: string, result: TerminalLaunchResult): void { const pty = this.ptysById.get(ptyId) if (pty && result.savedResumeDecision) { @@ -93,6 +95,7 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca } if (event.agentPresence?.process) { delete pty.savedTerminalLaunchResult + this.noteSwitchStartProcess(pty.ptyId, { agent, id: binding.id }, event.agentPresence.process) } if (pty.cliConversations?.incarnationId !== pty.incarnationId) { pty.cliConversations = { incarnationId: pty.incarnationId, byKey: new Map() } @@ -129,6 +132,8 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca providerSessionId: string, source?: { workspaceId: string; paneKey: string } ): Promise { + this.assertNoPendingSwitch(agent, providerSessionId) + await this.assertSwitchStartsEnded(agent, providerSessionId) await assertCliConversationWritersExited( { rows: () => this.getAgentProviderSessionRows(), @@ -166,38 +171,66 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca state.phase === 'partial' || state.phase === 'stopping') ), - shellAlone: async (pane) => { - if (process.platform === 'win32') { - const provider = getProviderForPty(pane.ptyId) - const inspection = await inspectPtyProviderProcess(provider, pane.ptyId, { - expectedIncarnationId: pane.incarnationId, - scanChildProcesses: true, - ignoreDetached: true - }) - const evidence = inspection.foregroundProcessEvidence - return ( - inspectionShowsShellAlone(inspection) && - (provider instanceof LocalPtyProvider || - (evidence?.ptyId === pane.ptyId && - evidence.ptyIncarnationId === pane.incarnationId && - evidence.verdict === 'unverifiable' && - evidence.reason === 'windows_ssh_foreground_unavailable')) - ) - } - const processes = await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) - const entry = processes?.find((candidate) => candidate.id === pane.ptyId) - const pty = this.ptysById.get(pane.ptyId) - return ( - !!entry?.rootProcessId && - !!entry.incarnationId && - entry.incarnationId === pty?.incarnationId && - (await posixCliWriterShellAlone(entry.rootProcessId)) - ) - } + shellAlone: (pane) => this.cliWriterShellAlone(pane) }, agent, providerSessionId, source && { paneKey: source.paneKey, worktreeId: source.workspaceId } ) + // A switch spawn admitted during the census either registered first or read this chat's reservation. + this.assertNoPendingSwitch(agent, providerSessionId) + } + + async releaseEndedSwitchStarts(agent: string, id: string): Promise { + await this.assertSwitchStartsEnded(agent, id).catch(() => undefined) + } + + /** A switch's terminal holds its conversation until the agent it started there is proven gone. */ + private async assertSwitchStartsEnded(agent: string, id: string): Promise { + for (const { ptyId, proof } of this.boundSwitchStarts(agent, id)) { + const pane = this.cliWriterPtyPane(ptyId) + const verdicts = await Promise.all(proof.processes.map(switchStartProcessVerdict)) + if ( + !pane || + verdicts.length === 0 || + verdicts.some((verdict) => verdict !== 'exited') || + !(await this.cliWriterShellAlone(pane)) || + this.ptysById.get(ptyId)?.switchStart !== proof + ) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } + delete this.ptysById.get(ptyId)?.switchStart + } + } + + private async cliWriterShellAlone(pane: CliWriterPane): Promise { + if (process.platform === 'win32') { + const provider = getProviderForPty(pane.ptyId) + const inspection = await inspectPtyProviderProcess(provider, pane.ptyId, { + expectedIncarnationId: pane.incarnationId, + scanChildProcesses: true, + ignoreDetached: true + }) + const evidence = inspection.foregroundProcessEvidence + return ( + inspectionShowsShellAlone(inspection) && + (provider instanceof LocalPtyProvider || + (evidence?.ptyId === pane.ptyId && + evidence.ptyIncarnationId === pane.incarnationId && + evidence.verdict === 'unverifiable' && + evidence.reason === 'windows_ssh_foreground_unavailable')) + ) + } + const processes = await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) + const entry = processes?.find((candidate) => candidate.id === pane.ptyId) + const pty = this.ptysById.get(pane.ptyId) + return ( + !!entry?.rootProcessId && + !!entry.incarnationId && + entry.incarnationId === pty?.incarnationId && + (await posixCliWriterShellAlone(entry.rootProcessId)) + ) } } diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts new file mode 100644 index 000000000000..db9de61926f5 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -0,0 +1,162 @@ +import type { AgentProcessIdentity } from '../../../shared/agent-process-presence' +import { isSameAgentProcess } from '../../../shared/agent-process-presence' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { PtyIncarnationId } from '../../../shared/pty-incarnation' +import { chatAgentsShareConversations } from '../../../shared/structured-agent-cli-conversation-adoption' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' +import { readSwitchPaneAgentProcesses } from './structured-session-switch-observation' + +/** A recipe no terminal took yet may lapse; expiry only ends a refusal, the spawn re-checks the chat. */ +const UNACCEPTED_SWITCH_CLAIM_MS = 60_000 +const SWITCH_START_WATCH_POLLS = 120 +const SWITCH_START_WATCH_INTERVAL_MS = 1_000 + +/** A chat → terminal switch from the chat's stop until its terminal execution exists. */ +export type SwitchClaim = { + agent: string + id: string + at: number + /** A spawn took it: it never lapses, and settles only with that spawn. */ + accepted: boolean +} + +/** The switch's start bound to the execution it spawned. */ +export type SwitchStartProof = { + incarnationId: PtyIncarnationId + agent: string + id: string + /** The agent processes seen in the pane; none seen keeps the proof until the pane goes. */ + processes: AgentProcessIdentity[] +} + +const moving = () => + agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'conversationMovingToTerminal' + }) + +export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaRuntimeWithGetWorktreePs { + private readonly switchClaims = new Set() + + private liveSwitchClaim(agent: string, id: string): SwitchClaim | undefined { + let live: SwitchClaim | undefined + for (const claim of this.switchClaims) { + if (!claim.accepted && Date.now() - claim.at >= UNACCEPTED_SWITCH_CLAIM_MS) { + this.switchClaims.delete(claim) + } else if (claim.id === id && chatAgentsShareConversations(claim.agent, agent)) { + live ??= claim + } + } + return live + } + + /** Throws while a switch of this conversation has not started its terminal yet. */ + protected assertNoPendingSwitch(agent: string, id: string): void { + if (this.liveSwitchClaim(agent, id)) { + throw moving() + } + } + + protected boundSwitchStarts(agent: string, id: string) { + return [...this.ptysById.values()].flatMap((pty) => { + const proof = pty.switchStart + return proof && + proof.incarnationId === pty.incarnationId && + proof.id === id && + chatAgentsShareConversations(proof.agent, agent) && + this.getPtyLivenessVerdict(pty.ptyId)?.status !== 'exited' + ? [{ ptyId: pty.ptyId, proof }] + : [] + }) + } + + /** Synchronous, in the chat's ordered step before its stop: a second switch of the same + * conversation, from any client, refuses while the first may still hold it. */ + registerSwitchClaim(agent: string, id: string): SwitchClaim { + if (this.liveSwitchClaim(agent, id) || this.boundSwitchStarts(agent, id).length > 0) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } + const claim = { agent, id, at: Date.now(), accepted: false } + this.switchClaims.add(claim) + return claim + } + + dropSwitchClaim(claim: SwitchClaim): void { + this.switchClaims.delete(claim) + } + + /** Synchronous, before the switch spawn's first await, so a chat start censusing after it refuses. */ + acceptSwitchStart(agent: string, id: string): SwitchClaim { + const claim = this.liveSwitchClaim(agent, id) ?? { agent, id, at: Date.now(), accepted: false } + claim.accepted = true + this.switchClaims.add(claim) + return claim + } + + /** Ends this attempt's claim: bound to the execution its spawn produced, or gone when none exists. */ + settleSwitchStart(claim: SwitchClaim, ptyId: string | null): void { + if (!this.switchClaims.delete(claim)) { + return + } + const pty = ptyId ? this.ptysById.get(ptyId) : undefined + if (!pty?.incarnationId) { + return + } + const key = `${claim.agent}\0${claim.id}` + const reported = + pty.cliConversations?.incarnationId === pty.incarnationId + ? pty.cliConversations.byKey.get(key)?.process + : undefined + const proof: SwitchStartProof = { + incarnationId: pty.incarnationId, + agent: claim.agent, + id: claim.id, + processes: reported ? [reported] : [] + } + pty.switchStart = proof + void this.watchSwitchStart(pty.ptyId, proof).catch((error: unknown) => + console.warn('[agent-session] could not watch a switched terminal start', error) + ) + } + + /** An authentic hook's process joins the proof; identity-only rows carry none. */ + protected noteSwitchStartProcess( + ptyId: string, + conversation: { agent: string; id: string }, + process: AgentProcessIdentity + ): void { + const pty = this.ptysById.get(ptyId) + const proof = pty?.switchStart + if ( + proof && + proof.incarnationId === pty.incarnationId && + proof.id === conversation.id && + chatAgentsShareConversations(proof.agent, conversation.agent) && + !proof.processes.some((known) => isSameAgentProcess(known, process)) + ) { + proof.processes.push(process) + } + } + + /** Bounded evidence collection only: it records the agent's identity and never admits anything. */ + private async watchSwitchStart(ptyId: string, proof: SwitchStartProof): Promise { + for (let poll = 0; poll < SWITCH_START_WATCH_POLLS; poll++) { + await new Promise((resolve) => setTimeout(resolve, SWITCH_START_WATCH_INTERVAL_MS).unref?.()) + if (this.ptysById.get(ptyId)?.switchStart !== proof || proof.processes.length > 0) { + return + } + const entry = (await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID))?.find( + (candidate) => candidate.id === ptyId + ) + if (!entry?.rootProcessId || entry.incarnationId !== proof.incarnationId) { + continue + } + const seen = await readSwitchPaneAgentProcesses(entry.rootProcessId, proof.id) + if (this.ptysById.get(ptyId)?.switchStart === proof && proof.processes.length === 0) { + proof.processes.push(...seen) + } + } + } +} diff --git a/src/main/runtime/session-tabs/structured-session-switch-observation.ts b/src/main/runtime/session-tabs/structured-session-switch-observation.ts new file mode 100644 index 000000000000..f86af7b50c26 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-observation.ts @@ -0,0 +1,68 @@ +import type { + AgentProcessIdentity, + AgentProcessVerdict +} from '../../../shared/agent-process-presence' +import { + probeAgentProcessPresence, + readAgentProcess +} from '../../../shared/agent-process-presence-probe' +import { + buildProcessTableIndex, + collectDescendantsFromIndex +} from '../../../shared/process-table-index' +import { getFreshShellForegroundSnapshot } from '../../../shared/process-table-snapshot-reader' +import { + isWindowsProcessStartTimeAvailable, + readWindowsProcessCreationTime, + readWindowsProcessTableFresh +} from '../../windows/windows-process-table' + +/** The processes below a pane's shell whose command line names the conversation: what the switch + * typed there. Session leaders left the terminal (a prompt's worker), so they are not its job. */ +export async function readSwitchPaneAgentProcesses( + rootPid: number, + conversationId: string +): Promise { + if (process.platform === 'win32') { + if (!isWindowsProcessStartTimeAvailable()) { + return [] + } + const index = buildProcessTableIndex(await readWindowsProcessTableFresh()) + return collectDescendantsFromIndex(index, rootPid).flatMap((row) => + row.command.includes(conversationId) && row.creationTimeMs + ? [{ pid: row.pid, platform: 'win32' as const, startTime: String(row.creationTimeMs) }] + : [] + ) + } + const platform = process.platform + if (platform !== 'darwin' && platform !== 'linux') { + return [] + } + const index = buildProcessTableIndex(await getFreshShellForegroundSnapshot()) + const named = collectDescendantsFromIndex(index, rootPid, (row) => row.stat.includes('s')).filter( + (row) => row.command.includes(conversationId) + ) + const identities = await Promise.all( + named.map(async (row) => { + const observed = await readAgentProcess(row.pid) + return observed.verdict === 'live' && !observed.zombie + ? [{ pid: row.pid, platform, startTime: observed.startTime }] + : [] + }) + ) + return identities.flat() +} + +/** `exited` only on proof: the pid is gone or now names a different process. */ +export async function switchStartProcessVerdict( + identity: AgentProcessIdentity +): Promise { + if (identity.platform !== 'win32') { + return probeAgentProcessPresence(identity) + } + if (process.platform !== 'win32' || !isWindowsProcessStartTimeAvailable()) { + return 'unverifiable' + } + const created = readWindowsProcessCreationTime(identity.pid) + return created !== null && String(created) === identity.startTime ? 'live' : 'exited' +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index 35a1ca0bbb90..6c0b915eb6ae 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -46,6 +46,7 @@ export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRun agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'switch' launchAgent?: TuiAgent terminalImageViewerSupported?: boolean viewMode?: 'terminal' | 'chat' diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 0718b0ae46dc..456d88e2eb46 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -44,7 +44,7 @@ export type OwnedMobileTerminalCreateOptions = { supportsSplitGroupPlacement?: boolean launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' spawnIntent?: SpawnIntent launchRuntime?: AgentLaunchRuntime signal?: AbortSignal diff --git a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts index 8d848097efe4..c6ad23fd1e90 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -5,7 +5,10 @@ import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-t import { OrcaRuntimeWithMobileChatForkCreate } from './mobile-terminal-chat-fork-create' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' import type { TuiAgent } from '../../../../shared/tui-agent' -import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' import { randomUUID } from 'node:crypto' import { getRuntimeDesktopSurface } from '../../runtime-desktop-surface' @@ -34,6 +37,9 @@ export type MobileTerminalCreateOptions = { agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + /** A switch's start: its spawn admission judges and claims the conversation. */ + resumeProviderSession?: AgentProviderSessionMetadata + resumeOrigin?: 'switch' viewMode?: 'terminal' | 'chat' activate?: boolean clientNavigationId?: string @@ -150,9 +156,12 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca } ) } - const win = this.getAvailableAuthoritativeWindow() - if (!win) { - return await this.createRuntimeOwnedMobileSessionTerminal( + const switchStart = + opts.resumeOrigin === 'switch' + ? { resumeProviderSession: opts.resumeProviderSession, resumeOrigin: opts.resumeOrigin } + : {} + const createOwned = (identity?: { tabId: string; leafId: string }) => + this.createRuntimeOwnedMobileSessionTerminal( worktreeId, opts.activate !== false, afterTabId, @@ -163,15 +172,20 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca env: startupCommand.env, envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, + ...(identity ? { identity } : {}), launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, launchConfig: startupCommand.launchConfig, launchRuntime: startupCommand.launchRuntime, + ...switchStart, signal: opts.signal } ) + const win = this.getAvailableAuthoritativeWindow() + if (!win) { + return await createOwned() } if (win.webContents.isDestroyed?.()) { throw new Error('runtime_unavailable') @@ -225,6 +239,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...switchStart, ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', @@ -280,27 +295,10 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca throw new Error('Timed out waiting for terminal surface after creation') } // Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab. - return await this.createRuntimeOwnedMobileSessionTerminal( - worktreeId, - opts.activate !== false, - afterTabId, - { - command: startupCommand.command, - ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), - cwd, - env: startupCommand.env, - envToDelete: startupCommand.envToDelete, - startupCommandDelivery: startupCommand.startupCommandDelivery, - identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, - launchAgent: startupCommand.launchAgent, - viewMode: opts.viewMode, - targetGroupId: opts.targetGroupId, - supportsSplitGroupPlacement: opts.supportsSplitGroupPlacement, - launchConfig: startupCommand.launchConfig, - launchRuntime: startupCommand.launchRuntime, - signal: opts.signal - } - ) + return await createOwned({ + tabId: pendingSurface.tab.parentTabId, + leafId: pendingSurface.tab.leafId + }) } catch (error) { // Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587). if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 5801e9b82d26..f7edcab8932e 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -147,6 +147,11 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.conversationMovingToTerminal', + COPY.conversationMovingToTerminal + ), conversationOpenInTerminal: (values) => translate( 'components.native-chat.failureWords.conversationOpenInTerminal', diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index b9b0f34ed90a..0ee94080be9d 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -80,6 +80,8 @@ export const AGENT_SESSION_FAILURE_COPY = { 'Codex still has this conversation open somewhere else. Close it there, then try again.', conversationOpenInTerminal: "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + conversationMovingToTerminal: + 'This conversation is moving to a terminal. Use it there, or wait a moment and try again.', chooseClaudeAccount: 'Choose or add one in Claude Accounts settings.', chooseClaudeAccountThenRunCommand: 'Choose or add one in Claude Accounts settings, then run /{{command}} again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index ae538f21ff7f..ae30b6dcbbc9 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -184,6 +184,8 @@ const FAILURE_SENTENCES = { runtimeNotInstalled: namingAgent('runtimeNotInstalled'), conversationOpenInTerminal: namingAgent('conversationOpenInTerminal'), conversationOpenElsewhere: (_context, _fact, _surface, say) => say('conversationOpenElsewhere'), + conversationMovingToTerminal: (_context, _fact, _surface, say) => + say('conversationMovingToTerminal'), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index 36957513964f..15529bb89703 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -40,6 +40,7 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'runtimeNotInstalled', 'conversationOpenInTerminal', 'conversationOpenElsewhere', + 'conversationMovingToTerminal', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 882b147257b4..71be7706c5e3 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -89,6 +89,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** A terminal may still be writing the chat's conversation, so its agent did not start. */ 'conversationOpenInTerminal', 'conversationOpenElsewhere', + /** The conversation is being switched into a terminal that hasn't started yet. */ + 'conversationMovingToTerminal', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index c00f0c995e3c..20b15f0fa714 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -121,6 +121,7 @@ const REASON_WORDS = { runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, conversationOpenElsewhere: { fact: 'conversationOpenElsewhere', action: 'actFirst' }, + conversationMovingToTerminal: { fact: 'conversationMovingToTerminal', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5624d9ad3327..e541f6b84ab1 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -2,6 +2,7 @@ export const SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY = 'agent-session.saved-resume-origin.v1' as const import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from './agent-session-current-requests-capability' import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation' +import { IN_PLACE_SWITCH_RUNTIME_CAPABILITIES } from './structured-agent-cli-conversation-adoption' import { PROGRAM_STATUS_RUNTIME_CAPABILITY } from './program-status/protocol' import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability' import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters' @@ -248,6 +249,7 @@ export const RUNTIME_CAPABILITIES = [ MANAGED_RUNTIME_REMOVE_RUNTIME_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, + ...IN_PLACE_SWITCH_RUNTIME_CAPABILITIES, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index fd232b39adfd..618cb2c55e9f 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -719,6 +719,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.subscribeAvailability': AvailabilityParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, + 'agentSession.switchToTerminal': OptionsParams, 'agentSession.threadGoal': ThreadGoalParams, 'agentSession.unsubscribe': UnsubscribeParams, 'agentSessionAttachment.read': AttachmentReadParams, diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts index fd6b2d89d000..e1e4ca28b0f3 100644 --- a/src/shared/rpc-contract/session-tabs-schemas-params.ts +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -158,6 +158,8 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ // Why: lets the host record which conversation the pane resumes before the agent reports it // (#10923); a malformed value drops the hint, never the create. Older hosts strip it. resumeProviderSession: ResumeProviderSessionParam.optional().catch(undefined), + // Sent only to hosts advertising the agent's switch capability: an older host strips it unprotected. + resumeOrigin: z.literal('switch').optional(), agent: z .custom(isTuiAgent, { message: 'Unknown agent preset' @@ -186,6 +188,13 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ // returns the in-flight operation instead of spawning a duplicate terminal. clientMutationId: z.string().min(1).max(128).optional() }).superRefine((value, context) => { + if (value.resumeOrigin === 'switch' && (!value.resumeProviderSession || !value.launchAgent)) { + context.addIssue({ + code: 'custom', + path: ['resumeOrigin'], + message: 'A switch names its conversation and agent' + }) + } if (value.sourceChatFork) { if (!value.clientMutationId) { context.addIssue({ diff --git a/src/shared/structured-agent-cli-conversation-adoption.ts b/src/shared/structured-agent-cli-conversation-adoption.ts index 1bc9d8e4365f..11aad59da1d8 100644 --- a/src/shared/structured-agent-cli-conversation-adoption.ts +++ b/src/shared/structured-agent-cli-conversation-adoption.ts @@ -42,3 +42,22 @@ export function cliConversationRowsShareAgent( const rightAgents: readonly string[] = CLI_CONVERSATION_ADOPTING_AGENTS[right] return CLI_CONVERSATION_ADOPTING_AGENTS[left].some((agent) => rightAgents.includes(agent)) } + +/** The one enablement source for moving a conversation between chat and terminal in place: the + * agents whose chat catches up on turns made elsewhere and whose terminal start Orca can watch. */ +export const IN_PLACE_SWITCH_AGENTS: readonly CliConversationRowAgent[] = ['codex'] + +export function inPlaceSwitchEnabled(agent: unknown): boolean { + return adoptsCliConversations(agent) && IN_PLACE_SWITCH_AGENTS.includes(agent) +} + +/** What a host advertises per enabled agent; a peer without it never receives a switch. */ +export function inPlaceSwitchCapability(agent: string): string { + return `agent-session.structured.switch.v1:${agent}` +} + +export const IN_PLACE_SWITCH_RUNTIME_CAPABILITIES = + IN_PLACE_SWITCH_AGENTS.map(inPlaceSwitchCapability) + +/** `saved`: built from a pane's saved facts; `switch`: the terminal half of a chat's switch. */ +export type TerminalResumeOrigin = 'saved' | 'switch' diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 8eac0a500805..dd41caebff4f 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -99,6 +99,7 @@ const KIND_CATEGORY = { runtimeNotInstalled: 'startFailed', conversationOpenInTerminal: 'startFailed', conversationOpenElsewhere: 'startFailed', + conversationMovingToTerminal: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -134,6 +135,7 @@ const KIND_VERDICT = { runtimeNotInstalled: 'failure', conversationOpenInTerminal: 'failure', conversationOpenElsewhere: 'failure', + conversationMovingToTerminal: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', From 6bc59791bbd831f638114617b3e8dad10c4fe5f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:51:14 -0700 Subject: [PATCH 009/207] feat(native-chat): switch a Codex conversation between chat and terminal in place Switch to CLI (tab menu, hover card, chat top-right icon, History) asks the chat's host to stop the idle chat and opens the same-id resume in the chat's slot, then closes the chat tab. Switch to native chat opens the chat in the terminal's slot, or reveals the chat that already holds the conversation, and closes the terminal pane. Hosts without the agent's switch capability keep the new-tab entries, worded Resume in New Chat / Resume in New Terminal; the view toggle reads Show as chat / Show as terminal. --- src/preload/api/pty-api.ts | 2 +- src/preload/api/pty-bridge-session-control.ts | 2 +- .../NativeChatStructuredSession.tsx | 4 +- .../NativeChatTopRightControls.tsx | 90 ++++++++++++ .../use-native-chat-context-menu.tsx | 2 +- .../AiVaultSessionActionMenuItems.tsx | 6 +- .../right-sidebar/AiVaultSessionDetails.tsx | 63 +-------- .../AiVaultSessionDetailsSwitchButtons.tsx | 77 +++++++++++ .../right-sidebar/AiVaultSessionRow.tsx | 3 + .../AiVaultSessionSurfaceSwitchMenuItems.tsx | 34 +++-- .../SessionRowTrailingActions.tsx | 3 + .../ai-vault-session-cli-fork-copy.tsx | 14 ++ .../ai-vault-session-in-place-switch.ts | 55 ++++++++ .../ai-vault-session-launch-actions.ts | 9 +- .../tab-bar/NativeChatCliForkMenuItems.tsx | 21 ++- .../src/components/tab-bar/SortableTab.tsx | 2 + .../tab-bar/SortableTabContextMenu.tsx | 2 +- .../src/components/tab-bar/TabHoverCard.tsx | 72 +++++++--- .../tab-bar/TabSessionSwitchHoverAction.tsx | 91 ++++++++++++ .../tab-bar/cli-chat-resume-feedback.ts | 22 +-- .../components/tab-bar/cli-chat-resume-run.ts | 50 +++++-- .../tab-bar/cli-chat-resume-source.ts | 27 +++- .../tab-bar/native-chat-cli-switch-run.ts | 129 ++++++++++++++++++ .../tab-bar/switched-terminal-conversation.ts | 29 ++++ .../tab-bar/tab-session-history-switch.ts | 5 +- .../terminal-pane/TerminalContextMenu.tsx | 4 +- .../TerminalPaneResumeInChatButton.tsx | 40 ++++-- .../terminal-pane/pty-connection-types.ts | 2 +- .../terminal-pane/pty-transport-types.ts | 4 +- .../use-terminal-pane-projection.ts | 10 +- src/renderer/src/components/ui/hover-card.tsx | 3 +- .../ipc-events/terminal-request-ipc-bridge.ts | 1 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 15 +- src/renderer/src/i18n/locales/es.json | 15 +- src/renderer/src/i18n/locales/fr.json | 15 +- src/renderer/src/i18n/locales/ja.json | 15 +- src/renderer/src/i18n/locales/ko.json | 15 +- src/renderer/src/i18n/locales/zh.json | 15 +- .../src/lib/ai-vault-tab-title-requests.ts | 30 ++++ .../src/lib/in-place-switch-support.ts | 68 +++++++++ .../src/lib/launch-ai-vault-session.ts | 21 ++- .../structured-agent-session-client.ts | 14 ++ .../src/runtime/web-runtime-session-types.ts | 2 + .../web-runtime-terminal-create-operation.ts | 7 +- .../src/store/terminals/terminal-actions.ts | 4 +- .../src/store/terminals/terminal-state.ts | 2 +- src/shared/runtime-terminal-contracts.ts | 7 +- 48 files changed, 954 insertions(+), 170 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts create mode 100644 src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx create mode 100644 src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts create mode 100644 src/renderer/src/components/tab-bar/switched-terminal-conversation.ts create mode 100644 src/renderer/src/lib/in-place-switch-support.ts diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index afa346330e7a..7fee6f2d283f 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -41,7 +41,7 @@ export type PtyApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 0a32ecaae9a2..fb1168a06d06 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -37,7 +37,7 @@ export const ptySessionControlApi = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index bcd81ef62faf..43f82aebd0ce 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -20,7 +20,7 @@ import { selectNativeChatViewState, structuredChatHistoryPhase } from './native- import { useNativeChatRootInputRouting } from './use-native-chat-root-input-routing' import { useNativeChatFontSize } from './use-native-chat-font-size' import { useNativeChatFind } from './use-native-chat-find' -import { NativeChatFindBar } from './NativeChatFindBar' +import { NativeChatTopRightControls } from './NativeChatTopRightControls' import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' @@ -291,7 +291,7 @@ export function NativeChatStructuredSession( data-native-chat-scheme={appearanceStyle.colorScheme} >
- {find.isOpen ? : null} + {viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? ( loadingPane ) : viewState.kind === 'error' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx new file mode 100644 index 000000000000..1d544b074566 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx @@ -0,0 +1,90 @@ +import { SquareTerminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useInPlaceSwitchSupported } from '@/lib/in-place-switch-support' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { SwitchToCliTooltipText } from '../right-sidebar/ai-vault-session-cli-fork-copy' +import { + runNativeChatCliSwitch, + useNativeChatCliSwitchPending +} from '../tab-bar/native-chat-cli-switch-run' +import { resolveTabSessionHistorySubject } from '../tab-bar/tab-session-history-switch' +import { NativeChatFindBar } from './NativeChatFindBar' +import type { NativeChatFind } from './use-native-chat-find' +import type { NativeChatStructuredViewProps } from './native-chat-view-types' +import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' + +/** The chat's top-right corner: its find bar while open, else its icon-only "Switch to CLI". */ +export function NativeChatTopRightControls({ + find, + session, + working +}: { + find: NativeChatFind + session: Pick + working: boolean +}): React.JSX.Element | null { + const worktreeId = useNativeChatTabOwnerWorktreeId(session.tabId) + // A primitive, so the selector is stable; the chat's host is where its History row lives. + const hostId = useAppStore((state) => { + const subject = worktreeId + ? resolveTabSessionHistorySubject(state, { + tab: { id: session.tabId, worktreeId }, + nativeChat: { sessionId: session.sessionId, agent: session.agent } + }) + : null + return subject?.kind === 'chat' ? subject.request.executionHostScope : null + }) + const agent = AI_VAULT_AGENTS.find((candidate) => candidate === session.agent) + const inPlace = useInPlaceSwitchSupported(hostId, agent) + const pending = useNativeChatCliSwitchPending(session.sessionId) + if (find.isOpen) { + return + } + if (!worktreeId || !hostId || !agent || !inPlace) { + return null + } + const label = translate('components.tab.bar.switch.toCli', 'Switch to CLI') + return ( +
+ + + + + + {working ? ( + translate( + 'components.tab.bar.switch.waitForReply', + 'Wait for the reply to finish or stop it, then switch.' + ) + ) : ( + + )} + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 9c84b7d56f2e..bcbde501e96f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -289,7 +289,7 @@ export function useNativeChatContextMenu({ {translate( 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to Terminal' + 'Show as terminal' )} {shortcutLabel} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index 533e3c7cfab0..d963dcb4ab6b 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -13,6 +13,7 @@ import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-m import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { AiVaultSessionSurfaceSwitchMenuItems } from './AiVaultSessionSurfaceSwitchMenuItems' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' import { isAiVaultFolderWorkspaceId } from './ai-vault-session-worktree-affordances' export function SessionActionMenuItems({ @@ -35,7 +36,8 @@ export function SessionActionMenuItems({ onRevealLog, onOpenCwd, deleteBlockedReason, - onDelete + onDelete, + inPlaceAgent }: { menuKind?: 'dropdown' | 'context' resumeDisabled: boolean @@ -61,6 +63,7 @@ export function SessionActionMenuItems({ // Null when Delete is offered; otherwise the tooltip explaining why it isn't. deleteBlockedReason: string | null onDelete?: () => void + inPlaceAgent?: AiVaultAgent }) { const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator @@ -126,6 +129,7 @@ export function SessionActionMenuItems({ tooltipSide={menuKind === 'context' ? 'right' : 'left'} onResumeInNewChat={onResumeInNewChat} onResumeInNewCli={onResumeInNewCli} + inPlaceAgent={inPlaceAgent} /> {onCopyResume ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 21e6d56dff73..fd3e80fdc55f 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -1,16 +1,7 @@ import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import type React from 'react' -import { - FileJson, - Folder, - FolderGit2, - MessageSquare, - MessageSquarePlus, - MessagesSquare, - Play, - SquareTerminal -} from 'lucide-react' +import { FileJson, Folder, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -21,7 +12,7 @@ import { } from '../../../../shared/ai-vault-types' import { translate } from '@/i18n/i18n' import { FirstPromptCard } from './ai-vault-first-prompt-card' -import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy' +import { AiVaultSessionDetailsSwitchButtons } from './AiVaultSessionDetailsSwitchButtons' import { sessionDetailConversationTurns, sessionPromptPreview } from './ai-vault-session-display' import { SessionSubagentsSection } from './AiVaultSessionSubagents' import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice' @@ -141,51 +132,11 @@ export function SessionInlineDetails({ )} ) : null} - {onResumeInNewChat ? ( - - ) : null} - {onResumeInNewCli ? ( - - - - - - - - - ) : null} + {onContinueInNewSession ? ( + ) : null} + {onResumeInNewCli ? ( + + + + + + {inPlaceAgent ? ( + + ) : ( + + )} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 9c69c72e5939..18aa403119ce 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,3 +1,4 @@ +import { useAiVaultInPlaceAgent } from '@/lib/in-place-switch-support' import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { useCallback } from 'react' @@ -101,6 +102,7 @@ export function VaultSessionRow({ const updatedAt = session.updatedAt ?? session.modifiedAt const title = useAiVaultSessionDisplayTitle(session) const detailsId = getSessionDetailsId(session.id) + const inPlaceAgent = useAiVaultInPlaceAgent(session) const latestTurn = latestSessionConversationTurn(session) // Computed once so the dropdown menu and the context menu never disagree. const deleteBlockedReason = onRequestDelete @@ -286,6 +288,7 @@ export function VaultSessionRow({ onContinueInNewSession={onContinueInNewSession} onResumeInNewChat={onResumeInNewChat} onResumeInNewCli={onResumeInNewCli} + inPlaceAgent={inPlaceAgent} onCopyResume={onCopyResume} onCopyId={onCopyId} onCopyPath={onCopyPath} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx index 7cb8d6b607c3..1fa66f84b6b4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx @@ -3,10 +3,12 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { ContextMenuItem } from '@/components/ui/context-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' import { LoadingSpinner } from '@/components/LoadingSpinner' -import { ResumeInNewCliTooltipText } from './ai-vault-session-cli-fork-copy' +import { ResumeInNewCliTooltipText, SwitchToCliTooltipText } from './ai-vault-session-cli-fork-copy' -/** History opens a new tab; active-tab controls switch surfaces. */ +/** A tab's terminal always switches in place; History and a chat do once the agent switches in + * place on its host (`inPlace`), and otherwise open a new chat or fork into a new terminal. */ export function AiVaultSessionSurfaceSwitchMenuItems({ action, menuKind, @@ -14,7 +16,8 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ chatPending, onResumeInNewChat, onResumeInNewCli, - cliPending = false + cliPending = false, + inPlaceAgent }: { action: 'resume' | 'switch' menuKind: 'dropdown' | 'context' @@ -23,7 +26,10 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ onResumeInNewChat?: (event: Event) => void onResumeInNewCli?: (event: Event) => void cliPending?: boolean + /** The agent whose conversation switches in place on its host; unset keeps the new-tab entries. */ + inPlaceAgent?: AiVaultAgent }): React.JSX.Element { + const inPlace = inPlaceAgent !== undefined const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem return ( <> @@ -34,15 +40,12 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ aria-busy={chatPending || undefined} > - {action === 'resume' + {action === 'resume' && !inPlace ? translate( 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat', 'Resume in New Chat' ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to Chat' - )} + : translate('components.tab.bar.switch.toNativeChat', 'Switch to native chat')} ) : null} {onResumeInNewCli ? ( @@ -54,19 +57,20 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ ) : ( )} - {action === 'resume' - ? translate( + {inPlace + ? translate('components.tab.bar.switch.toCli', 'Switch to CLI') + : translate( 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewCli', 'Resume in New Terminal' - ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to Terminal' )} - + {inPlaceAgent ? ( + + ) : ( + + )} ) : null} diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 9fb4e3e90a98..002ce322dc59 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -1,3 +1,4 @@ +import { useAiVaultInPlaceAgent } from '@/lib/in-place-switch-support' import type React from 'react' import { ChevronDown, @@ -95,6 +96,7 @@ export function SessionRowTrailingActions({ /** Only the details toggle shows. */ actionsHidden?: boolean }) { + const inPlaceAgent = useAiVaultInPlaceAgent(session) const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) return ( @@ -274,6 +276,7 @@ export function SessionRowTrailingActions({ onContinueInNewSession={onContinueInNewSession} onResumeInNewChat={onResumeInNewChat} onResumeInNewCli={onResumeInNewCli} + inPlaceAgent={inPlaceAgent} onJumpToOriginalPane={onJumpToOriginalPane} showJumpToWorktree={showJumpToWorktree} jumpWorkspaceId={worktreeInfo?.worktreeId} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx index 09d39962455f..2cc5a6d6f316 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx @@ -1,5 +1,7 @@ import type React from 'react' import { translate } from '@/i18n/i18n' +import { agentLabel } from '../../../../shared/ai-vault-session-filters' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' /** Shared by the row menus and the details button, which offer the same fork. Two block lines, not * one wrapped string: the tooltip balances wrapped text inside a box sized before balancing, which @@ -22,3 +24,15 @@ export function ResumeInNewCliTooltipText(): React.JSX.Element { ) } + +export function SwitchToCliTooltipText({ agent }: { agent: AiVaultAgent }): React.JSX.Element { + return ( + <> + {translate( + 'components.tab.bar.switch.toCliTooltip', + 'Switch to CLI: stops {{agent}} here and continues the same conversation in the terminal, in this tab.', + { agent: agentLabel(agent) } + )} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts new file mode 100644 index 000000000000..969c8253a9bb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts @@ -0,0 +1,55 @@ +import { useAppStore } from '@/store' +import { collectAgentPaneConversations } from '@/lib/ai-vault-tab-title-requests' +import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { resolveCliChatResumeSource } from '../tab-bar/cli-chat-resume-source' +import { runCliChatResume } from '../tab-bar/cli-chat-resume-run' +import { runNativeChatCliSwitch } from '../tab-bar/native-chat-cli-switch-run' + +/** History's "Switch to CLI": in the chat tab's slot when that tab is open in this window, else a + * new tab. False when the row's host doesn't switch this agent in place, so the row forks. */ +export function switchAiVaultSessionToCli(session: AiVaultSession): boolean { + const chat = session.structuredSession + if (!chat || !inPlaceSwitchSupportedNow(session.executionHostId, session.agent)) { + return false + } + const chatTab = useAppStore + .getState() + .unifiedTabsByWorktree[chat.workspaceId]?.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === chat.sessionId + ) + void runNativeChatCliSwitch({ + sessionId: chat.sessionId, + workspaceId: chat.workspaceId, + executionHostId: session.executionHostId, + agent: session.agent, + chatTabId: chatTab?.id ?? null + }) + return true +} + +/** History's "Switch to native chat": in place when a pane in this window last reported the + * conversation. False otherwise, so the row opens or reveals its chat in a new tab. */ +export function switchAiVaultSessionToNativeChat(session: AiVaultSession): boolean { + if (!inPlaceSwitchSupportedNow(session.executionHostId, session.agent)) { + return false + } + const state = useAppStore.getState() + const pane = collectAgentPaneConversations(state, adoptsCliConversations).find( + (candidate) => + candidate.providerSession.id === session.sessionId && + candidate.executionHostId === session.executionHostId + ) + const source = pane + ? resolveCliChatResumeSource(state, { + tab: { id: pane.tabId, worktreeId: pane.worktreeId }, + paneKey: pane.paneKey + }) + : null + if (!source) { + return false + } + void runCliChatResume(source) + return true +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 0f4bcae5b34e..ac117ce5e8f7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -35,6 +35,10 @@ import { adoptsCliConversations } from '../../../../shared/structured-agent-cli- import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { describeAiVaultResumeFailure } from '@/lib/ai-vault-resume-refusal' import { resumeAiVaultSessionInNewChat } from './ai-vault-session-resume-in-chat-launch' +import { + switchAiVaultSessionToCli, + switchAiVaultSessionToNativeChat +} from './ai-vault-session-in-place-switch' import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' export function useAiVaultSessionLaunchActions({ @@ -147,6 +151,9 @@ export function useAiVaultSessionLaunchActions({ // Native chat keeps the conversation it owns; the terminal gets a copy (see the fork builder). const handleResumeInNewCli = useCallback( (session: AiVaultSession, targetWorktreeId: string): void => { + if (switchAiVaultSessionToCli(session)) { + return + } const prepareStartup = async (worktreeId: string): Promise => { assertAiVaultCopiedSessionLaunchAllowed({ state: useAppStore.getState(), @@ -190,7 +197,7 @@ export function useAiVaultSessionLaunchActions({ const handleResumeInNewChat = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { - if (!adoptsCliConversations(session.agent)) { + if (!adoptsCliConversations(session.agent) || switchAiVaultSessionToNativeChat(session)) { return } const worktreeId = targetWorktreeId ?? activeWorktreeId ?? activeWorktree?.id ?? null diff --git a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx index 99a1c1e73e9d..b675664fc006 100644 --- a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx @@ -8,7 +8,10 @@ import { type NativeChatTabSession } from './tab-session-history-switch' import { runNativeChatCliFork, useNativeChatCliForkPending } from './native-chat-cli-fork-run' +import { useInPlaceSwitchSupported } from '@/lib/in-place-switch-support' +import { runNativeChatCliSwitch, useNativeChatCliSwitchPending } from './native-chat-cli-switch-run' +/** "Switch to CLI" where the chat's host switches its agent in place; otherwise the fork. */ export function NativeChatCliForkMenuItems({ tab, nativeChat, @@ -24,9 +27,11 @@ export function NativeChatCliForkMenuItems({ resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) ) const chat = subject?.kind === 'chat' ? subject : null - const pending = useNativeChatCliForkPending(chat) + const forkPending = useNativeChatCliForkPending(chat) + const switchPending = useNativeChatCliSwitchPending(chat?.sessionId) const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) + const inPlace = useInPlaceSwitchSupported(chat?.request.executionHostScope, agent) if (!chat || !agent) { return null } @@ -37,10 +42,20 @@ export function NativeChatCliForkMenuItems({ action="switch" menuKind="dropdown" tooltipSide="right" - cliPending={pending} + cliPending={inPlace ? switchPending : forkPending} + {...(inPlace ? { inPlaceAgent: agent } : {})} onResumeInNewCli={(event) => { event.preventDefault() - void runNativeChatCliFork({ subject: chat, tab, agent, commandOverrides, onComplete }) + void (inPlace + ? runNativeChatCliSwitch({ + sessionId: chat.sessionId, + workspaceId: chat.workspaceId, + executionHostId: chat.request.executionHostScope, + agent, + chatTabId: tab.id, + onComplete + }) + : runNativeChatCliFork({ subject: chat, tab, agent, commandOverrides, onComplete })) }} /> diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 47daf01e46a3..35fe0991853f 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { basename } from '@/lib/path' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Input } from '@/components/ui/input' import { TabHoverCard } from './TabHoverCard' +import { TabSessionSwitchHoverAction } from './TabSessionSwitchHoverAction' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TerminalAgent } from '../../../../shared/terminal-agent' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -368,6 +369,7 @@ export default function SortableTab({ tabRoot ) : ( } title={displayTitle} programName={ tabAgent diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 64ca27e0e9b3..68d5d91ff3f0 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -194,7 +194,7 @@ export function SortableTabContextMenu({ {translate( 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to Terminal' + 'Show as terminal' )} diff --git a/src/renderer/src/components/tab-bar/TabHoverCard.tsx b/src/renderer/src/components/tab-bar/TabHoverCard.tsx index b116eb5b5cd2..b2eaa8fb91c4 100644 --- a/src/renderer/src/components/tab-bar/TabHoverCard.tsx +++ b/src/renderer/src/components/tab-bar/TabHoverCard.tsx @@ -1,19 +1,67 @@ import type { ReactNode } from 'react' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -export function TabHoverCard({ - children, +function TabHoverCardBody({ title, icon, programName, - description + description, + action +}: { + title: string + icon: ReactNode + programName: string + description?: string + action?: ReactNode +}): React.JSX.Element { + return ( +
+
+
+ {title} +
+ {description &&
{description}
} +
+
+ + {icon} + + {programName} +
+ {action} +
+ ) +} + +/** `action` makes the card a hover card the pointer can enter, so its control is clickable. */ +export function TabHoverCard({ + children, + ...body }: { children: ReactNode title: string icon: ReactNode programName: string description?: string + action?: ReactNode }): React.JSX.Element { + if (body.action) { + return ( + + {children} + + + + + ) + } return ( -
-
-
- {title} -
- {description &&
{description}
} -
-
- - {icon} - - {programName} -
-
+
) diff --git a/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx new file mode 100644 index 000000000000..b3c43402b831 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { MessagesSquare, SquareTerminal } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { useInPlaceSwitchSupported } from '@/lib/in-place-switch-support' +import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' +import { resolveCliChatResumeSource } from './cli-chat-resume-source' +import { runCliChatResume, useCliChatResumePending } from './cli-chat-resume-run' +import { runNativeChatCliSwitch, useNativeChatCliSwitchPending } from './native-chat-cli-switch-run' +import { + resolveTabSessionHistorySubject, + type NativeChatTabSession +} from './tab-session-history-switch' + +/** The tab's in-place switch inside its hover card; mounted only while the card is open. */ +export function TabSessionSwitchHoverAction({ + tab, + nativeChat +}: { + tab: { id: string; worktreeId: string } + nativeChat?: NativeChatTabSession +}): React.JSX.Element | null { + return nativeChat ? ( + + ) : ( + + ) +} + +function ChatSwitchAction({ + tab, + nativeChat +}: { + tab: { id: string; worktreeId: string } + nativeChat: NativeChatTabSession +}): React.JSX.Element | null { + const [subject] = useState(() => + resolveTabSessionHistorySubject(useAppStore.getState(), { tab, nativeChat }) + ) + const chat = subject?.kind === 'chat' ? subject : null + const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) + const inPlace = useInPlaceSwitchSupported(chat?.request.executionHostScope, agent) + const pending = useNativeChatCliSwitchPending(chat?.sessionId) + if (!chat || !agent || !inPlace) { + return null + } + return ( + + ) +} + +function TerminalSwitchAction({ + tab +}: { + tab: { id: string; worktreeId: string } +}): React.JSX.Element | null { + const source = useAppStore(useShallow((state) => resolveCliChatResumeSource(state, { tab }))) + const pending = useCliChatResumePending(source) + if (!source) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 1807c15f9c3f..15ef7c47435f 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -8,21 +8,22 @@ import type { RuntimeClientTarget } from '../../../../shared/runtime-client-targ import { cliConversationExitGuidance } from '@/lib/cli-conversation-exit-guidance' import type { CliChatResumeSource } from './cli-chat-resume-source' +/** True when the chat that already holds the conversation was revealed instead: the switch's + * writer check passed before the host named that chat. */ export async function reportCliChatResumeFailure( source: CliChatResumeSource, error: unknown, - openOwner: boolean, target: RuntimeClientTarget -): Promise { +): Promise { const refusal = structuredLaunchFailure(error) if ( refusal?.code === 'agent_session_conflict' || refusal?.code === 'agent_session_ownership_unknown' ) { const owner = refusal.owner - if (openOwner && owner) { + if (owner) { await activateAiVaultStructuredSession({ structuredSession: owner }, undefined, target) - return + return true } toast.error( refusal.code === 'agent_session_conflict' && @@ -34,7 +35,7 @@ export async function reportCliChatResumeFailure( 'Another chat is already holding this conversation.' ) ) - return + return false } // The chat's agent refused to start while the terminal may still write the conversation. if ( @@ -42,7 +43,11 @@ export async function reportCliChatResumeFailure( refusal.details?.reason === 'conversationOpenInTerminal' ) { toast.error(cliConversationExitGuidance(source.agent, true)) - return + return false + } + if (refusal?.details?.reason === 'conversationMovingToTerminal') { + toast.error(agentSessionWriteFailureText(refusal, 'read-history')) + return false } if (refusal?.code === 'agent_session_identity_required') { if (refusal.details?.reason === 'transcriptNotFound') { @@ -53,12 +58,12 @@ export async function reportCliChatResumeFailure( { agent: agentLabel(source.agent) } ) ) - return + return false } const failure = structuredLaunchFailure(error) if (failure) { toast.error(agentSessionWriteFailureText(failure, 'read-history')) - return + return false } } toast.error( @@ -67,4 +72,5 @@ export async function reportCliChatResumeFailure( 'Could not resume this session in a new chat.' ) ) + return false } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 87a5e93246e8..032e45367f5c 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -3,13 +3,29 @@ import { useAppStore } from '@/store' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' -import { supportsStructuredAgentSessionCliSource } from '@/runtime/structured-agent-session-client' +import { + supportsInPlaceSwitch, + supportsStructuredAgentSessionCliSource +} from '@/runtime/structured-agent-session-client' +import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { waitForPromiseWithSignal } from '../../../../shared/abort-signal-reason' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured-agent-cli-conversation-adoption' import { resolveCliChatResumeSource, type CliChatResumeSource } from './cli-chat-resume-source' import { reportCliChatResumeFailure } from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' +import { forgetSwitchedTerminalConversation } from './switched-terminal-conversation' + +/** The chat took the conversation, so the terminal pane it came from closes (its tab with it). */ +function retireSwitchedTerminalPane(source: CliChatResumeSource): void { + const pane = parsePaneKey(source.paneKey) + forgetSwitchedTerminalConversation(source.tabId) + if (pane) { + const detail: CloseTerminalPaneDetail = { tabId: pane.tabId, leafId: pane.leafId } + window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) + } +} const pending = new Map() const listeners = new Set<() => void>() @@ -33,16 +49,15 @@ export function useCliChatResumePending(source: CliChatResumeSource | null): boo ) } -export async function runCliChatResume( - source: CliChatResumeSource, - openOwner = false -): Promise { +/** Switches a terminal's conversation into a chat in the terminal's slot: a new chat, or the chat + * that already holds it, revealed. The terminal pane closes once the chat has it. */ +export async function runCliChatResume(source: CliChatResumeSource): Promise { const key = keyFor(source) if (pending.has(key)) { return } const target = structuredAgentSessionTargetForHost(source.executionHostId) - if (!target || target.kind !== 'local') { + if (!target) { return } const controller = new AbortController() @@ -97,6 +112,15 @@ export async function runCliChatResume( return true } try { + if ( + target.kind !== 'local' && + !(await waitForPromiseWithSignal( + supportsInPlaceSwitch(target, source.agent), + controller.signal + )) + ) { + return + } const cliSourceSupported = await waitForPromiseWithSignal( supportsStructuredAgentSessionCliSource(target), controller.signal @@ -127,18 +151,26 @@ export async function runCliChatResume( const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: { beforeCreate: handoff }, + afterTabId: source.tabId, deferPublication: true }) if (!launch) { return } const result = await waitForPromiseWithSignal(launch.settlement, controller.signal) - if (result.kind === 'failed' && !result.notified && pending.get(key) === controller) { - await reportCliChatResumeFailure(source, result.error, openOwner, target) + if (result.kind === 'structured') { + retireSwitchedTerminalPane(source) + } else if ( + result.kind === 'failed' && + !result.notified && + pending.get(key) === controller && + (await reportCliChatResumeFailure(source, result.error, target)) + ) { + retireSwitchedTerminalPane(source) } } catch (error) { if (pending.get(key) === controller) { - await reportCliChatResumeFailure(source, error, false, target) + await reportCliChatResumeFailure(source, error, target) } } finally { clearTimeout(timer) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index beba57e98e09..a903e46429de 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -1,14 +1,17 @@ import type { AppState } from '@/store/types' import { collectAgentPaneConversations, - resolvePaneAgentConversation + resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' +import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import { makePaneKey } from '../../../../shared/stable-pane-id' import { adoptsCliConversations, type CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' import { canPaneOfferResumeInNewChat } from './tab-session-history-switch' +import { switchedTerminalConversation } from './switched-terminal-conversation' export type CliChatResumeSource = { tabId: string @@ -19,24 +22,34 @@ export type CliChatResumeSource = { executionHostId: ExecutionHostId } +/** The pane's last reported conversation, else the one a switch just put in this tab. */ export function resolveCliChatResumeSource( state: AppState, args: { tab: { id: string; worktreeId: string }; paneKey?: string } ): CliChatResumeSource | null { const conversation = args.paneKey - ? resolvePaneAgentConversation( + ? resolvePaneLastAgentConversation( state, { worktreeId: args.tab.worktreeId, paneKey: args.paneKey }, adoptsCliConversations ) - : collectAgentPaneConversations(state, adoptsCliConversations, { - runningAgentsOnly: true - }).find((candidate) => candidate.tabId === args.tab.id) + : collectAgentPaneConversations(state, adoptsCliConversations).find( + (candidate) => candidate.tabId === args.tab.id + ) + const leafId = state.terminalLayoutsByTabId[args.tab.id]?.activeLeafId + const seeded = switchedTerminalConversation(args.tab.id) + const paneKey = args.paneKey ?? (leafId ? makePaneKey(args.tab.id, leafId) : null) + if (!conversation) { + return seeded && paneKey + ? { tabId: args.tab.id, workspaceId: args.tab.worktreeId, paneKey, ...seeded } + : null + } if ( - !conversation || conversation.tabId !== args.tab.id || conversation.worktreeId !== args.tab.worktreeId || - conversation.executionHostId !== LOCAL_EXECUTION_HOST_ID || + // A paired host moves a terminal's conversation only once it switches that agent in place. + (conversation.executionHostId !== LOCAL_EXECUTION_HOST_ID && + !inPlaceSwitchSupportedNow(conversation.executionHostId, conversation.agent)) || !canPaneOfferResumeInNewChat(state, { worktreeId: conversation.worktreeId, paneKey: conversation.paneKey diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts new file mode 100644 index 000000000000..af580d1f5a54 --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts @@ -0,0 +1,129 @@ +import { useSyncExternalStore } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { readIpcErrorMessage } from '@/lib/ipc-error' +import { buildAiVaultResumeStartupForWorktree } from '@/lib/ai-vault-resume-command' +import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' +import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' +import { + callStructuredAgentSession, + supportsInPlaceSwitch +} from '@/runtime/structured-agent-session-client' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { AiVaultChatForkSourceSchema } from '../../../../shared/ai-vault-chat-fork-contract' +import type { AiVaultAgent } from '../../../../shared/ai-vault-types' +import { agentLabel } from '../../../../shared/ai-vault-session-filters' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { seedSwitchedTerminalConversation } from './switched-terminal-conversation' + +const pending = new Set() +const listeners = new Set<() => void>() +const changed = () => listeners.forEach((listener) => listener()) + +export function useNativeChatCliSwitchPending(sessionId: string | null | undefined): boolean { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => !!sessionId && pending.has(sessionId), + () => false + ) +} + +/** Why a switch to the CLI didn't happen, in words the chat's user can act on. */ +export function nativeChatCliSwitchFailureText(error: unknown, agent: AiVaultAgent): string { + const refusal = structuredLaunchFailure(error) + const reason = refusal?.details?.reason + if (reason === 'turnActive') { + return translate( + 'components.tab.bar.switch.waitForReply', + 'Wait for the reply to finish or stop it, then switch.' + ) + } + if (reason === 'ownerUnproven') { + return translate( + 'components.tab.bar.switch.stopUnconfirmed', + "Couldn't confirm {{agent}} stopped. Try again.", + { agent: agentLabel(agent) } + ) + } + if (reason === 'conversationHeldElsewhere') { + return translate( + 'components.native-chat.failureWords.conversationMovingToTerminal', + 'This conversation is moving to a terminal. Use it there, or wait a moment and try again.' + ) + } + return ( + (refusal ? undefined : readIpcErrorMessage(error)) || + translate('components.tab.bar.switch.failed', "Couldn't switch this conversation to the CLI.") + ) +} + +/** Moves a chat's conversation into a terminal on the same id: the host stops the idle chat, the + * terminal opens in the chat's slot, then the chat tab closes. `chatTabId` null opens a new tab. */ +export async function runNativeChatCliSwitch(args: { + sessionId: string + workspaceId: string + executionHostId: string + agent: AiVaultAgent + chatTabId: string | null + onComplete?: () => void +}): Promise { + const target = structuredAgentSessionTargetForHost(args.executionHostId) + const hostId = parseExecutionHostId(args.executionHostId)?.id + if (pending.has(args.sessionId) || !target || !hostId) { + return + } + pending.add(args.sessionId) + changed() + try { + // Fail closed: a host that doesn't advertise the switch never receives it. + if (!(await supportsInPlaceSwitch(target, args.agent))) { + throw new Error(nativeChatCliSwitchFailureText(null, args.agent)) + } + const answered = await callStructuredAgentSession<{ source: unknown }>( + target, + 'agentSession.switchToTerminal', + { sessionId: args.sessionId } + ) + const source = AiVaultChatForkSourceSchema.parse(answered.source) + const state = useAppStore.getState() + const startup = buildAiVaultResumeStartupForWorktree({ + state, + worktreeId: args.workspaceId, + session: { ...source, executionHostId: hostId }, + commandOverride: state.settings?.agentCmdOverrides?.[source.agent] + }) + const launch = launchAiVaultSessionInNewTab({ + agent: source.agent, + worktreeId: args.workspaceId, + ...startup, + ...(args.chatTabId ? { afterTabId: args.chatTabId } : {}), + resumeOrigin: 'switch' + }) + const outcome = launch.tabId === null ? await launch.runtimeLaunch : null + if (outcome && outcome.status !== 'created') { + throw new Error(outcome.status === 'failed' ? outcome.message : undefined) + } + if (launch.tabId && adoptsCliConversations(source.agent)) { + seedSwitchedTerminalConversation(launch.tabId, { + agent: source.agent, + providerSessionId: source.sessionId, + executionHostId: hostId + }) + } + // The terminal exists, so the chat tab retires; its host close finds the agent already stopped. + if (args.chatTabId) { + useAppStore.getState().closeUnifiedTab(args.chatTabId) + } + } catch (error) { + toast.error(nativeChatCliSwitchFailureText(error, args.agent)) + } finally { + pending.delete(args.sessionId) + changed() + args.onComplete?.() + } +} diff --git a/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts b/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts new file mode 100644 index 000000000000..3651a0afdd04 --- /dev/null +++ b/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts @@ -0,0 +1,29 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' + +/** The conversation a switch put in a terminal tab, until its agent reports one. Presentation only: + * an agent's first report comes with its first prompt, and the switch back is offered before it. */ +type SwitchedTerminalConversation = { + agent: CliConversationRowAgent + providerSessionId: string + executionHostId: ExecutionHostId +} + +const byTabId = new Map() + +export function seedSwitchedTerminalConversation( + tabId: string, + conversation: SwitchedTerminalConversation +): void { + byTabId.set(tabId, conversation) +} + +export function switchedTerminalConversation( + tabId: string +): SwitchedTerminalConversation | undefined { + return byTabId.get(tabId) +} + +export function forgetSwitchedTerminalConversation(tabId: string): void { + byTabId.delete(tabId) +} diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index 93abdfcd2900..d335de178ede 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -4,7 +4,8 @@ import type { AppState } from '@/store/types' import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { collectAgentPaneConversations, - resolvePaneAgentConversation + resolvePaneAgentConversation, + resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' import { isAiVaultSessionResumableContent, @@ -82,7 +83,7 @@ export function canPaneOfferResumeInNewChat( state: AppState, pane: { worktreeId: string; paneKey: string } ): boolean { - const conversation = resolvePaneAgentConversation(state, pane, adoptsCliConversations) + const conversation = resolvePaneLastAgentConversation(state, pane, adoptsCliConversations) if (!conversation) { return false } diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 51fdee846177..8f6989e3ea6a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -233,11 +233,11 @@ function TerminalContextMenuItems({ {isNativeChatView ? translate( 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to Terminal' + 'Show as terminal' ) : translate( 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to Chat' + 'Show as chat' )} {shortcuts.nativeChat} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx index d012862a7658..9a980777e67f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' +import { agentLabel } from '../../../../shared/ai-vault-session-filters' import { resolveCliChatResumeSource } from '../tab-bar/cli-chat-resume-source' import { runCliChatResume, useCliChatResumePending } from '../tab-bar/cli-chat-resume-run' @@ -18,17 +19,32 @@ export function TerminalPaneResumeInChatButton({ }): React.JSX.Element { const source = useAppStore( useShallow((state) => - resolveCliChatResumeSource(state, { - tab: { id: tabId, worktreeId }, - paneKey - }) + resolveCliChatResumeSource(state, { tab: { id: tabId, worktreeId }, paneKey }) ) ) + // The host's writer check decides; this only explains the refusal before the click. + const running = useAppStore((state) => { + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + return ( + state.agentStatusByPaneKey[paneKey] !== undefined || + (!!foreground?.agent && !foreground.shellForeground) + ) + }) const pending = useCliChatResumePending(source) - const label = translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to Chat' - ) + const label = translate('components.tab.bar.switch.toNativeChat', 'Switch to native chat') + const tooltip = + running && source + ? translate( + 'components.tab.bar.switch.exitAgentFirst', + 'Exit {{agent}} in this terminal to switch.', + { + agent: agentLabel(source.agent) + } + ) + : translate( + 'components.tab.bar.switch.toNativeChatTooltip', + 'Switch to native chat: continues the same conversation in a chat, in this tab.' + ) return ( @@ -41,11 +57,13 @@ export function TerminalPaneResumeInChatButton({ className="pane-title-split-trigger" aria-label={label} disabled={!source || pending} + // Not `disabled`: the tooltip has to say why. + aria-disabled={running || undefined} aria-busy={pending || undefined} onClick={(event) => { event.stopPropagation() - if (source) { - void runCliChatResume(source, true) + if (source && !running) { + void runCliChatResume(source) } }} > @@ -53,7 +71,7 @@ export function TerminalPaneResumeInChatButton({ - {label} + {tooltip} ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 6d2fb3583ffe..46c37197fab8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -31,7 +31,7 @@ export type PtyPaneStartup = { launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: a resume built from saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent 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 b14f471f2394..e81ef8adf48d 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -193,7 +193,7 @@ export type PtyTransport = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent startupCommandDelivery?: StartupCommandDelivery @@ -299,7 +299,7 @@ export type IpcPtyTransportOptions = { commandDelivery?: 'renderer' | 'provider' launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' agentPrompt?: string agentPromptDelivery?: AgentPromptDelivery agentArgsOverride?: string | null diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index e36be126e8a6..4d6998fff093 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -1,3 +1,4 @@ +import { switchedTerminalConversation } from '../tab-bar/switched-terminal-conversation' import { useCallback, useEffect } from 'react' import type { CSSProperties } from 'react' import { @@ -188,10 +189,11 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll activePaneCanResumeInChat: !activePaneIsChatLeaf && activeLeafId !== null && - canPaneOfferResumeInNewChat(state, { - worktreeId, - paneKey: makePaneKey(tabId, activeLeafId) - }) + (switchedTerminalConversation(tabId) !== undefined || + canPaneOfferResumeInNewChat(state, { + worktreeId, + paneKey: makePaneKey(tabId, activeLeafId) + })) })) ) const contextMenuIsChatView = effectiveChatViewMode && contextMenuLeafId === chatLeafId diff --git a/src/renderer/src/components/ui/hover-card.tsx b/src/renderer/src/components/ui/hover-card.tsx index 4ed28740cba3..479c5370c56e 100644 --- a/src/renderer/src/components/ui/hover-card.tsx +++ b/src/renderer/src/components/ui/hover-card.tsx @@ -19,7 +19,8 @@ const hoverCardContentVariants = cva('', { variant: { default: 'w-64 p-4', 'workspace-details': - 'w-[360px] max-w-[calc(100vw-2rem)] max-h-[28rem] overflow-y-auto p-3 text-xs scrollbar-sleek' + 'w-[360px] max-w-[calc(100vw-2rem)] max-h-[28rem] overflow-y-auto p-3 text-xs scrollbar-sleek', + 'tab-preview': 'w-64 max-w-[calc(100vw-1rem)] p-3 text-xs' } }, defaultVariants: { variant: 'default' } diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts index 64898b7ef134..eb5f52a9e8d9 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge.ts @@ -71,6 +71,7 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { ...(data.resumeProviderSession ? { resumeProviderSession: data.resumeProviderSession } : {}), + ...(data.resumeOrigin ? { resumeOrigin: data.resumeOrigin } : {}), ...(data.launchToken ? { launchToken: data.launchToken } : {}), ...(data.launchAgent ? { launchAgent: data.launchAgent } : {}), ...(data.startupCommandDelivery diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index be251bdb1857..6cd30af0dc93 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2971,6 +2971,7 @@ "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", "conversationOpenElsewhere": "Codex still has this conversation open somewhere else. Close it there, then try again.", "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + "conversationMovingToTerminal": "This conversation is moving to a terminal. Use it there, or wait a moment and try again.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ae05c28d041a..d0cee595676c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18348,6 +18348,7 @@ "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", "conversationOpenElsewhere": "Codex still has this conversation open somewhere else. Close it there, then try again.", "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", + "conversationMovingToTerminal": "This conversation is moving to a terminal. Use it there, or wait a moment and try again.", "chooseClaudeAccount": "Choose or add one in Claude Accounts settings.", "chooseClaudeAccountThenRunCommand": "Choose or add one in Claude Accounts settings, then run /{{command}} again.", "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", @@ -19065,10 +19066,20 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "Switch to Terminal", - "switchToChatView": "Switch to Chat", + "switchToTerminalView": "Show as terminal", + "switchToChatView": "Show as chat", "closeTabsToLeft": "Close All Tabs To The Left" }, + "switch": { + "toCli": "Switch to CLI", + "toNativeChat": "Switch to native chat", + "toCliTooltip": "Switch to CLI: stops {{agent}} here and continues the same conversation in the terminal, in this tab.", + "toNativeChatTooltip": "Switch to native chat: continues the same conversation in a chat, in this tab.", + "exitAgentFirst": "Exit {{agent}} in this terminal to switch.", + "waitForReply": "Wait for the reply to finish or stop it, then switch.", + "stopUnconfirmed": "Couldn't confirm {{agent}} stopped. Try again.", + "failed": "Couldn't switch this conversation to the CLI." + }, "BrowserTab": { "closeOthers": "Close Others", "closeTabsToLeft": "Close All Tabs To The Left" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f232d4a69154..3cd8daac0e8d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18062,6 +18062,7 @@ "runtimeNotInstalled": "{{agent}} no está instalado en el equipo que ejecuta este chat. Instálelo desde el chat en una versión actual de Orca.", "conversationOpenElsewhere": "Codex aún tiene esta conversación abierta en otro lugar. Ciérrela allí y vuelva a intentarlo.", "conversationOpenInTerminal": "Orca no puede confirmar que {{agent}} haya salido en su terminal. Detenga lo que se esté ejecutando allí o cierre la terminal y vuelva a intentarlo.", + "conversationMovingToTerminal": "Esta conversación se está trasladando a una terminal. Úsela allí o espere un momento y vuelva a intentarlo.", "chooseClaudeAccount": "Elija o agregue una en la configuración de Cuentas de Claude.", "chooseClaudeAccountThenRunCommand": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a ejecutar /{{command}}.", "chooseClaudeAccountThenSend": "Elija o agregue una en la configuración de Cuentas de Claude y vuelva a enviar su mensaje.", @@ -18761,10 +18762,20 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "Cambiar al terminal", - "switchToChatView": "Cambiar al chat", + "switchToTerminalView": "Mostrar como terminal", + "switchToChatView": "Mostrar como chat", "closeTabsToLeft": "Cerrar todas las pestañas a la izquierda" }, + "switch": { + "toCli": "Cambiar a la CLI", + "toNativeChat": "Cambiar al chat nativo", + "toCliTooltip": "Cambiar a la CLI: detiene {{agent}} aquí y continúa la misma conversación en la terminal, en esta pestaña.", + "toNativeChatTooltip": "Cambiar al chat nativo: continúa la misma conversación en un chat, en esta pestaña.", + "exitAgentFirst": "Salga de {{agent}} en esta terminal para cambiar.", + "waitForReply": "Espere a que termine la respuesta o deténgala y luego cambie.", + "stopUnconfirmed": "No se pudo confirmar que {{agent}} se detuvo. Vuelva a intentarlo.", + "failed": "No se pudo cambiar esta conversación a la CLI." + }, "BrowserTab": { "closeOthers": "Cerrar otras", "closeTabsToLeft": "Cerrar todas las pestañas a la izquierda" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 1da9b09e2a39..79b45c68fc1c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18171,6 +18171,7 @@ "runtimeNotInstalled": "{{agent}} n'est pas installé sur l'ordinateur qui exécute ce chat. Installez-le depuis le chat dans une version récente d'Orca.", "conversationOpenElsewhere": "Codex garde cette conversation ouverte ailleurs. Fermez-la à cet endroit, puis réessayez.", "conversationOpenInTerminal": "Orca ne peut pas confirmer que {{agent}} a quitté son terminal. Arrêtez ce qui s'y exécute ou fermez le terminal, puis réessayez.", + "conversationMovingToTerminal": "Cette conversation est en cours de transfert vers un terminal. Utilisez-la là-bas, ou attendez un instant et réessayez.", "chooseClaudeAccount": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude.", "chooseClaudeAccountThenRunCommand": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis relancez /{{command}}.", "chooseClaudeAccountThenSend": "Choisissez-en un ou ajoutez-en un dans les paramètres Comptes Claude, puis renvoyez votre message.", @@ -18870,10 +18871,20 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "Passer au terminal", - "switchToChatView": "Passer au chat", + "switchToTerminalView": "Afficher en terminal", + "switchToChatView": "Afficher en chat", "closeTabsToLeft": "Fermer tous les onglets à gauche" }, + "switch": { + "toCli": "Passer à la CLI", + "toNativeChat": "Passer au chat natif", + "toCliTooltip": "Passer à la CLI : arrête {{agent}} ici et poursuit la même conversation dans le terminal, dans cet onglet.", + "toNativeChatTooltip": "Passer au chat natif : poursuit la même conversation dans un chat, dans cet onglet.", + "exitAgentFirst": "Quittez {{agent}} dans ce terminal pour basculer.", + "waitForReply": "Attendez la fin de la réponse ou arrêtez-la, puis basculez.", + "stopUnconfirmed": "Impossible de confirmer l'arrêt de {{agent}}. Réessayez.", + "failed": "Impossible de basculer cette conversation vers la CLI." + }, "BrowserTab": { "closeOthers": "Fermer les autres", "closeTabsToLeft": "Fermer tous les onglets à gauche" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7f5f620fd4cd..0466b3789f0d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18102,6 +18102,7 @@ "runtimeNotInstalled": "{{agent}} はこのチャットを実行しているコンピューターにインストールされていません。最新バージョンの Orca でチャットからインストールしてください。", "conversationOpenElsewhere": "Codex はまだ別の場所でこの会話を開いています。そこで閉じてから、もう一度お試しください。", "conversationOpenInTerminal": "Orca は {{agent}} がターミナルで終了したことを確認できません。そこで実行中のものを停止するかターミナルを閉じてから、もう一度お試しください。", + "conversationMovingToTerminal": "この会話はターミナルに移動中です。そちらで使用するか、少し待ってからもう一度お試しください。", "chooseClaudeAccount": "Claude アカウントの設定で選択または追加してください。", "chooseClaudeAccountThenRunCommand": "Claude アカウントの設定で選択または追加してから、/{{command}} をもう一度実行してください。", "chooseClaudeAccountThenSend": "Claude アカウントの設定で選択または追加してから、メッセージをもう一度送信してください。", @@ -18801,10 +18802,20 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "ターミナルに切り替える", - "switchToChatView": "チャットに切り替える", + "switchToTerminalView": "ターミナルとして表示", + "switchToChatView": "チャットとして表示", "closeTabsToLeft": "左側のすべてのタブを閉じる" }, + "switch": { + "toCli": "CLI に切り替える", + "toNativeChat": "ネイティブチャットに切り替える", + "toCliTooltip": "CLI に切り替え: ここで {{agent}} を停止し、このタブのターミナルで同じ会話を続けます。", + "toNativeChatTooltip": "ネイティブチャットに切り替え: このタブのチャットで同じ会話を続けます。", + "exitAgentFirst": "切り替えるには、このターミナルで {{agent}} を終了してください。", + "waitForReply": "応答が終わるまで待つか停止してから、切り替えてください。", + "stopUnconfirmed": "{{agent}} が停止したことを確認できませんでした。もう一度お試しください。", + "failed": "この会話を CLI に切り替えられませんでした。" + }, "BrowserTab": { "closeOthers": "その他を閉じる", "closeTabsToLeft": "左側のすべてのタブを閉じる" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8489acc0e720..6b4c10118f23 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18102,6 +18102,7 @@ "runtimeNotInstalled": "{{agent}}이(가) 이 채팅을 실행하는 컴퓨터에 설치되어 있지 않습니다. 최신 버전의 Orca에서 채팅을 통해 설치하세요.", "conversationOpenElsewhere": "Codex가 다른 곳에서 이 대화를 열어 두고 있습니다. 그곳에서 닫은 후 다시 시도하세요.", "conversationOpenInTerminal": "Orca에서 {{agent}}이(가) 터미널에서 종료되었는지 확인할 수 없습니다. 그곳에서 실행 중인 항목을 중지하거나 터미널을 닫은 후 다시 시도하세요.", + "conversationMovingToTerminal": "이 대화는 터미널로 이동하는 중입니다. 그곳에서 사용하거나 잠시 후 다시 시도하세요.", "chooseClaudeAccount": "Claude 계정 설정에서 선택하거나 추가하세요.", "chooseClaudeAccountThenRunCommand": "Claude 계정 설정에서 선택하거나 추가한 다음 /{{command}}을(를) 다시 실행하세요.", "chooseClaudeAccountThenSend": "Claude 계정 설정에서 선택하거나 추가한 다음 메시지를 다시 보내세요.", @@ -18801,10 +18802,20 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "터미널로 전환", - "switchToChatView": "채팅으로 전환", + "switchToTerminalView": "터미널로 표시", + "switchToChatView": "채팅으로 표시", "closeTabsToLeft": "왼쪽의 모든 탭 닫기" }, + "switch": { + "toCli": "CLI로 전환", + "toNativeChat": "네이티브 채팅으로 전환", + "toCliTooltip": "CLI로 전환: 여기서 {{agent}}을(를) 중지하고 이 탭의 터미널에서 같은 대화를 이어갑니다.", + "toNativeChatTooltip": "네이티브 채팅으로 전환: 이 탭의 채팅에서 같은 대화를 이어갑니다.", + "exitAgentFirst": "전환하려면 이 터미널에서 {{agent}}을(를) 종료하세요.", + "waitForReply": "응답이 끝날 때까지 기다리거나 응답을 중지한 후 전환하세요.", + "stopUnconfirmed": "{{agent}}이(가) 중지되었는지 확인할 수 없습니다. 다시 시도하세요.", + "failed": "이 대화를 CLI로 전환할 수 없습니다." + }, "BrowserTab": { "closeOthers": "다른 탭 닫기", "closeTabsToLeft": "왼쪽의 모든 탭 닫기" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cbdf29ae07ab..6d7091fe9fb3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18067,6 +18067,7 @@ "runtimeNotInstalled": "运行此聊天的计算机上未安装 {{agent}}。请在最新版本的 Orca 中从聊天里安装。", "conversationOpenElsewhere": "Codex 仍在其他地方打开着此对话。请在那里关闭后重试。", "conversationOpenInTerminal": "Orca 无法确认 {{agent}} 已在其终端中退出。请停止那里正在运行的内容或关闭终端,然后重试。", + "conversationMovingToTerminal": "此对话正在转移到终端。请在那里使用,或稍等片刻后重试。", "chooseClaudeAccount": "请在 Claude 账户设置中选择或添加一个。", "chooseClaudeAccountThenRunCommand": "请在 Claude 账户设置中选择或添加一个,然后重新运行 /{{command}}。", "chooseClaudeAccountThenSend": "请在 Claude 账户设置中选择或添加一个,然后重新发送您的消息。", @@ -18766,10 +18767,20 @@ "tab": { "bar": { "SortableTabContextMenu": { - "switchToTerminalView": "切换到终端", - "switchToChatView": "切换到聊天", + "switchToTerminalView": "以终端显示", + "switchToChatView": "以聊天显示", "closeTabsToLeft": "关闭左侧的所有选项卡" }, + "switch": { + "toCli": "切换到 CLI", + "toNativeChat": "切换到原生聊天", + "toCliTooltip": "切换到 CLI:在此停止 {{agent}},并在此标签页的终端中继续同一对话。", + "toNativeChatTooltip": "切换到原生聊天:在此标签页的聊天中继续同一对话。", + "exitAgentFirst": "请先在此终端中退出 {{agent}},然后再切换。", + "waitForReply": "请等待回复完成或停止回复,然后再切换。", + "stopUnconfirmed": "无法确认 {{agent}} 已停止。请重试。", + "failed": "无法将此对话切换到 CLI。" + }, "BrowserTab": { "closeOthers": "关闭其他", "closeTabsToLeft": "关闭左侧的所有选项卡" diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 600c223c8573..da315267f73b 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -167,3 +167,33 @@ export function resolvePaneAgentConversation( const { priority: _priority, ...request } = candidate return request } + +/** As above, or the conversation the pane's agent last reported once it exited: a switch is + * offered when the agent is gone, which is when it can succeed. */ +export function resolvePaneLastAgentConversation( + state: AppState, + pane: { worktreeId: string; paneKey: string }, + isAgent: (agent: AgentType | null | undefined) => agent is Agent +): AgentPaneConversation | null { + const live = resolvePaneAgentConversation(state, pane, isAgent) + const retained = state.retainedAgentsByPaneKey[pane.paneKey] + if (live || retained?.entry.paneKey !== pane.paneKey || retained.worktreeId !== pane.worktreeId) { + return live + } + const candidates = new Map>() + registerCandidate(state, new Map([[retained.tab.id, retained.tab]]), candidates, isAgent, { + agent: retained.agentType, + paneKey: pane.paneKey, + priority: 10, + providerSession: retained.entry.providerSession, + refresh: false, + tabId: retained.tab.id, + worktreeId: retained.worktreeId + }) + const [candidate] = candidates.values() + if (!candidate) { + return null + } + const { priority: _priority, ...request } = candidate + return request +} diff --git a/src/renderer/src/lib/in-place-switch-support.ts b/src/renderer/src/lib/in-place-switch-support.ts new file mode 100644 index 000000000000..58febd83ed08 --- /dev/null +++ b/src/renderer/src/lib/in-place-switch-support.ts @@ -0,0 +1,68 @@ +import { useSyncExternalStore } from 'react' +import { supportsInPlaceSwitch } from '@/runtime/structured-agent-session-client' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { + onRuntimeEnvironmentRevisionsChanged, + onRuntimeEnvironmentsRetired +} from '@/runtime/runtime-environment-revision' +import { inPlaceSwitchEnabled } from '../../../shared/structured-agent-cli-conversation-adoption' +import type { AiVaultAgent } from '../../../shared/ai-vault-types' + +// The answer per host and agent; a reconnect or retirement asks again. +const known = new Map() +const asking = new Set() +const listeners = new Set<() => void>() +const changed = () => listeners.forEach((listener) => listener()) +const forget = () => { + known.clear() + changed() +} +onRuntimeEnvironmentRevisionsChanged(forget) +onRuntimeEnvironmentsRetired(forget) + +/** The cached answer, asking in the background when there is none yet. */ +export function inPlaceSwitchSupportedNow( + executionHostId: string | null | undefined, + agent: string +): boolean { + if (!inPlaceSwitchEnabled(agent)) { + return false + } + const key = `${executionHostId ?? ''}\0${agent}` + const answer = known.get(key) + const target = structuredAgentSessionTargetForHost(executionHostId) + if (answer === undefined && target && !asking.has(key)) { + asking.add(key) + void supportsInPlaceSwitch(target, agent).then((supported) => { + asking.delete(key) + known.set(key, supported) + changed() + }) + } + return answer === true +} + +/** Whether the host running this conversation switches it in place; false until it answered yes. */ +export function useInPlaceSwitchSupported( + executionHostId: string | null | undefined, + agent: string | null | undefined +): boolean { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => (agent ? inPlaceSwitchSupportedNow(executionHostId, agent) : false), + () => false + ) +} + +/** A History row's agent when its host switches it in place: the row offers the switch, not a copy. */ +export function useAiVaultInPlaceAgent(session: { + agent: AiVaultAgent + executionHostId?: string | null +}): AiVaultAgent | undefined { + return useInPlaceSwitchSupported(session.executionHostId, session.agent) + ? session.agent + : undefined +} diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index b630483e51aa..4646748c8793 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -27,8 +27,13 @@ export function launchAiVaultSessionInNewTab(args: { providerSession?: AgentProviderSessionMetadata targetGroupId?: string splitDirection?: TabSplitDirection + /** Opens in this tab's slot: the next tab in its group. */ + afterTabId?: string + /** A chat's switch, whose spawn claims the conversation; set only for a capable host. */ + resumeOrigin?: 'switch' }): LaunchAiVaultSessionInNewTabResult { const store = useAppStore.getState() + const switchStart = args.resumeOrigin ? { resumeOrigin: args.resumeOrigin } : {} // Why both paths split: the runtime path used to return first and drop the split (#22791). const split = args.splitDirection && args.targetGroupId @@ -49,6 +54,8 @@ export function launchAiVaultSessionInNewTab(args: { worktreeId: args.worktreeId, environmentId: runtimeEnvironmentId, ...(targetGroupId ? { targetGroupId } : {}), + ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), + ...switchStart, agentSessionKind: 'resume', launchAgent: args.agent, command: args.command, @@ -74,7 +81,10 @@ export function launchAiVaultSessionInNewTab(args: { return { tabId: null, runtimeLaunch: observedRuntimeLaunch } } - const tab = store.createTab(args.worktreeId, args.targetGroupId, undefined, { + const slot = args.afterTabId + ? store.unifiedTabsByWorktree[args.worktreeId]?.find((tab) => tab.id === args.afterTabId) + : undefined + const tab = store.createTab(args.worktreeId, slot?.groupId ?? args.targetGroupId, undefined, { ...(split ? { split } : {}), ...(args.cwd ? { startupCwd: args.cwd } : {}), pendingStartup: { @@ -83,6 +93,7 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.envToDelete ? { envToDelete: args.envToDelete } : {}), ...(args.launchConfig ? { launchConfig: args.launchConfig, launchAgent: args.agent } : {}), ...(args.providerSession ? { resumeProviderSession: args.providerSession } : {}), + ...(args.resumeOrigin ? { launchAgent: args.agent, ...switchStart } : {}), telemetry: { agent_kind: tuiAgentToAgentKind(args.agent), launch_source: 'sidebar', @@ -93,6 +104,14 @@ export function launchAiVaultSessionInNewTab(args: { store.setActiveTabType('terminal', args.worktreeId) persistAgentLaunchTabOrder(args.worktreeId, tab.id) + if (slot) { + const order = ( + useAppStore.getState().groupsByWorktree[args.worktreeId]?.find((g) => g.id === slot.groupId) + ?.tabOrder ?? [] + ).filter((id) => id !== tab.id) + order.splice(order.indexOf(slot.id) + 1, 0, tab.id) + useAppStore.getState().reorderUnifiedTabs(slot.groupId, order) + } return { tabId: tab.id } } diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 7ad8cff68ac2..19abbb987b3b 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -17,6 +17,10 @@ import { type RuntimeCapability } from '../../../shared/protocol-version' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' +import { + inPlaceSwitchCapability, + inPlaceSwitchEnabled +} from '../../../shared/structured-agent-cli-conversation-adoption' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { @@ -64,6 +68,16 @@ export function supportsStructuredAgentSessionCliSource( ) } +/** Whether the destination host switches this agent's conversations in place; unknown is no. */ +export function supportsInPlaceSwitch( + target: RuntimeClientTarget, + agent: string +): Promise { + return inPlaceSwitchEnabled(agent) + ? structuredAgentSessionHostSupports(target, inPlaceSwitchCapability(agent)) + : Promise.resolve(false) +} + /** Whether the host writes no row for a Stop that stopped nothing, so a repeated Stop is quiet. */ export function supportsStructuredAgentSessionQuietRepeatedStop( target: RuntimeClientTarget diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index aeb015419d91..a09629f34a61 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -39,6 +39,8 @@ export type CreateWebRuntimeSessionTerminalArgs = { agentArgs?: string | null launchPreferences?: AgentLaunchPreferences providerSession?: AgentProviderSessionMetadata + /** A chat's switch: sent only to a host advertising the agent's switch capability. */ + resumeOrigin?: 'switch' viewMode?: 'terminal' | 'chat' activate?: boolean selectWorktree?: boolean diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 955e51e9b086..5c6dffce2100 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -94,7 +94,9 @@ export async function createWebRuntimeSessionTerminalResult( // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. // Why: rebuilding a prepared command through host authority can discard its embedded prompt and delivery flags. - const mustUseLegacyAgentCreate = args.preparedAgentCommand || args.afterTabId + // A switch keeps its prepared recipe and its claim, which only the prepared create carries. + const mustUseLegacyAgentCreate = + args.preparedAgentCommand || args.afterTabId || args.resumeOrigin === 'switch' const hostAuthority = mustUseLegacyAgentCreate ? undefined : args.agentSessionKind === 'resume' @@ -182,6 +184,9 @@ export async function createWebRuntimeSessionTerminalResult( ...(args.launchToken ? { launchToken: args.launchToken } : {}), ...(args.agent ? { agent: args.agent } : {}), ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.resumeOrigin && args.providerSession + ? { resumeOrigin: args.resumeOrigin, resumeProviderSession: args.providerSession } + : {}), ...(args.viewMode ? { viewMode: args.viewMode } : {}), // Why: old hosts understand activate:false; new hosts use select/navigation for caller-local focus. activate: false, diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 93c970532853..21665cdc9ec9 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -200,7 +200,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null @@ -227,7 +227,7 @@ export type TerminalActions = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' launchToken?: string launchAgent?: TuiAgent agentArgsOverride?: string | null diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 4c27bff02c43..199565cb52a6 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -57,7 +57,7 @@ export type TerminalState = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' resumeSource?: SavedResumeStartupSource launchToken?: string launchAgent?: TuiAgent diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 30f161c9219e..5a4ffefaca7b 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -253,6 +253,8 @@ type RuntimeTerminalCreateBaseRequestPayload = { envToDelete?: string[] launchConfig?: SleepingAgentLaunchConfig resumeProviderSession?: AgentProviderSessionMetadata + /** A chat's switch: the pane's spawn admission claims the conversation. */ + resumeOrigin?: 'switch' launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' @@ -267,10 +269,7 @@ type RuntimeTerminalCreateBaseRequestPayload = { export type RuntimeTerminalCreateRequestPayload = | (RuntimeTerminalCreateBaseRequestPayload & { source?: undefined }) - | (RuntimeTerminalCreateBaseRequestPayload & { - worktreeId: string - source: 'runtime-session' - }) + | (RuntimeTerminalCreateBaseRequestPayload & { worktreeId: string; source: 'runtime-session' }) export type RuntimeTerminalCreate = TerminalLaunchResult & { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } From f09f7c612ba45ab81411cbda5b0b70593ade5b35 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:00:07 -0700 Subject: [PATCH 010/207] test(native-chat): pin the in-place Codex switch in both directions Covers the host op stopping an idle chat while a racing send is refused, the switch claim lifetime (unaccepted lapse, accepted spawn past the window, bind, identity-only replay, second switch refused), the switch spawn claiming before its first await, and the renderer refusing hosts that do not advertise the switch. Updates label and owner-reveal expectations for the new wording. --- ...nt-session-terminal-writer-acquire.test.ts | 44 ++++++ .../structured-session-switch-claims.test.ts | 143 ++++++++++++++++++ .../use-native-chat-context-menu.test.tsx | 6 +- .../SortableTab.rename-shortcut.test.tsx | 1 + .../tab-bar/SortableTabContextMenu.test.tsx | 8 +- .../tab-bar/cli-chat-resume-run.test.ts | 19 ++- .../native-chat-cli-switch-run.test.ts | 95 ++++++++++++ .../tab-session-history-switch.test.ts | 5 +- .../tab-bar/tab-title-tooltip.test.tsx | 11 ++ .../TerminalPaneResumeInChatButton.test.tsx | 9 +- 10 files changed, 321 insertions(+), 20 deletions(-) create mode 100644 src/main/runtime/session-tabs/structured-session-switch-claims.test.ts create mode 100644 src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index 001d926b0860..3431d6cd44d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -136,3 +137,46 @@ it('refuses the chat agent start while a terminal writes its conversation, once timeout: 10_000 }) }) + +// The switch to a terminal: admitted only when idle, registers before it stops, proves the stop. +it('switches an idle chat to its terminal and a racing send starts no second writer', async () => { + const order: string[] = [] + const closeSession = vi.mocked(host.deps.adapter.closeSession) + closeSession.mockImplementation(async () => { + order.push('stop') + return true + }) + await host.switchToTerminal(SESSION, () => { + order.push('claim') + terminalWriting = true + }) + expect(order).toEqual(['claim', 'stop']) + expect(agentSessionLeaseOwnerVerdict(store.getRecord(SESSION)!.lease)).toBe('exited') + const spawnsBefore = acquire.mock.calls.length + const raced = await rejection(await send('from another window')) + expect(raced?.rejection).toMatchObject({ kind: 'conversationOpenInTerminal' }) + expect(acquire.mock.calls.length).toBe(spawnsBefore) +}) + +it('refuses a switch while the chat is starting, without aborting that start', async () => { + await host.close(SESSION, 'evict') + let release = (): void => undefined + const started = new Promise((resolve) => { + release = resolve + }) + let signal: AbortSignal | undefined + acquire.mockImplementationOnce(async (attempt) => { + signal = attempt.signal + await started + return acquire.getMockImplementation()!(attempt) + }) + await send('starts the agent') + await vi.waitFor(() => expect(signal).toBeDefined()) + const admit = vi.fn() + await expect(host.switchToTerminal(SESSION, admit)).rejects.toMatchObject({ + refusal: { details: { reason: 'turnActive' } } + }) + expect(admit).not.toHaveBeenCalled() + expect(signal?.aborted).toBe(false) + release() +}) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts new file mode 100644 index 000000000000..8a1f195a65a4 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import '../rpc/unused-default-rpc-methods.test-fixture' +import { OrcaRuntimeService } from '../orca-runtime' +import { admitSwitchResumeSpawn } from '../../ipc/pty/host-env/resume-spawn-judgement' +import { switchStartProcessVerdict } from './structured-session-switch-observation' +import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' +import { codexChatHolding } from '../../ai-vault/structured-session-ownership.test-support' +import type { AdmissionRuntime } from '../../ipc/pty/host-env/resume-spawn-judgement' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) +vi.mock('./structured-session-switch-observation', () => ({ + readSwitchPaneAgentProcesses: vi.fn(async () => []), + switchStartProcessVerdict: vi.fn(async (): Promise => 'live') +})) + +const C = 'thread-c' +const LEAF = '11111111-1111-4111-8111-111111111111' +const AGENT = { pid: 999_991, platform: process.platform, startTime: 'started' } as const +let runtime: OrcaRuntimeService +let shellAlone: boolean + +const chatStart = () => runtime.assertStructuredAdoptionTerminalWritersExited('codex', C) +const reason = (promise: Promise) => + promise.then( + () => 'admitted', + (error: { refusal?: { details?: { reason?: string } } }) => error.refusal?.details?.reason + ) +function hook(process?: typeof AGENT): void { + runtime.recordCliWriterConversation({ + paneKey: `tab-1:${LEAF}`, + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + providerSession: { key: 'session_id', id: C }, + payload: { state: 'done', prompt: '', agentType: 'codex' }, + ...(process ? { agentPresence: { agent: 'codex', process } } : {}) + }) +} + +beforeEach(() => { + shellAlone = true + runtime = new OrcaRuntimeService() + runtime.registerPty('pty-1', 'wt-1', null, { tabId: 'tab-1', leafId: LEAF, incarnationId: 'i1' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census's one process-table read, replaced by the pane's modeled state. + vi.spyOn( + runtime as never as { cliWriterShellAlone: () => Promise }, + 'cliWriterShellAlone' + ).mockImplementation(async () => shellAlone) + vi.mocked(switchStartProcessVerdict).mockResolvedValue('live') +}) +afterEach(() => vi.useRealTimers()) + +describe('the switch claim', () => { + it('refuses a chat start until a spawn takes it; an accepted start never lapses', async () => { + vi.useFakeTimers() + runtime.registerSwitchClaim('codex', C) + expect(await reason(chatStart())).toBe('conversationMovingToTerminal') + vi.advanceTimersByTime(61_000) + expect(await reason(chatStart())).toBe('admitted') + + runtime.registerSwitchClaim('codex', C) + runtime.acceptSwitchStart('codex', C) + // A spawn slower than the window keeps refusing until it settles. + vi.advanceTimersByTime(120_000) + expect(await reason(chatStart())).toBe('conversationMovingToTerminal') + }) + + it('binds to the switch terminal, ignores identity-only replays, and ends on a proven exit', async () => { + const claim = runtime.acceptSwitchStart('codex', C) + runtime.settleSwitchStart(claim, 'pty-1') + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + // The launch identity names the conversation without proving a start or an exit. + hook() + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + hook(AGENT) + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') + shellAlone = false + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + shellAlone = true + expect(await reason(chatStart())).toBe('admitted') + }) + + it('refuses a second switch of the conversation while the first terminal may hold it', () => { + const first = runtime.acceptSwitchStart('codex', C) + runtime.settleSwitchStart(first, 'pty-1') + expect(() => runtime.registerSwitchClaim('codex', C)).toThrow('agent_session_conflict') + }) +}) + +describe('a switch spawn', () => { + const args = { + launchAgent: 'codex' as const, + resumeProviderSession: { key: 'session_id' as const, id: C }, + resumeOrigin: 'switch' + } + const admission = (writing: boolean) => + ({ + acceptSwitchStart: vi.fn(() => ({ agent: 'codex', id: C, at: 0, accepted: true })), + settleSwitchStart: vi.fn(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a switch spawn reads only the store's records and tab index. + openAgentSessionRecordStore: vi.fn( + async () => + ({ + listRecords: () => (writing ? [codexChatHolding('chat-1', [C])] : []), + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) + }) as never + ) + }) satisfies AdmissionRuntime + + it('claims before reading the chat, and a writing chat refuses it into a plain shell', async () => { + const quiet = admission(false) + expect(await admitSwitchResumeSpawn(quiet, args, null)).toMatchObject({ + verdict: 'admitted', + switchClaim: { id: C } + }) + const writing = admission(true) + const refused = admitSwitchResumeSpawn(writing, args, null) + expect(writing.acceptSwitchStart.mock.invocationCallOrder[0]).toBeLessThan( + writing.openAgentSessionRecordStore.mock.invocationCallOrder[0]! + ) + expect(await refused).toMatchObject({ verdict: 'chat-writing' }) + expect(writing.settleSwitchStart).toHaveBeenCalledWith(expect.anything(), null) + }) + + it('refuses where it cannot judge the pane, and a switch without its conversation', async () => { + const quiet = admission(false) + expect( + await admitSwitchResumeSpawn(quiet, { ...args, connectionId: 'ssh' }, null) + ).toMatchObject({ verdict: 'unverifiable' }) + expect(await admitSwitchResumeSpawn(quiet, args, 'Ubuntu')).toMatchObject({ + verdict: 'unverifiable' + }) + await expect( + admitSwitchResumeSpawn(quiet, { ...args, resumeProviderSession: undefined }, null) + ).rejects.toThrow('switch_start_missing_conversation') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index 03ce68d99b86..d121c5f015fb 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -385,9 +385,9 @@ describe('useNativeChatContextMenu', () => { // Keep the assertions tied to the mocked menu item's semantic children. const labels = items.list.map((candidate) => childrenText(candidate.children)) - expect(labels.some((label) => label.startsWith('Switch to Terminal'))).toBe(true) + expect(labels.some((label) => label.startsWith('Show as terminal'))).toBe(true) const item = items.list.find((candidate) => - childrenText(candidate.children).startsWith('Switch to Terminal') + childrenText(candidate.children).startsWith('Show as terminal') ) expect(item).toBeDefined() item?.onSelect?.() @@ -398,7 +398,7 @@ describe('useNativeChatContextMenu', () => { renderToStaticMarkup() expect( - items.list.some((candidate) => childrenText(candidate.children) === 'Switch to Terminal') + items.list.some((candidate) => childrenText(candidate.children) === 'Show as terminal') ).toBe(false) }) diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 3885b09578e0..ea920936a46e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -66,6 +66,7 @@ vi.mock('react', async () => { vi.mock('./TabSessionSurfaceSwitchMenuItems', () => ({ TabSessionSurfaceSwitchMenuItems: () => null })) +vi.mock('./TabSessionSwitchHoverAction', () => ({ TabSessionSwitchHoverAction: () => null })) vi.mock('./use-tab-strip-slot-props', () => ({ useTabStripSlotProps: () => ({ className: '', 'data-tab-strip-slot': '' }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 857c925fcae4..d548bee92469 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -264,8 +264,8 @@ describe('SortableTabContextMenu', () => { it('does not expose a native/terminal view switch', () => { const { container } = renderMenu() - expect(container.textContent).not.toContain('Switch to Terminal') - expect(container.textContent).not.toContain('Switch to Chat') + expect(container.textContent).not.toContain('Show as terminal') + expect(container.textContent).not.toContain('Show as chat') }) it('hides switching a terminal-view tab into chat view', () => { @@ -283,11 +283,11 @@ describe('SortableTabContextMenu', () => { }) const marker = getSurfaceSwitchMarker(container) - expect(getButton(container, 'Switch to Terminal').compareDocumentPosition(marker)).toBe( + expect(getButton(container, 'Show as terminal').compareDocumentPosition(marker)).toBe( Node.DOCUMENT_POSITION_FOLLOWING ) expect(marker.getAttribute('data-leading-separator')).toBe('false') - act(() => getButton(container, 'Switch to Terminal').click()) + act(() => getButton(container, 'Show as terminal').click()) expect(onToggleViewMode).toHaveBeenCalled() }) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 4b6e04dd2d8d..ae760b4b2e97 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -198,7 +198,7 @@ describe('CLI identity resume through the existing launch owner', () => { ok: false, refusal: { code: 'agent_session_conflict', message: 'old host' } }) - await runCliChatResume(SOURCE, true) + await runCliChatResume(SOURCE) expect(mocks.call.mock.calls[0][2].resumeFrom).toEqual({ providerSessionId: 'provider-1' }) expect(tabs()).toHaveLength(1) expect(mocks.activate).not.toHaveBeenCalled() @@ -227,7 +227,7 @@ describe('CLI identity resume through the existing launch owner', () => { createAttemptRefusedBeforeCommit: true } }) - await runCliChatResume(SOURCE, true) + await runCliChatResume(SOURCE) expect(mocks.toast).toHaveBeenCalledTimes(1) expect(mocks.toast.mock.calls[0]?.[0]).toMatch( /^Orca can't confirm Claude.* has exited in its terminal\. Stop what's running there or close the terminal, then open this conversation from Session History\.$/ @@ -237,7 +237,7 @@ describe('CLI identity resume through the existing launch owner', () => { expect([...structuredLaunchStates()]).toEqual([]) }) - it('opens a proved owner only for the header, on the initiating host, without importing', async () => { + it('reveals the chat that already holds the conversation in place and closes the terminal pane', async () => { const owner = { sessionId: 'owner-chat', workspaceId: 'owner-workspace' } mocks.call.mockResolvedValue({ ok: false, @@ -248,13 +248,18 @@ describe('CLI identity resume through the existing launch owner', () => { createAttemptRefusedBeforeCommit: true } }) - await runCliChatResume(SOURCE) - expect(mocks.toast).toHaveBeenCalledWith('Another chat is already holding this conversation.') - expect(mocks.activate).not.toHaveBeenCalled() - await runCliChatResume(SOURCE, true) + const leafId = '11111111-1111-4111-8111-111111111111' + mocks.source = { ...SOURCE, paneKey: `tab-1:${leafId}` } + const closed = vi.fn() + window.addEventListener('orca-close-terminal-pane', closed) + await runCliChatResume(mocks.source) + window.removeEventListener('orca-close-terminal-pane', closed) + expect(mocks.toast).not.toHaveBeenCalled() expect(mocks.activate).toHaveBeenCalledWith({ structuredSession: owner }, undefined, { kind: 'local' }) + expect(closed).toHaveBeenCalledOnce() + expect(closed.mock.calls[0]?.[0].detail).toEqual({ tabId: 'tab-1', leafId }) expect(tabs()).toEqual([]) }) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts new file mode 100644 index 000000000000..72538d15253c --- /dev/null +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + supported: true, + call: vi.fn(), + launch: vi.fn((_args: unknown) => ({ tabId: 'terminal-tab' })), + closeUnifiedTab: vi.fn(), + toast: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ settings: {}, closeUnifiedTab: mocks.closeUnifiedTab }) + } +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + supportsInPlaceSwitch: async () => mocks.supported, + callStructuredAgentSession: mocks.call +})) +vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launch })) +vi.mock('@/lib/ai-vault-resume-command', () => ({ + buildAiVaultResumeStartupForWorktree: () => ({ + command: 'codex resume thread-c', + env: { CODEX_HOME: '/accounts/a/home' }, + providerSession: { key: 'session_id', id: 'thread-c' } + }) +})) + +import { runNativeChatCliSwitch } from './native-chat-cli-switch-run' + +const SWITCH = { + sessionId: 'chat-1', + workspaceId: 'wt-1', + executionHostId: 'local', + agent: 'codex' as const, + chatTabId: 'chat-tab' +} +const SOURCE = { + agent: 'codex', + sourceAgent: 'codex', + sessionId: 'thread-c', + filePath: '/accounts/a/home/sessions/rollout-thread-c.jsonl', + cwd: '/repo', + codexHome: '/accounts/a/home', + executionHostPlatform: 'darwin' +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.supported = true + mocks.call.mockResolvedValue({ source: SOURCE }) +}) + +it('stops the chat on its host, opens the same-id resume in its slot, then closes the chat tab', async () => { + await runNativeChatCliSwitch(SWITCH) + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.switchToTerminal', { + sessionId: 'chat-1' + }) + expect(mocks.launch).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + worktreeId: 'wt-1', + command: 'codex resume thread-c', + env: { CODEX_HOME: '/accounts/a/home' }, + providerSession: { key: 'session_id', id: 'thread-c' }, + afterTabId: 'chat-tab', + resumeOrigin: 'switch' + }) + ) + expect(mocks.closeUnifiedTab).toHaveBeenCalledWith('chat-tab') + expect(mocks.launch.mock.invocationCallOrder[0]).toBeLessThan( + mocks.closeUnifiedTab.mock.invocationCallOrder[0]! + ) +}) + +it('never sends a switch to a host that does not advertise it', async () => { + mocks.supported = false + await runNativeChatCliSwitch(SWITCH) + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() + expect(mocks.toast).toHaveBeenCalledOnce() +}) + +it('keeps the chat when the host refuses a busy chat, and says why', async () => { + mocks.call.mockRejectedValue({ + data: { + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + } + }) + await runNativeChatCliSwitch(SWITCH) + expect(mocks.launch).not.toHaveBeenCalled() + expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() + expect(mocks.toast).toHaveBeenCalledWith('Wait for the reply to finish or stop it, then switch.') +}) diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 1b39afd447a8..694d60a403d1 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -442,13 +442,14 @@ describe('one pane of a terminal tab', () => { }) it.each(Object.keys(exitedAgentRecords))( - 'offers nothing once the agent exited and only a %s record remains, as the tab menu does', + 'keeps the History move off once the agent exited and only a %s record remains', (kind) => { makeState() useAppStore.setState(exitedAgentRecords[kind]) const exited = useAppStore.getState() expect(paneSubject(exited, claudePane)).toBeNull() - expect(paneGate(exited, claudePane)).toBe(false) + // The pane's switch to native chat stays offered after the agent exits: that is when it works. + expect(paneGate(exited, claudePane)).toBe(kind === 'retained') expect( resolveTabSessionHistorySubject(exited, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) ).toBeNull() diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 273d45029724..e1228374f348 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -59,6 +59,17 @@ vi.mock('@/components/ui/tooltip', () => ({ } })) +// The tab card turns into a hover card when it carries the switch action; render it like the tooltip. +vi.mock('@/components/ui/hover-card', async () => { + const tooltip = await import('@/components/ui/tooltip') + return { + HoverCard: tooltip.Tooltip, + HoverCardTrigger: tooltip.TooltipTrigger, + HoverCardContent: tooltip.TooltipContent + } +}) +vi.mock('./TabSessionSwitchHoverAction', () => ({ TabSessionSwitchHoverAction: () => null })) + vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, DropdownMenuContent: () => null, diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx index e7565c29928f..5b7ab7cc0f0f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx @@ -18,7 +18,8 @@ const mocks = vi.hoisted(() => ({ pending: false })) vi.mock('@/store', () => ({ - useAppStore: (selector: (state: Record) => unknown) => selector({}) + useAppStore: (selector: (state: Record) => unknown) => + selector({ paneForegroundAgentByPaneKey: {}, agentStatusByPaneKey: {} }) })) vi.mock('../tab-bar/cli-chat-resume-source', () => ({ resolveCliChatResumeSource: (_state: unknown, args: unknown) => { @@ -32,18 +33,18 @@ vi.mock('../tab-bar/cli-chat-resume-run', () => ({ })) afterEach(cleanup) -it('dispatches the exact pane with owner-opening intent and shares pending', () => { +it('dispatches the exact pane and shares pending', () => { const { rerender } = render( ) - fireEvent.click(screen.getByRole('button', { name: 'Switch to Chat' })) + fireEvent.click(screen.getByRole('button', { name: 'Switch to native chat' })) expect(mocks.resolve).toHaveBeenCalledWith({ tab: { id: 'tab-1', worktreeId: 'wt-1' }, paneKey: 'tab-1:leaf' }) - expect(mocks.run).toHaveBeenCalledWith(mocks.source, true) + expect(mocks.run).toHaveBeenCalledWith(mocks.source) mocks.pending = true rerender( From 5e785543afd25d31564336a87162188449be5f9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:11:47 -0700 Subject: [PATCH 011/207] fix: keep existing terminal attachments observational --- ...rminal-create-host-session-binding.test.ts | 87 ++++++++++++++++++- .../terminal/launch/terminal-create.ts | 13 ++- .../terminal/launch/terminal-launch-result.ts | 13 +++ .../pty-connection/apply-reattach-payload.ts | 10 ++- .../reattach-payload-context.test.ts | 79 +++++++++++++++++ ...ntime-pty-transport-create-handoff.test.ts | 76 +++++++++++++--- .../remote-runtime-pty-transport.ts | 4 +- 7 files changed, 257 insertions(+), 25 deletions(-) diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 9a87b49c1cae..afc41991b071 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -82,15 +82,66 @@ describe('host-initiated terminal creation under an attached window', () => { }) }) -it.each(['refused', 'adopted', 'fresh-unavailable'] as const)( +it.each(['refused', 'adopted', 'stable-adopted', 'ensure-adopted', 'fresh-unavailable'] as const)( 'publishes the effective %s terminal rather than the requested saved recipe', async (kind) => { const { runtime, spawn } = createRuntimeWithAttachedWindow() const config = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + const adopted = kind === 'adopted' || kind === 'stable-adopted' || kind === 'ensure-adopted' + const ownerSurface = { + worktreeId: 'repo-1::/tmp/wt-cli', + terminalHandle: 'owner-handle', + tabId: 'owner-tab', + leafId: '11111111-1111-4111-8111-111111111111' + } + const coldRestore = { scrollback: 'checkpoint A', cwd: '/tmp/wt-cli', cols: 80, rows: 24 } spawn.mockResolvedValue({ id: 'effective-pty', - ...(kind === 'adopted' - ? { isReattach: true, launchAgent: 'codex', launchConfig: config } + pid: 42, + incarnationId: 'physical-i1', + coldRestore, + snapshot: 'current image B', + snapshotCols: 80, + snapshotRows: 24, + snapshotSeq: 7, + providerSequence: { value: 7, generation: 'continued' }, + attachedGrid: { cols: 80, rows: 24 }, + isAlternateScreen: false, + replay: 'provider backlog', + shellReadyArmed: true, + openCodeCapabilities: { version: '2.0.16', pluginApi: 'v2', promptMode: 'prefill' }, + ...(adopted + ? { launchAgent: 'codex', launchConfig: config, launchToken: 'owner-token' } + : {}), + ...(kind === 'adopted' ? { isReattach: true } : {}), + ...(kind === 'stable-adopted' + ? { + stablePaneOwner: { + handle: ownerSurface.terminalHandle, + tabId: ownerSurface.tabId, + leafId: ownerSurface.leafId + } + } + : {}), + ...(kind === 'ensure-adopted' + ? { + agentSessionEnsure: { + disposition: 'adopted', + owner: { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'C', + worktreeScopeDigest: 'wt', + agent: 'codex' + }, + generation: 'owner-generation', + phase: 'live', + ptyId: 'effective-pty', + surface: ownerSurface + } + } + } : {}), ...(kind === 'refused' ? { @@ -119,6 +170,36 @@ it.each(['refused', 'adopted', 'fresh-unavailable'] as const)( resumeProviderSession: { key: 'session_id', id: 'C' }, launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } }) + expect(result).toEqual({ + handle: expect.any(String), + tabId: expect.any(String), + paneKey: expect.any(String), + ptyId: 'effective-pty', + worktreeId: 'repo-1::/tmp/wt-cli', + title: kind === 'fresh-unavailable' ? 'Rejected C' : null, + launchConfig: kind === 'refused' ? undefined : config, + launchAgent: kind === 'refused' ? undefined : 'codex', + launchToken: kind === 'refused' ? undefined : adopted ? 'owner-token' : 'actual-fresh-token', + executionHostId: 'local', + hostPlatform: process.platform, + incarnationId: 'physical-i1', + surface: 'background', + processId: 42, + ...(adopted ? { isReattach: true } : { coldRestore }), + ...(kind === 'ensure-adopted' ? { agentSessionDisposition: 'adopted' } : {}), + ...(!adopted ? { agentResumeUnavailable: true } : {}), + ...(kind === 'refused' + ? { + agentResumeRefused: 'chat-home', + savedResumeDecision: { + agent: 'claude', + providerSession: { key: 'session_id', id: 'C' }, + verdict: 'chat-home' + } + } + : {}), + warning: expect.any(String) + }) expect(result.agentResumeRefused).toBe(kind === 'refused' ? 'chat-home' : undefined) expect(result.launchAgent).toBe(kind === 'refused' ? undefined : 'codex') expect(result.launchConfig).toEqual(kind === 'refused' ? undefined : config) diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 85b69952849b..3fa5abc02001 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -1,4 +1,8 @@ -import { recordTerminalLaunchMetadata, resolveTerminalLaunchResult } from './terminal-launch-result' +import { + recordTerminalLaunchMetadata, + resolveTerminalLaunchResult, + terminalLaunchPublicFacts +} from './terminal-launch-result' import { OrcaRuntimeWithTerminalCreateDeduplication } from './terminal-create-deduplication' import * as dependencies from '../../orca-runtime-create-terminal-dependencies' import { createDesktopTerminal } from '../../orca-runtime-create-terminal-desktop' @@ -282,7 +286,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi warning = dependencies.createTerminalRevealWarning(handle) } return { - ...result, + ...terminalLaunchPublicFacts(result, launch.adoptedStablePane), handle, tabId, paneKey, @@ -294,11 +298,6 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi launchToken: pty?.launchToken ?? undefined, ...this.getPtyExecutionHostMetadata(result.id), surface, - ...(result.pid ? { processId: result.pid } : {}), - ...(result.agentSessionEnsure - ? { agentSessionDisposition: result.agentSessionEnsure.disposition } - : {}), - ...(launch.adoptedStablePane ? { isReattach: true as const } : {}), ...(warning ? { warning } : {}) } } finally { diff --git a/src/main/runtime/terminal/launch/terminal-launch-result.ts b/src/main/runtime/terminal/launch/terminal-launch-result.ts index 815330ec115c..4d33c732ee21 100644 --- a/src/main/runtime/terminal/launch/terminal-launch-result.ts +++ b/src/main/runtime/terminal/launch/terminal-launch-result.ts @@ -1,3 +1,4 @@ +import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { admitStablePaneAdoption } from '../../runtime-terminal-pane-identity' import type { RuntimePtyController } from '../../runtime-pty-controller-contract' import type { TerminalCreateOptions } from '../../runtime-terminal-contracts' @@ -64,3 +65,15 @@ export function recordTerminalLaunchMetadata( pty.launchAgent = launch.launchAgent ?? null } } + +export function terminalLaunchPublicFacts(result: TerminalSpawnResult, adoptedStablePane: boolean) { + return { + ...terminalLaunchFacts(result), + ...(!adoptedStablePane && result.coldRestore ? { coldRestore: result.coldRestore } : {}), + ...(result.pid ? { processId: result.pid } : {}), + ...(result.agentSessionEnsure + ? { agentSessionDisposition: result.agentSessionEnsure.disposition } + : {}), + ...(adoptedStablePane ? { isReattach: true as const } : {}) + } +} 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 49eb05fede87..860424376259 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 @@ -267,10 +267,12 @@ export function createReattachPayloadHandlers( session.writeReplayData( `${RESET_GRAPHIC_RENDITION}${ctx.connectResult.coldRestore.scrollback}` ) - // Why not rebuilt when refused: main started a plain shell instead of that plan. + // An existing owner permits history replay, not another saved continuation. let resumeUnavailable = Boolean(ctx.connectResult.agentResumeUnavailable) let preparedStartup = - ctx.connectResult.agentResumeRefused || resumeUnavailable + ctx.connectResult.isReattach === true || + ctx.connectResult.agentResumeRefused || + resumeUnavailable ? null : (ctx.coldRestoreStartup ?? session.buildColdRestoreAgentResumeStartup()) if (preparedStartup && !ctx.coldRestoreStartup) { @@ -285,7 +287,9 @@ export function createReattachPayloadHandlers( preparedStartup = null } } - const didPrepareResume = session.applyColdRestoreAgentResumeStartup(preparedStartup) + const didPrepareResume = + ctx.connectResult.isReattach !== true && + session.applyColdRestoreAgentResumeStartup(preparedStartup) if (resumeUnavailable) { // Why: main dropped the resume argv, so this pane is a NEW session — // the plain restored banner would claim the old one came back. 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 19647f404c59..1fc03ede1bd2 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 @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' import { createPane } from '../pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -163,3 +164,81 @@ it.each(['missing', 'different', 'admitted'] as const)( } } ) + +it.each(['missing', 'admitted', 'prepared'] as const)( + 'observes a cached cold owner without consuming an uncorrelated newer recipe (%s)', + async (kind) => { + await installTerminalTestGlobals() + const record = { + paneKey: 'tab:leaf', + worktreeId: 'wt', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'C' }, + state: 'done' as const, + prompt: '', + capturedAt: 20, + updatedAt: 20 + } + const previousRecords = useAppStore.getState().sleepingAgentSessionsByPaneKey + useAppStore.setState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } }) + try { + const startup: ColdRestoreAgentResumeStartup = { + command: 'codex resume C', + agent: 'codex', + resumeOrigin: 'saved', + resumeProviderSession: record.providerSession, + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + launchToken: 'replacement-token', + useLiveEntry: false, + hasSleepingRecord: true, + sleepingRecordEntry: { paneKey: record.paneKey, record } + } + const session = createSession({ + cacheKey: record.paneKey, + buildColdRestoreAgentResumeStartup: vi.fn(() => startup), + applyColdRestoreAgentResumeStartup: vi.fn(() => true), + cancelSavedResumeStartup: vi.fn(), + schedulePendingStartupCommandDelivery: vi.fn(), + clearSleepingRecordAfterColdRestoreSpawn: vi.fn(), + showSessionRestoredBanner: vi.fn(), + consumeRestoredViewportBlankingMarker: vi.fn(), + writeFreshShellViewportBlanking: vi.fn() + }) + const context: ReattachPayloadContext = { + ...createContext('', 1), + connectResult: { + id: 'pty-1', + isReattach: true, + coldRestore: { scrollback: 'cached history', cwd: '/workspace' }, + ...(kind === 'admitted' + ? { + savedResumeDecision: { + agent: 'codex', + providerSession: record.providerSession, + verdict: 'admitted' + } + } + : {}) + }, + coldRestoreStartup: kind === 'prepared' ? startup : null + } + await createReattachPayloadHandlers(session, context).applyReattachPayload() + expect(session.writeReplayData).toHaveBeenCalledWith( + expect.stringContaining('cached history') + ) + expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('pty-1') + expect(context.reattachPayloadApplied).toBe(true) + expect(session.buildColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(session.applyColdRestoreAgentResumeStartup).not.toHaveBeenCalled() + expect(session.cancelSavedResumeStartup).not.toHaveBeenCalled() + expect(session.schedulePendingStartupCommandDelivery).not.toHaveBeenCalled() + expect(session.clearSleepingRecordAfterColdRestoreSpawn).not.toHaveBeenCalled() + expect(session.showSessionRestoredBanner).not.toHaveBeenCalled() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + expect(window.api.pty.write).not.toHaveBeenCalled() + } finally { + useAppStore.setState({ sleepingAgentSessionsByPaneKey: previousRecords }) + await restoreTerminalTestGlobals() + } + } +) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts index b512254b5ee9..2fe8fc0daf27 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts @@ -7,17 +7,23 @@ import { let subscriptionCallbacks: MultiplexSubscriptionCallbacks = null let resolvedPaneHandle = 'terminal-1' -const { runtimeCall, refreshSessionTabsSnapshot, resetRemoteRuntimeTransport } = - createRemoteRuntimeTransportMocks({ - getCallbacks: () => subscriptionCallbacks, - setCallbacks: (callbacks) => { - subscriptionCallbacks = callbacks - }, - getResolvedPaneHandle: () => resolvedPaneHandle, - setResolvedPaneHandle: (handle) => { - resolvedPaneHandle = handle - } - }) +const { + runtimeCall, + refreshSessionTabsSnapshot, + resetRemoteRuntimeTransport, + latestSubscribePayload, + emitSnapshot, + inputFrameTexts +} = createRemoteRuntimeTransportMocks({ + getCallbacks: () => subscriptionCallbacks, + setCallbacks: (callbacks) => { + subscriptionCallbacks = callbacks + }, + getResolvedPaneHandle: () => resolvedPaneHandle, + setResolvedPaneHandle: (handle) => { + resolvedPaneHandle = handle + } +}) describe('createRemoteRuntimePtyTransport', () => { it.each([ @@ -498,4 +504,52 @@ describe('createRemoteRuntimePtyTransport', () => { expect.objectContaining({ method: 'terminal.createAgentSession' }) ) }) + it.each(['existing', 'fresh', 'legacy'] as const)( + 'uses subscription image for an existing intermediate-host owner and keeps fresh cold data (%s)', + async (kind) => { + const coldRestore = { scrollback: 'old checkpoint A', cwd: '/workspace' } + runtimeCall.mockImplementation(async (args: { method: string }) => ({ + ok: true, + result: + args.method === 'terminal.create' + ? { + terminal: { + handle: 'terminal-1', + ...(kind === 'existing' ? { isReattach: true, incarnationId: 'i1' } : {}), + ...(kind !== 'legacy' ? { coldRestore } : {}) + } + } + : {} + })) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const onReplayData = vi.fn() + const onConnect = vi.fn() + const result = await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) + try { + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + expect(result?.coldRestore).toEqual(kind === 'fresh' ? coldRestore : undefined) + expect(result?.isReattach).toBe(kind === 'existing' ? true : undefined) + expect(result?.replay).toBe('') + const { streamId } = latestSubscribePayload() + emitSnapshot(streamId, 'actual owner image B') + expect(onReplayData.mock.calls).toEqual([ + ['actual owner image B', { carriesNormalBuffer: true }] + ]) + expect(onConnect).toHaveBeenCalled() + expect(inputFrameTexts()).toEqual([]) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + } finally { + transport.destroy?.() + } + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 8fdfd760e6e4..ce9a4a0a6c9d 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -3347,7 +3347,9 @@ export function createRemoteRuntimePtyTransport( return { ...terminalLaunchFacts(createdTerminal), - ...(createdTerminal.coldRestore ? { coldRestore: createdTerminal.coldRestore } : {}), + ...(createdTerminal.isReattach !== true && createdTerminal.coldRestore + ? { coldRestore: createdTerminal.coldRestore } + : {}), id: remotePtyId, replay: '', ...(authoritativePtyIncarnationId ? { incarnationId: authoritativePtyIncarnationId } : {}), From a7d59ce1f200c5ff7af1717c9cded79dda4d6995 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:24:43 -0700 Subject: [PATCH 012/207] feat(native-chat): start the existing chat when a Codex terminal switches back to it Switching a terminal's conversation into the chat that already holds it now starts that chat's agent on its host (agentSession.switchToChat), so the provider open catches up on the terminal's turns before the user sends. The host uses the ordinary operation start and answers success only when that same child is ready; an ended child's typed failure, such as the conversation still open elsewhere, is the answer. The renderer sends it only to hosts that advertise the switch, retires the terminal pane only after the start succeeded and the chat's tab is really active, and keeps the terminal on any refusal. --- .../structured-agent-session-failure-text.ts | 2 +- ...structured-agent-session-host-mutations.ts | 4 +- .../structured-agent-session-host.ts | 5 +- ...structured-agent-session-switch-to-chat.ts | 98 +++++++++++++++++++ ...nt-session-terminal-writer-acquire.test.ts | 39 +++++++- ...ession-gate-classification.test-fixture.ts | 1 + ...structured-agent-session-switch-to-chat.ts | 46 +++++++++ .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 4 +- .../tab-bar/cli-chat-resume-feedback.ts | 81 ++++++++++++--- .../tab-bar/cli-chat-resume-run.test.ts | 56 ++++++++--- .../components/tab-bar/cli-chat-resume-run.ts | 7 +- .../activate-ai-vault-structured-session.ts | 22 +++-- .../rpc-params-catalog.generated.ts | 1 + 14 files changed, 325 insertions(+), 43 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index bdcac83f0ecb..91ef3c8c3e35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -44,7 +44,7 @@ const TYPED_START_REFUSALS = [ ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] -function typedStartRefusal( +export function typedStartRefusal( reason: string | undefined ): (typeof TYPED_START_REFUSALS)[number] | undefined { return TYPED_START_REFUSALS.find((typed) => typed === reason) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index a324b34b7421..fbd08f045910 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,6 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. +import { startStructuredAgentSessionForSwitch } from './structured-agent-session-switch-to-chat' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -282,6 +283,7 @@ export function structuredAgentSessionMutationDelegates( settleStructuredAgentSessionLateDispatch(context(), input), releaseUnansweredDispatches: ( input: Parameters[1] - ) => releaseStructuredAgentSessionUnansweredDispatches(context(), input) + ) => releaseStructuredAgentSessionUnansweredDispatches(context(), input), + switchToChat: (sessionId: string) => startStructuredAgentSessionForSwitch(context(), sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index ea282faeba2a..475c69acfcc2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -212,8 +212,7 @@ export class StructuredAgentSessionHost { * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) - switchToTerminal: StructuredAgentSessionConversationLifetime['switchToTerminal'] = (id, admit) => - this.lifetime.switchToTerminal(id, admit) + switchToTerminal = (id: string, admit: () => T) => this.lifetime.switchToTerminal(id, admit) /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => @@ -319,6 +318,8 @@ export class StructuredAgentSessionHost { setOption = this.mutations.setOption changeThreadGoal = this.mutations.changeThreadGoal readOptions = this.mutations.readOptions + /** A terminal's conversation moves into this chat: its agent starts and is ready, or why not. */ + switchToChat = this.mutations.switchToChat rewind = (caller: HostTypes.StructuredAgentSessionCaller, params: AgentSessionRewindParams) => rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts new file mode 100644 index 000000000000..810cc60b66f9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts @@ -0,0 +1,98 @@ +// A terminal handing its conversation to the chat that already holds it: the chat's agent starts +// through the one operation start (its provider open imports the terminal's turns), and only that +// same child reaching ready is success. Its ended child's typed failure is the click's refusal. + +import { + agentSessionRefusalFromReference, + refuse, + refuseUnclassified +} from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { + operationDidNotRun, + selectStructuredAgentSessionOperationChild +} from './structured-agent-session-operation-child' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' +import { + structuredAgentSessionStartFailure, + structuredAgentSessionStartFailureFact, + typedStartRefusal +} from './structured-agent-session-failure-text' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' + +export type StructuredAgentSessionSwitchToChatOutcome = + | { ok: true } + | { ok: false; refusal: AgentSessionWireRefusal } + +export async function startStructuredAgentSessionForSwitch( + context: StructuredAgentSessionMutationContext, + sessionId: string +): Promise { + const selected = await context.serialize(sessionId, async () => { + if (!(await context.openConversation(sessionId))) { + return operationDidNotRun() + } + const selection = selectStructuredAgentSessionOperationChild(context, sessionId) + return selection.ok && !selection.child + ? { ...selection, start: context.claimAgent(sessionId) } + : { ...selection, start: null } + }) + if (!selected.ok) { + return { ok: false, refusal: selected.refusal } + } + let { child, readiness } = selected + if (!child) { + const started = await (selected.start?.outcome ?? operationDidNotRun()) + if (!started.ok) { + return { ok: false, refusal: started.refusal } + } + const selection = selectStructuredAgentSessionOperationChild(context, sessionId) + if (!selection.ok || !selection.child) { + return selection.ok ? operationDidNotRun() : selection + } + ;({ child, readiness } = selection) + } + const outcome = (await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS)) ?? 'ready' + const awaited = child + return context.serialize(sessionId, async () => + switchStartVerdict(context, sessionId, awaited, outcome) + ) +} + +/** Under the queue: the awaited child itself is ready, or its own end says why it is not. */ +function switchStartVerdict( + context: StructuredAgentSessionMutationContext, + sessionId: string, + awaited: StructuredAgentSessionProviderChildIdentity, + outcome: 'ready' | 'ended' | 'timeout' +): StructuredAgentSessionSwitchToChatOutcome { + const session = context.sessions.get(sessionId) + const child = session?.child + if (outcome === 'ready' && child?.phase === 'ready' && !child.close) { + return sameProviderChild(child, awaited) ? { ok: true } : operationDidNotRun() + } + const ended = session?.lastEndedChild + const failure = + ended && sameProviderChild(ended, awaited) + ? structuredAgentSessionEndedChildFailure(ended) + : null + if (!failure) { + return operationDidNotRun(outcome === 'timeout') + } + const fact = structuredAgentSessionStartFailureFact(failure) + const { reason } = structuredAgentSessionStartFailure( + failure, + structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)) + ) + const typed = typedStartRefusal(fact.kind) + const refusal: AgentSessionWireRefusal = typed + ? refuse('agent_session_operation_invalid', { reason: typed }, reason) + : fact.refusal + ? agentSessionRefusalFromReference(fact.refusal, reason) + : refuseUnclassified('agent_session_owner_restart_failed', reason) + return { ok: false, refusal } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index 3431d6cd44d5..7c7b9654cdae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -9,7 +9,10 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -39,6 +42,9 @@ const acquire = vi.fn(async ({ fence, observedAt: NOW } })) +const open = vi.fn(async () => ({ + reportedOptions: { model: '' } +})) const assertNoTerminalWriter = vi.fn(async () => { if (terminalWriting) { throw agentSessionRefusalError('agent_session_conflict', { @@ -52,6 +58,7 @@ beforeEach(async () => { resetHostTestOperationIds() terminalWriting = false acquire.mockClear() + open.mockClear() assertNoTerminalWriter.mockClear() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ @@ -69,7 +76,7 @@ beforeEach(async () => { cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), - open: async () => ({ reportedOptions: { model: '' } }) + open }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', @@ -180,3 +187,31 @@ it('refuses a switch while the chat is starting, without aborting that start', a expect(signal?.aborted).toBe(false) release() }) + +// Terminal to chat: the chat's agent starts with no send, and only its ready provider is success. +it('starts the chat that holds the conversation for a switch from its terminal', async () => { + await host.close(SESSION, 'evict') + terminalWriting = true + const spawnsBefore = acquire.mock.calls.length + expect(await host.switchToChat(SESSION)).toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationOpenInTerminal' } } + }) + expect(acquire.mock.calls.length).toBe(spawnsBefore) + + terminalWriting = false + open.mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('thread has an active writer', 'conversationOpenElsewhere') + ) + expect(await host.switchToChat(SESSION)).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationOpenElsewhere' } + } + }) + + expect(await host.switchToChat(SESSION)).toEqual({ ok: true }) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + expect(vi.mocked(host.deps.adapter.dispatch)).not.toHaveBeenCalled() +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 9410bc5b055d..a26c75601743 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -99,5 +99,6 @@ export const WORK_METHODS = [ { method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } }, { method: 'agentSession.reveal', params: { sessionId: SESSION } }, { method: 'agentSession.switchToTerminal', params: { sessionId: SESSION } }, + { method: 'agentSession.switchToChat', params: { sessionId: SESSION } }, { method: 'agentSession.subscribeStatus', params: null } ] as const diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts new file mode 100644 index 000000000000..0e7623b6efec --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-chat.ts @@ -0,0 +1,46 @@ +// `agentSession.switchToChat` — a terminal's conversation moving into the chat that already holds +// it. The host resolves the record and starts its agent through the ordinary operation start, so the +// provider open imports the terminal's turns; success is that agent ready. A refusal leaves the +// terminal to the client. Gated per agent by `agent-session.structured.switch.v1:`. + +import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { refuse } from '../../../../shared/agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire' +import { defineMethod } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredCapability, + requireStructuredSessionHost +} from './structured-agent-session-gate' +import { OptionsParams } from './structured-agent-session-schemas' + +export const STRUCTURED_AGENT_SESSION_SWITCH_TO_CHAT_METHODS = [ + defineMethod({ + name: 'agentSession.switchToChat', + permission: 'workspace', + params: OptionsParams, + handler: async ( + params, + ctx + ): Promise< + { ok: true; sessionId: string } | { ok: false; refusal: AgentSessionWireRefusal } + > => { + requireStructuredCapability(ctx) + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredSessionHost(ctx, params.sessionId) + const record = host.deps.store.getRecord(params.sessionId) + if (!record || !inPlaceSwitchEnabled(record.provider)) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This chat cannot take its conversation from a terminal on this host.' + ) + } + } + const started = await host.switchToChat(params.sessionId) + return started.ok ? { ok: true, sessionId: params.sessionId } : started + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index f3b88b73c03f..fdfebaef3857 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(39) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(40) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 516c0bfad40b..676087ea7d9d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -53,6 +53,7 @@ import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent import { STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS } from './structured-agent-session-conversation-outline' import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from './structured-agent-session-options-read' import { STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS } from './structured-agent-session-switch-to-terminal' +import { STRUCTURED_AGENT_SESSION_SWITCH_TO_CHAT_METHODS } from './structured-agent-session-switch-to-chat' import { AttachParams, CancelParams, @@ -301,5 +302,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ ...STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS, ...STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS, ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS, - ...STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS + ...STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS, + ...STRUCTURED_AGENT_SESSION_SWITCH_TO_CHAT_METHODS ] diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 15ef7c47435f..b37bc2f40333 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -1,41 +1,91 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' -import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' +import { + activateAiVaultStructuredSession, + activateAiVaultStructuredSessionVerified +} from '@/lib/activate-ai-vault-structured-session' +import { + callStructuredAgentSession, + supportsInPlaceSwitch +} from '@/runtime/structured-agent-session-client' import { agentSessionWriteFailureText } from '../native-chat/agent-session-write-notice-text' import { agentLabel } from '../../../../shared/ai-vault-session-filters' +import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { cliConversationExitGuidance } from '@/lib/cli-conversation-exit-guidance' import type { CliChatResumeSource } from './cli-chat-resume-source' -/** True when the chat that already holds the conversation was revealed instead: the switch's - * writer check passed before the host named that chat. */ +/** True only when the chat already holding the conversation took it over: the host started its + * agent (catching up on the terminal's turns) and its tab is active. Otherwise the terminal stays. */ export async function reportCliChatResumeFailure( source: CliChatResumeSource, error: unknown, target: RuntimeClientTarget ): Promise { + const owner = structuredLaunchFailure(error)?.owner + if (owner) { + return switchIntoOwner(source, owner, target) + } + toastCliChatResumeFailure(source, error) + return false +} + +async function switchIntoOwner( + source: CliChatResumeSource, + structuredSession: NonNullable, + target: RuntimeClientTarget +): Promise { + // A host without the switch keeps the earlier behaviour: the chat is shown, the terminal stays. + if (!(await supportsInPlaceSwitch(target, source.agent))) { + await activateAiVaultStructuredSession({ structuredSession }, undefined, target) + return false + } + const switched = await callStructuredAgentSession< + { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } + >(target, 'agentSession.switchToChat', { sessionId: structuredSession.sessionId }).catch( + () => null + ) + if (!switched?.ok) { + const failure = switched + ? structuredLaunchFailure(Object.assign(new Error(switched.refusal.message), switched)) + : undefined + // Never the owner shortcut again: a refused start keeps the terminal and says why. + toast.error( + failure?.code === 'agent_session_operation_invalid' && + failure.details?.reason === 'conversationOpenInTerminal' + ? cliConversationExitGuidance(source.agent, true) + : agentSessionWriteFailureText(failure ?? { kind: 'unconfirmed' }, 'read-history', { + agentName: agentLabel(source.agent) + }) + ) + return false + } + const shown = await activateAiVaultStructuredSessionVerified( + { structuredSession }, + undefined, + target + ) + return shown === 'activated' +} + +function toastCliChatResumeFailure(source: CliChatResumeSource, error: unknown): void { const refusal = structuredLaunchFailure(error) if ( refusal?.code === 'agent_session_conflict' || refusal?.code === 'agent_session_ownership_unknown' ) { - const owner = refusal.owner - if (owner) { - await activateAiVaultStructuredSession({ structuredSession: owner }, undefined, target) - return true - } toast.error( refusal.code === 'agent_session_conflict' && - refusal.details?.reason === 'conversationHeldElsewhere' && - !owner + refusal.details?.reason === 'conversationHeldElsewhere' ? cliConversationExitGuidance(source.agent, true) : translate( 'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict', 'Another chat is already holding this conversation.' ) ) - return false + return } // The chat's agent refused to start while the terminal may still write the conversation. if ( @@ -43,11 +93,11 @@ export async function reportCliChatResumeFailure( refusal.details?.reason === 'conversationOpenInTerminal' ) { toast.error(cliConversationExitGuidance(source.agent, true)) - return false + return } if (refusal?.details?.reason === 'conversationMovingToTerminal') { toast.error(agentSessionWriteFailureText(refusal, 'read-history')) - return false + return } if (refusal?.code === 'agent_session_identity_required') { if (refusal.details?.reason === 'transcriptNotFound') { @@ -58,12 +108,12 @@ export async function reportCliChatResumeFailure( { agent: agentLabel(source.agent) } ) ) - return false + return } const failure = structuredLaunchFailure(error) if (failure) { toast.error(agentSessionWriteFailureText(failure, 'read-history')) - return false + return } } toast.error( @@ -72,5 +122,4 @@ export async function reportCliChatResumeFailure( 'Could not resume this session in a new chat.' ) ) - return false } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index ae760b4b2e97..0e8df40006d9 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -12,13 +12,16 @@ const mocks = vi.hoisted(() => { createCapability: vi.fn(), call: vi.fn(), toast: vi.fn(), - activate: vi.fn(async () => true) + activate: vi.fn(async () => true), + activateVerified: vi.fn(async () => 'activated'), + switchCapability: vi.fn(async () => false) } }) vi.mock('./cli-chat-resume-source', () => ({ resolveCliChatResumeSource: () => mocks.source })) vi.mock('@/runtime/structured-agent-session-client', () => ({ supportsStructuredAgentSessionCliSource: mocks.sourceCapability, supportsStructuredAgentSessionCreateMessage: mocks.createCapability, + supportsInPlaceSwitch: mocks.switchCapability, callStructuredAgentSession: (...args: unknown[]) => args[1] === 'agentSession.createSupport' ? Promise.resolve({ supported: true }) @@ -34,7 +37,8 @@ vi.mock('@/lib/structured-agent-session-create-hydration', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ - activateAiVaultStructuredSession: mocks.activate + activateAiVaultStructuredSession: mocks.activate, + activateAiVaultStructuredSessionVerified: mocks.activateVerified })) import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' @@ -237,27 +241,57 @@ describe('CLI identity resume through the existing launch owner', () => { expect([...structuredLaunchStates()]).toEqual([]) }) - it('reveals the chat that already holds the conversation in place and closes the terminal pane', async () => { + it('starts the chat that holds the conversation before the terminal closes; a refusal keeps it', async () => { const owner = { sessionId: 'owner-chat', workspaceId: 'owner-workspace' } - mocks.call.mockResolvedValue({ + let switched: unknown = { ok: false, refusal: { - code: 'agent_session_conflict', - message: 'owner', - details: { reason: 'conversationHeldElsewhere', owner }, - createAttemptRefusedBeforeCommit: true + code: 'agent_session_operation_invalid', + message: 'x', + details: { reason: 'conversationOpenElsewhere' } } - }) + } + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.switchToChat' + ? switched + : { + ok: false, + refusal: { + code: 'agent_session_conflict', + message: 'owner', + details: { reason: 'conversationHeldElsewhere', owner }, + createAttemptRefusedBeforeCommit: true + } + } + ) const leafId = '11111111-1111-4111-8111-111111111111' mocks.source = { ...SOURCE, paneKey: `tab-1:${leafId}` } const closed = vi.fn() window.addEventListener('orca-close-terminal-pane', closed) + // A host without the switch only shows the chat; it never receives the switch. await runCliChatResume(mocks.source) - window.removeEventListener('orca-close-terminal-pane', closed) - expect(mocks.toast).not.toHaveBeenCalled() expect(mocks.activate).toHaveBeenCalledWith({ structuredSession: owner }, undefined, { kind: 'local' }) + mocks.switchCapability.mockResolvedValue(true) + await runCliChatResume(mocks.source) + expect(mocks.toast).toHaveBeenCalledOnce() + expect(mocks.activateVerified).not.toHaveBeenCalled() + expect(closed).not.toHaveBeenCalled() + + switched = { ok: true, sessionId: 'owner-chat' } + await runCliChatResume(mocks.source) + window.removeEventListener('orca-close-terminal-pane', closed) + const switches = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.switchToChat' + ) + expect(switches).toEqual([ + [{ kind: 'local' }, 'agentSession.switchToChat', { sessionId: 'owner-chat' }], + [{ kind: 'local' }, 'agentSession.switchToChat', { sessionId: 'owner-chat' }] + ]) + expect(mocks.activateVerified).toHaveBeenCalledWith({ structuredSession: owner }, undefined, { + kind: 'local' + }) expect(closed).toHaveBeenCalledOnce() expect(closed.mock.calls[0]?.[0].detail).toEqual({ tabId: 'tab-1', leafId }) expect(tabs()).toEqual([]) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 032e45367f5c..5b02ef8065c0 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -169,8 +169,11 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise { + return (await activateAiVaultStructuredSessionVerified(session, deps, target)) !== 'none' +} + +/** `activated` only when the chat's tab is really active; `reported` means the click's toast said why + * not, which a caller retiring something on success must not read as success. */ +export async function activateAiVaultStructuredSessionVerified( + session: Pick, + deps: StructuredSessionActivationDeps = defaultDeps, + target?: RuntimeClientTarget +): Promise<'activated' | 'reported' | 'none'> { const structured = session.structuredSession if (!structured) { - return false + return 'none' } // Why: the click can chain a refresh, a capability probe, a reveal and a second refresh, each // with its own timeout, and nothing on the row says it is working. Without this, an impatient @@ -125,13 +135,13 @@ export async function activateAiVaultStructuredSession( } } -const activationsInFlight = new Map>() +const activationsInFlight = new Map>() async function activateStructuredSession( structured: NonNullable, deps: StructuredSessionActivationDeps, host?: RuntimeClientTarget -): Promise { +): Promise<'activated' | 'reported'> { const target = { worktreeId: structured.workspaceId, sessionId: structured.sessionId, @@ -155,19 +165,19 @@ async function activateStructuredSession( // Unreachable: we never got an answer, so this is the one case waiting can still fix. deps.unavailable() } - return true + return 'reported' } await refreshedWithoutThrowing(deps, structured.workspaceId, host) if (!deps.activate(target)) { deps.unavailable() - return true + return 'reported' } } } if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) { activateAndRevealWorktree(structured.workspaceId) } - return true + return 'activated' } /** The inventory refresh is advisory at every call site here, so its failure is a `false`, never a diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 618cb2c55e9f..c42f0c739aa1 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -719,6 +719,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.subscribeAvailability': AvailabilityParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, + 'agentSession.switchToChat': OptionsParams, 'agentSession.switchToTerminal': OptionsParams, 'agentSession.threadGoal': ThreadGoalParams, 'agentSession.unsubscribe': UnsubscribeParams, From 84175b1cb59582771fce1c19b28675eaafbb15e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:50:49 -0700 Subject: [PATCH 013/207] fix(native-chat): tie each switch claim to the spawn attempt that took it A switch claim is now owned by the terminal spawn attempt that accepted it (its host session id, else its pane). Only one attempt may hold a conversation: a different attempt is refused while one is accepted or a switched terminal may still hold it, and a retry of the same attempt joins it. Settlement follows the spawn's real result, including a reattach; a spawn that ended without a reply keeps its claim until the host's own session list proves that session absent, so a lost reply can no longer let a chat start beside the terminal. Also from review: a paired Orca server that switches the agent in place now offers the switch back from its terminals, and the paired create names its new tab so the switch back is offered before the first prompt; the presentation seed binds to the switched pane's leaf and PTY and ends when that PTY is replaced or the pane goes, so a split's plain shell is never switched or closed; and an unreadable Windows process identity reads as unverifiable, with only a fresh process table's absence or a different start time proving exit. --- .../pty/host-env/resume-spawn-judgement.ts | 31 +++++--- .../pty/host-env/saved-resume-admission.ts | 39 ++++++---- src/main/ipc/pty/ipc/spawn-execute.ts | 7 +- src/main/ipc/pty/ipc/spawn-run.ts | 7 +- src/main/ipc/pty/ipc/spawn-state.ts | 2 + src/main/ipc/pty/runtime/spawn-execute.ts | 7 +- src/main/ipc/pty/runtime/spawn-state.ts | 2 + src/main/ipc/pty/runtime/spawn.ts | 7 +- .../structured-session-cli-writer-host.ts | 2 + .../structured-session-switch-claims.test.ts | 75 +++++++++++++++---- .../structured-session-switch-claims.ts | 65 +++++++++++++--- ...uctured-session-switch-observation.test.ts | 35 +++++++++ .../structured-session-switch-observation.ts | 14 +++- .../tab-bar/cli-chat-resume-source.ts | 19 +++-- .../native-chat-cli-switch-run.test.ts | 29 ++++++- .../tab-bar/native-chat-cli-switch-run.ts | 6 +- .../tab-bar/switched-terminal-conversation.ts | 24 +++++- .../tab-session-history-switch.test.ts | 30 +++++++- .../tab-bar/tab-session-history-switch.ts | 7 +- .../use-terminal-pane-projection.ts | 2 +- .../src/lib/launch-ai-vault-session.ts | 2 +- .../src/runtime/web-runtime-session-types.ts | 4 +- .../runtime/web-runtime-terminal-creation.ts | 6 +- 23 files changed, 338 insertions(+), 84 deletions(-) create mode 100644 src/main/runtime/session-tabs/structured-session-switch-observation.test.ts diff --git a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts index 31cd3075a894..0046f85bde3f 100644 --- a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts +++ b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts @@ -10,7 +10,10 @@ import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import type { SwitchClaim } from '../../../runtime/session-tabs/structured-session-switch-claims' +import type { + SwitchClaim, + SwitchSpawnOutcome +} from '../../../runtime/session-tabs/structured-session-switch-claims' import { agentSessionLeasePossiblyWriting, listStructuredProviderSessionOwnership @@ -39,8 +42,13 @@ export type AdmissionRuntime = { paneKey: string ): Promise openAgentSessionRecordStore(): Promise - acceptSwitchStart?(agent: string, id: string): SwitchClaim - settleSwitchStart?(claim: SwitchClaim, ptyId: string | null): void + acceptSwitchStart?( + agent: string, + id: string, + attempt: string, + sessionId?: string + ): SwitchClaim | null + settleSwitchAttempt?(attempt: string, outcome: SwitchSpawnOutcome): void } /** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: @@ -56,8 +64,6 @@ export type ResumeSpawnAdmission = verdict: 'admitted' refused: RefusedPaneResume sourceRecord?: SleepingAgentSessionRecord - /** The switch claim this spawn settles: bound to its PTY, or dropped when none started. */ - switchClaim?: SwitchClaim } | ResumeSpawnRefusal @@ -114,7 +120,8 @@ export async function judgeResumes( export async function admitSwitchResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, - wslDistro: string | null + wslDistro: string | null, + spawn?: { attempt?: string; sessionId?: string } ): Promise { const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent @@ -122,15 +129,17 @@ export async function admitSwitchResumeSpawn( throw new Error('switch_start_missing_conversation') } const refused = { agent, providerSession } - if (args.connectionId || wslDistro !== null || !runtime?.acceptSwitchStart) { + const attempt = spawn?.attempt + if (args.connectionId || wslDistro !== null || !runtime?.acceptSwitchStart || !attempt) { return { verdict: 'unverifiable', refused } } // Before any await: a chat start whose census ends after this point sees the claim. - const switchClaim = runtime.acceptSwitchStart(agent, providerSession.id) + if (!runtime.acceptSwitchStart(agent, providerSession.id, attempt, spawn.sessionId)) { + return { verdict: 'chat-writing', refused } + } const admission = await judgeResumes(runtime, [refused], true) if (admission.verdict !== 'admitted') { - runtime.settleSwitchStart?.(switchClaim, null) - return admission + runtime.settleSwitchAttempt?.(attempt, 'not-started') } - return { ...admission, switchClaim } + return admission } diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index ddb73a900854..b4f09a31fd06 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -7,6 +7,7 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-type import { agentHookServer } from '../../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import type { SwitchSpawnOutcome } from '../../../runtime/session-tabs/structured-session-switch-claims' import { admitSwitchResumeSpawn, judgeResumes, @@ -26,10 +27,11 @@ export type { export async function admitResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, - wslDistro: string | null + wslDistro: string | null, + spawn?: { attempt?: string; sessionId?: string } ): Promise { if (args.resumeOrigin === 'switch') { - return admitSwitchResumeSpawn(runtime, args, wslDistro) + return admitSwitchResumeSpawn(runtime, args, wslDistro, spawn) } const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent @@ -105,14 +107,14 @@ export function dropAgentLaunch(args: ResumeSpawnArgs): void { delete args.resumeProviderSession } -/** Every exit of a switch spawn settles its claim: on the PTY it produced, or gone with none. */ +/** Every exit of a switch spawn settles its attempt's claim; `admitSwitchResumeSpawn` explains how. */ export function settleSwitchSpawn( runtime: AdmissionRuntime | undefined, - admission: ResumeSpawnAdmission | null, - ptyId: string | null + attempt: string | undefined, + outcome: SwitchSpawnOutcome ): void { - if (admission?.verdict === 'admitted' && admission.switchClaim) { - runtime?.settleSwitchStart?.(admission.switchClaim, ptyId) + if (attempt) { + runtime?.settleSwitchAttempt?.(attempt, outcome) } } @@ -159,16 +161,27 @@ export function savedResumeRecord( : undefined } -/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. */ +/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. A switch's claim + * is owned by this spawn attempt: its host session id, else its pane. */ export async function chooseSavedResumeFreshLaunch( - runtime: AdmissionRuntime | undefined, + spawn: { + deps: { runtime?: AdmissionRuntime } + expectedWslDistro: string | null + spawnOptions: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } + effectiveSessionAppId: string | undefined + switchAttempt?: string + }, args: ResumeSpawnArgs, - wslDistro: string | null, - options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } + paneKey: string | null ): Promise { - const admission = await admitResumeSpawn(runtime, args, wslDistro) + const sessionId = spawn.effectiveSessionAppId + spawn.switchAttempt = sessionId ?? paneKey ?? undefined + const admission = await admitResumeSpawn(spawn.deps.runtime, args, spawn.expectedWslDistro, { + attempt: spawn.switchAttempt, + sessionId + }) if (resumeSpawnRefusal(admission)) { - suppressSavedResumeLaunch(args, options) + suppressSavedResumeLaunch(args, spawn.spawnOptions) } return admission } diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts index 7832ee122ac7..bf621daf495f 100644 --- a/src/main/ipc/pty/ipc/spawn-execute.ts +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -34,12 +34,7 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const chooseFresh = async (): Promise => { ctx.spawnOptions.env = { ...ctx.spawnOptions.env } args.launchConfig = ctx.effectiveLaunchConfig - ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( - ctx.deps.runtime, - args, - ctx.expectedWslDistro, - ctx.spawnOptions - ) + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(ctx, args, ctx.stablePaneKey) if (captured && ctx.resumeAdmission) { ctx.resumeAdmission.refused = captured.refused ctx.resumeAdmission.sourceRecord = captured.sourceRecord diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 9e5eda9b3dc5..de66664d88b5 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -126,10 +126,13 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg if (!ctx.result.isReattach) { deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) } - settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, committed.id) + settleSwitchSpawn(deps.runtime, ctx.switchAttempt, { ptyId: committed.id }) return committed } catch (caught) { - settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, ctx.result.id) + settleSwitchSpawn(deps.runtime, ctx.switchAttempt, { + // A daemon session may exist even with no reply; an in-process spawn that returned none made none. + unknown: ctx.result.id || ctx.effectiveSessionAppId + }) const err = endPaneAgentLaunch(ctx, { error: caught }) ?? caught releaseAbandonedAgentTeamsLeader(ctx) if (ctx.preSpawnHiddenMarkId !== null) { diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index e7c82b057d19..1b7238cba217 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -59,6 +59,8 @@ export type PtyIpcSpawnState = { isMintedSessionId: boolean effectiveSessionId: string | undefined effectiveSessionAppId: string | undefined + /** A switch spawn's claim identity, kept apart from the admission a reattach clears. */ + switchAttempt?: string expectedWslDistro: string | null baseEnv: Record | undefined effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 64133237fc54..89d32f016fdc 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -45,12 +45,7 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise } const chooseFresh = async (): Promise => { ctx.spawnOptions.env = { ...ctx.spawnOptions.env } - ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( - ctx.deps.runtime, - args, - ctx.expectedWslDistro, - ctx.spawnOptions - ) + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(ctx, args, ctx.spawnIdentityPaneKey) if (captured && ctx.resumeAdmission) { ctx.resumeAdmission.refused = captured.refused ctx.resumeAdmission.sourceRecord = captured.sourceRecord diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 5ca761ba57ab..9473741d3a1a 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -44,6 +44,8 @@ export type RuntimePtySpawnState = { requestedSessionId: string | undefined sessionId: string | undefined effectiveSessionAppId: string | undefined + /** A switch spawn's claim identity, kept apart from the admission a reattach clears. */ + switchAttempt?: string isNewDaemonSession: boolean expectedWslDistro: string | null /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index fc49c3e8fcee..90e82e81ea0a 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -133,10 +133,13 @@ export async function spawnPtyFromRuntimeController( if (!ctx.result.isReattach) { deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) } - settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, committed.id) + settleSwitchSpawn(deps.runtime, ctx.switchAttempt, { ptyId: committed.id }) return committed } catch (err) { - settleSwitchSpawn(deps.runtime, ctx.resumeAdmission, ctx.result.id) + settleSwitchSpawn(deps.runtime, ctx.switchAttempt, { + // A daemon session may exist even with no reply; an in-process spawn that returned none made none. + unknown: ctx.result.id || ctx.effectiveSessionAppId + }) releaseRuntimeSpawnPreSpawnHiddenMark(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 59553952fcb1..8b8a8be6c98a 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -132,6 +132,7 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca providerSessionId: string, source?: { workspaceId: string; paneKey: string } ): Promise { + await this.reconcileSwitchAttempts() this.assertNoPendingSwitch(agent, providerSessionId) await this.assertSwitchStartsEnded(agent, providerSessionId) await assertCliConversationWritersExited( @@ -182,6 +183,7 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca } async releaseEndedSwitchStarts(agent: string, id: string): Promise { + await this.reconcileSwitchAttempts() await this.assertSwitchStartsEnded(agent, id).catch(() => undefined) } diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts index 8a1f195a65a4..03586a60123c 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -19,6 +19,7 @@ vi.mock('./structured-session-switch-observation', () => ({ })) const C = 'thread-c' +const SPAWN = { attempt: 'pane-1' } const LEAF = '11111111-1111-4111-8111-111111111111' const AGENT = { pid: 999_991, platform: process.platform, startTime: 'started' } as const let runtime: OrcaRuntimeService @@ -64,15 +65,15 @@ describe('the switch claim', () => { expect(await reason(chatStart())).toBe('admitted') runtime.registerSwitchClaim('codex', C) - runtime.acceptSwitchStart('codex', C) + runtime.acceptSwitchStart('codex', C, 'pty-1', 'pty-1') // A spawn slower than the window keeps refusing until it settles. vi.advanceTimersByTime(120_000) expect(await reason(chatStart())).toBe('conversationMovingToTerminal') }) it('binds to the switch terminal, ignores identity-only replays, and ends on a proven exit', async () => { - const claim = runtime.acceptSwitchStart('codex', C) - runtime.settleSwitchStart(claim, 'pty-1') + runtime.acceptSwitchStart('codex', C, 'pty-1', 'pty-1') + runtime.settleSwitchAttempt('pty-1', { ptyId: 'pty-1' }) expect(await reason(chatStart())).toBe('conversationHeldElsewhere') // The launch identity names the conversation without proving a start or an exit. hook() @@ -87,12 +88,58 @@ describe('the switch claim', () => { }) it('refuses a second switch of the conversation while the first terminal may hold it', () => { - const first = runtime.acceptSwitchStart('codex', C) - runtime.settleSwitchStart(first, 'pty-1') + runtime.acceptSwitchStart('codex', C, 'pty-1', 'pty-1') + runtime.settleSwitchAttempt('pty-1', { ptyId: 'pty-1' }) expect(() => runtime.registerSwitchClaim('codex', C)).toThrow('agent_session_conflict') }) }) +describe('a switch spawn attempt owns its claim', () => { + const accept = (attempt: string) => runtime.acceptSwitchStart('codex', C, attempt, attempt) + + it('binds a reattach result to its PTY, and that pane exiting releases the conversation', async () => { + expect(accept('pty-1')).not.toBeNull() + runtime.settleSwitchAttempt('pty-1', { ptyId: 'pty-1' }) + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + await runtime.onPtyExit('pty-1', 0, 'i1') + expect(await reason(chatStart())).toBe('admitted') + }) + + it('keeps a lost spawn reply until the host proves its session absent', async () => { + let listed: { id: string }[] | Error = [{ id: 'lost' }] + runtime.setPtyController({ + listProcesses: async () => { + if (listed instanceof Error) { + throw listed + } + return listed + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reconciliation reads only listProcesses. + } as never) + accept('lost') + runtime.settleSwitchAttempt('lost', { unknown: 'lost' }) + expect(await reason(chatStart())).toBe('conversationMovingToTerminal') + listed = new Error('host unreachable') + expect(await reason(chatStart())).toBe('conversationMovingToTerminal') + listed = [] + expect(await reason(chatStart())).toBe('admitted') + }) + + it('refuses a delayed second client before and after the first binds; a retry joins', () => { + runtime.registerSwitchClaim('codex', C) + const b = accept('pty-b') + expect(accept('pty-a')).toBeNull() + expect(accept('pty-b')).toBe(b) + runtime.registerPty('pty-b', 'wt-1', null, { + tabId: 'tab-2', + leafId: LEAF, + incarnationId: 'i2' + }) + runtime.settleSwitchAttempt('pty-b', { ptyId: 'pty-b' }) + expect(accept('pty-a')).toBeNull() + }) +}) + describe('a switch spawn', () => { const args = { launchAgent: 'codex' as const, @@ -102,7 +149,7 @@ describe('a switch spawn', () => { const admission = (writing: boolean) => ({ acceptSwitchStart: vi.fn(() => ({ agent: 'codex', id: C, at: 0, accepted: true })), - settleSwitchStart: vi.fn(), + settleSwitchAttempt: vi.fn(), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a switch spawn reads only the store's records and tab index. openAgentSessionRecordStore: vi.fn( async () => @@ -115,29 +162,29 @@ describe('a switch spawn', () => { it('claims before reading the chat, and a writing chat refuses it into a plain shell', async () => { const quiet = admission(false) - expect(await admitSwitchResumeSpawn(quiet, args, null)).toMatchObject({ - verdict: 'admitted', - switchClaim: { id: C } + expect(await admitSwitchResumeSpawn(quiet, args, null, SPAWN)).toMatchObject({ + verdict: 'admitted' }) + expect(quiet.acceptSwitchStart).toHaveBeenCalledWith('codex', C, 'pane-1', undefined) const writing = admission(true) - const refused = admitSwitchResumeSpawn(writing, args, null) + const refused = admitSwitchResumeSpawn(writing, args, null, SPAWN) expect(writing.acceptSwitchStart.mock.invocationCallOrder[0]).toBeLessThan( writing.openAgentSessionRecordStore.mock.invocationCallOrder[0]! ) expect(await refused).toMatchObject({ verdict: 'chat-writing' }) - expect(writing.settleSwitchStart).toHaveBeenCalledWith(expect.anything(), null) + expect(writing.settleSwitchAttempt).toHaveBeenCalledWith('pane-1', 'not-started') }) it('refuses where it cannot judge the pane, and a switch without its conversation', async () => { const quiet = admission(false) expect( - await admitSwitchResumeSpawn(quiet, { ...args, connectionId: 'ssh' }, null) + await admitSwitchResumeSpawn(quiet, { ...args, connectionId: 'ssh' }, null, SPAWN) ).toMatchObject({ verdict: 'unverifiable' }) - expect(await admitSwitchResumeSpawn(quiet, args, 'Ubuntu')).toMatchObject({ + expect(await admitSwitchResumeSpawn(quiet, args, 'Ubuntu', SPAWN)).toMatchObject({ verdict: 'unverifiable' }) await expect( - admitSwitchResumeSpawn(quiet, { ...args, resumeProviderSession: undefined }, null) + admitSwitchResumeSpawn(quiet, { ...args, resumeProviderSession: undefined }, null, SPAWN) ).rejects.toThrow('switch_start_missing_conversation') }) }) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts index db9de61926f5..42bac3577ff0 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -19,8 +19,16 @@ export type SwitchClaim = { at: number /** A spawn took it: it never lapses, and settles only with that spawn. */ accepted: boolean + /** The accepting spawn's identity (its host session, else its pane); only it joins or settles. */ + attempt?: string + /** The host session to reconcile when the spawn ended without a result. */ + sessionId?: string + unsettled?: true } +/** A switch spawn's end: its PTY, a proved non-start, or a session the host may still hold. */ +export type SwitchSpawnOutcome = { ptyId: string } | 'not-started' | { unknown: string | undefined } + /** The switch's start bound to the execution it spawned. */ export type SwitchStartProof = { incarnationId: PtyIncarnationId @@ -87,23 +95,59 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR this.switchClaims.delete(claim) } - /** Synchronous, before the switch spawn's first await, so a chat start censusing after it refuses. */ - acceptSwitchStart(agent: string, id: string): SwitchClaim { - const claim = this.liveSwitchClaim(agent, id) ?? { agent, id, at: Date.now(), accepted: false } - claim.accepted = true + /** Synchronous, before the switch spawn's first await, so a chat start censusing after it refuses. + * Null refuses: another attempt took this conversation, or a switched terminal may hold it. */ + acceptSwitchStart( + agent: string, + id: string, + attempt: string, + sessionId: string | undefined + ): SwitchClaim | null { + const live = this.liveSwitchClaim(agent, id) + if (live?.accepted ? live.attempt !== attempt : !live && this.boundSwitchStarts(agent, id)[0]) { + return null + } + const claim = live ?? { agent, id, at: Date.now(), accepted: true } + Object.assign(claim, { accepted: true, attempt, sessionId: sessionId ?? claim.sessionId }) + delete claim.unsettled this.switchClaims.add(claim) return claim } - /** Ends this attempt's claim: bound to the execution its spawn produced, or gone when none exists. */ - settleSwitchStart(claim: SwitchClaim, ptyId: string | null): void { - if (!this.switchClaims.delete(claim)) { - return + /** Only a PTY binds and only a proved non-start drops; an unknown end waits for the host. */ + settleSwitchAttempt(attempt: string, outcome: SwitchSpawnOutcome): void { + const claim = [...this.switchClaims].find((c) => c.accepted && c.attempt === attempt) + const sessionId = + outcome === 'not-started' ? undefined : 'ptyId' in outcome ? outcome.ptyId : outcome.unknown + if (claim && !sessionId) { + this.switchClaims.delete(claim) + } else if (claim) { + Object.assign(claim, { sessionId, unsettled: true }) + this.bindSwitchStart(claim) } - const pty = ptyId ? this.ptysById.get(ptyId) : undefined - if (!pty?.incarnationId) { + } + + /** Unsettled attempts against the host's own session list; a failed read keeps them. */ + protected async reconcileSwitchAttempts(): Promise { + const open = [...this.switchClaims].filter((c) => c.unsettled && !this.bindSwitchStart(c)) + if (open.length === 0) { return } + const listed = await this.ptyController + ?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) + .catch(() => null) + for (const claim of open) { + if (listed && !this.bindSwitchStart(claim) && !listed.some((p) => p.id === claim.sessionId)) { + this.switchClaims.delete(claim) + } + } + } + + private bindSwitchStart(claim: SwitchClaim): boolean { + const pty = claim.sessionId ? this.ptysById.get(claim.sessionId) : undefined + if (!pty?.incarnationId || !this.switchClaims.delete(claim)) { + return false + } const key = `${claim.agent}\0${claim.id}` const reported = pty.cliConversations?.incarnationId === pty.incarnationId @@ -119,6 +163,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR void this.watchSwitchStart(pty.ptyId, proof).catch((error: unknown) => console.warn('[agent-session] could not watch a switched terminal start', error) ) + return true } /** An authentic hook's process joins the proof; identity-only rows carry none. */ diff --git a/src/main/runtime/session-tabs/structured-session-switch-observation.test.ts b/src/main/runtime/session-tabs/structured-session-switch-observation.test.ts new file mode 100644 index 000000000000..21579eb6c2ae --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-observation.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { switchStartProcessVerdict } from './structured-session-switch-observation' + +const table = vi.hoisted(() => { + const state: { + created: number | null + rows: { pid: number; ppid: number; name: string; creationTimeMs?: number }[] + } = { created: null, rows: [] } + return state +}) +vi.mock('../../windows/windows-process-table', () => ({ + isWindowsProcessStartTimeAvailable: () => true, + readWindowsProcessCreationTime: () => table.created, + readWindowsProcessIdentityTableFresh: async () => table.rows, + readWindowsProcessTableFresh: async () => [] +})) + +const AGENT = { pid: 4242, platform: 'win32', startTime: '1000' } as const +const platform = process.platform +beforeEach(() => Object.defineProperty(process, 'platform', { value: 'win32' })) +afterEach(() => Object.defineProperty(process, 'platform', { value: platform })) + +it('reads an unreadable Windows identity as unverifiable, and only absence or reuse as exited', async () => { + table.created = null + table.rows = [{ pid: 4242, ppid: 1, name: 'codex.exe' }] + expect(await switchStartProcessVerdict(AGENT)).toBe('unverifiable') + table.rows = [] + expect(await switchStartProcessVerdict(AGENT)).toBe('unverifiable') + table.rows = [{ pid: 7, ppid: 1, name: 'other.exe' }] + expect(await switchStartProcessVerdict(AGENT)).toBe('exited') + table.rows = [{ pid: 4242, ppid: 1, name: 'codex.exe', creationTimeMs: 2000 }] + expect(await switchStartProcessVerdict(AGENT)).toBe('exited') + table.created = 1000 + expect(await switchStartProcessVerdict(AGENT)).toBe('live') +}) diff --git a/src/main/runtime/session-tabs/structured-session-switch-observation.ts b/src/main/runtime/session-tabs/structured-session-switch-observation.ts index f86af7b50c26..8ab7ef5a843c 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-observation.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-observation.ts @@ -14,6 +14,7 @@ import { getFreshShellForegroundSnapshot } from '../../../shared/process-table-s import { isWindowsProcessStartTimeAvailable, readWindowsProcessCreationTime, + readWindowsProcessIdentityTableFresh, readWindowsProcessTableFresh } from '../../windows/windows-process-table' @@ -64,5 +65,16 @@ export async function switchStartProcessVerdict( return 'unverifiable' } const created = readWindowsProcessCreationTime(identity.pid) - return created !== null && String(created) === identity.startTime ? 'live' : 'exited' + if (created !== null) { + return String(created) === identity.startTime ? 'live' : 'exited' + } + // An unreadable pid is not an absent one: only a fresh table's own listing proves it is gone. + const rows = await readWindowsProcessIdentityTableFresh().catch(() => []) + const row = rows.find((candidate) => candidate.pid === identity.pid) + if (rows.length > 0 && !row) { + return 'exited' + } + return row?.creationTimeMs && String(row.creationTimeMs) !== identity.startTime + ? 'exited' + : 'unverifiable' } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index a903e46429de..e2d116b5ef9c 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -5,7 +5,7 @@ import { } from '@/lib/ai-vault-tab-title-requests' import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' -import { makePaneKey } from '../../../../shared/stable-pane-id' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { adoptsCliConversations, type CliConversationRowAgent @@ -36,12 +36,19 @@ export function resolveCliChatResumeSource( : collectAgentPaneConversations(state, adoptsCliConversations).find( (candidate) => candidate.tabId === args.tab.id ) - const leafId = state.terminalLayoutsByTabId[args.tab.id]?.activeLeafId - const seeded = switchedTerminalConversation(args.tab.id) - const paneKey = args.paneKey ?? (leafId ? makePaneKey(args.tab.id, leafId) : null) + const layout = state.terminalLayoutsByTabId[args.tab.id] + const leafId = args.paneKey ? parsePaneKey(args.paneKey)?.leafId : layout?.activeLeafId + const seeded = switchedTerminalConversation(args.tab.id, leafId, layout) if (!conversation) { - return seeded && paneKey - ? { tabId: args.tab.id, workspaceId: args.tab.worktreeId, paneKey, ...seeded } + return seeded && leafId + ? { + tabId: args.tab.id, + workspaceId: args.tab.worktreeId, + paneKey: makePaneKey(args.tab.id, leafId), + agent: seeded.agent, + providerSessionId: seeded.providerSessionId, + executionHostId: seeded.executionHostId + } : null } if ( diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts index 72538d15253c..84959c59c2f5 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts @@ -3,7 +3,9 @@ import { beforeEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ supported: true, call: vi.fn(), - launch: vi.fn((_args: unknown) => ({ tabId: 'terminal-tab' })), + launch: vi.fn((_args: unknown): { tabId: string | null; runtimeLaunch?: Promise } => ({ + tabId: 'terminal-tab' + })), closeUnifiedTab: vi.fn(), toast: vi.fn() })) @@ -27,6 +29,7 @@ vi.mock('@/lib/ai-vault-resume-command', () => ({ })) import { runNativeChatCliSwitch } from './native-chat-cli-switch-run' +import { switchedTerminalConversation } from './switched-terminal-conversation' const SWITCH = { sessionId: 'chat-1', @@ -93,3 +96,27 @@ it('keeps the chat when the host refuses a busy chat, and says why', async () => expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() expect(mocks.toast).toHaveBeenCalledWith('Wait for the reply to finish or stop it, then switch.') }) + +it('offers the switch back on a paired terminal before any prompt, for its own pane only', async () => { + mocks.launch.mockReturnValueOnce({ + tabId: null, + runtimeLaunch: Promise.resolve({ status: 'created', tabId: 'web-terminal-t' }) + }) + await runNativeChatCliSwitch({ ...SWITCH, executionHostId: 'runtime:server-1' }) + const layout = (ptyIdsByLeafId: Record) => ({ + root: null, + activeLeafId: 'L1', + expandedLeafId: null, + ptyIdsByLeafId + }) + expect(switchedTerminalConversation('web-terminal-t', 'L1', layout({ L1: 'p1' }))).toMatchObject({ + providerSessionId: 'thread-c' + }) + // A split's new plain shell never takes the switched pane's conversation. + expect(switchedTerminalConversation('web-terminal-t', 'L2', layout({ L1: 'p1', L2: 'p2' }))).toBe( + undefined + ) + // A replaced PTY in the switched pane ends it. + expect(switchedTerminalConversation('web-terminal-t', 'L1', layout({ L1: 'p9' }))).toBe(undefined) + expect(switchedTerminalConversation('web-terminal-t', 'L1', layout({ L1: 'p1' }))).toBe(undefined) +}) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts index af580d1f5a54..f3f228132951 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts @@ -108,8 +108,10 @@ export async function runNativeChatCliSwitch(args: { if (outcome && outcome.status !== 'created') { throw new Error(outcome.status === 'failed' ? outcome.message : undefined) } - if (launch.tabId && adoptsCliConversations(source.agent)) { - seedSwitchedTerminalConversation(launch.tabId, { + // A paired host's terminal is named by its create result, so the switch back works before a prompt. + const tabId = launch.tabId ?? (outcome?.status === 'created' ? outcome.tabId : undefined) + if (tabId && adoptsCliConversations(source.agent)) { + seedSwitchedTerminalConversation(tabId, { agent: source.agent, providerSessionId: source.sessionId, executionHostId: hostId diff --git a/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts b/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts index 3651a0afdd04..949be7bc75fc 100644 --- a/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts +++ b/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import type { CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' /** The conversation a switch put in a terminal tab, until its agent reports one. Presentation only: @@ -7,6 +8,9 @@ type SwitchedTerminalConversation = { agent: CliConversationRowAgent providerSessionId: string executionHostId: ExecutionHostId + /** The switch's own pane and PTY, latched when the new tab's only leaf first shows them. */ + leafId?: string + ptyId?: string } const byTabId = new Map() @@ -18,10 +22,26 @@ export function seedSwitchedTerminalConversation( byTabId.set(tabId, conversation) } +/** Only for the switch's own leaf and PTY; a replaced PTY or a gone leaf ends the seed. */ export function switchedTerminalConversation( - tabId: string + tabId: string, + leafId: string | null | undefined, + layout: TerminalLayoutSnapshot | undefined ): SwitchedTerminalConversation | undefined { - return byTabId.get(tabId) + const seed = byTabId.get(tabId) + const leaves = Object.keys(layout?.ptyIdsByLeafId ?? {}) + if (seed && !seed.leafId && leaves.length === 1) { + seed.leafId = leaves[0] + } + const ptyId = seed?.leafId ? layout?.ptyIdsByLeafId?.[seed.leafId] : undefined + if (seed?.leafId && ptyId && seed.ptyId === undefined) { + seed.ptyId = ptyId + } + if (seed?.ptyId && ptyId !== seed.ptyId) { + byTabId.delete(tabId) + return undefined + } + return seed?.leafId && seed.leafId === leafId ? seed : undefined } export function forgetSwitchedTerminalConversation(tabId: string): void { diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 694d60a403d1..2bd676760973 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -28,12 +28,14 @@ import { type TabSessionHistorySubject } from './tab-session-history-switch' +import type * as LaunchPlan from '@/lib/agent-session-launch-plan' + const mocks = vi.hoisted(() => { const state: { resumeState: { blocked: boolean; worktreeId: string | null } platform: string } = { resumeState: { blocked: false, worktreeId: 'repo-1::/repo/wt' }, platform: 'darwin' } - return { ...state, resumeInChat: vi.fn() } + return { ...state, resumeInChat: vi.fn(), inPlaceSwitch: false, remoteFeasible: false } }) // Staged host answers: its capabilities arrive over IPC at boot, and the platform from preload. @@ -53,6 +55,19 @@ vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ })) })) vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => mocks.platform })) +vi.mock('@/lib/in-place-switch-support', () => ({ + inPlaceSwitchSupportedNow: () => mocks.inPlaceSwitch +})) +// A paired host's chat support arrives in its runtime status; staged here as the route's answer. +vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + structuredAgentSessionLaunchFeasible: ( + ...args: Parameters + ) => mocks.remoteFeasible || actual.structuredAgentSessionLaunchFeasible(...args) + } +}) // Parity with the panel's real composition is pinned in tab-session-history-switch.parity.test.ts. vi.mock('../right-sidebar/ai-vault-session-resume-in-chat-workspace', () => ({ @@ -471,6 +486,19 @@ describe('one pane of a terminal tab', () => { expect(paneGate(onWindows({ kind: 'wsl', distro: 'Ubuntu' }), claudePane)).toBe(false) }) + it('offers the switch on a paired Orca server only once it switches that agent in place', () => { + const remote = () => makeState(liveClaudeEntry, { executionHostId: 'runtime:server-1' }) + mocks.remoteFeasible = true + expect(paneGate(remote(), claudePane)).toBe(false) + mocks.inPlaceSwitch = true + expect(paneGate(remote(), claudePane)).toBe(true) + expect(paneGate(makeState(liveClaudeEntry, { connectionId: 'dev-box' }), claudePane)).toBe( + false + ) + mocks.inPlaceSwitch = false + mocks.remoteFeasible = false + }) + it('offers nothing over SSH', () => { expect(paneGate(makeState(liveClaudeEntry, { connectionId: 'dev-box' }), claudePane)).toBe( false diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts index d335de178ede..59c351ce6d89 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.ts @@ -1,5 +1,6 @@ // Resolves a tab to its Agent Session History row so both menus offer the same surface switches. +import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' import type { AppState } from '@/store/types' import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' import { @@ -92,8 +93,12 @@ export function canPaneOfferResumeInNewChat( conversation.worktreeId ).defaultExecutionHostScope // The row gate's launch-route half, so a project the chat route refuses (WSL, repair) hides it. + // A runtime host that switches this agent in place hands a terminal's conversation over too. + const remoteSwitch = + parseExecutionHostId(hostScope)?.kind === 'runtime' && + inPlaceSwitchSupportedNow(conversation.executionHostId, conversation.agent) return ( - canHostOfferTabSessionMove('cli', hostScope) && + (canHostOfferTabSessionMove('cli', hostScope) || remoteSwitch) && CLI_CONVERSATION_ADOPTING_AGENTS[conversation.agent].some((agent) => structuredAgentSessionLaunchFeasible(state, { agent, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index 4d6998fff093..d944faa58cde 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -189,7 +189,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll activePaneCanResumeInChat: !activePaneIsChatLeaf && activeLeafId !== null && - (switchedTerminalConversation(tabId) !== undefined || + (!!switchedTerminalConversation(tabId, activeLeafId, state.terminalLayoutsByTabId[tabId]) || canPaneOfferResumeInNewChat(state, { worktreeId, paneKey: makePaneKey(tabId, activeLeafId) diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 4646748c8793..0fac9335e160 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -67,7 +67,7 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), activate: true }, - { notifyFailure: false } + { notifyFailure: false, ...(args.resumeOrigin ? { withTabId: true } : {}) } ) const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { if (outcome.status === 'created') { diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index a09629f34a61..3287a64ec521 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -11,8 +11,8 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import type { TuiAgent } from '../../../shared/tui-agent' export type WebRuntimeTerminalCreateOutcome = - | { status: 'created' } - | { status: 'failed'; message: string } + /** `tabId`: this client's tab for the host's new terminal, when the host named it. */ + { status: 'created'; tabId?: string } | { status: 'failed'; message: string } export type CreateWebRuntimeSessionTerminalArgs = { worktreeId: string diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index bde5df66a78c..1670bf75fef5 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -17,7 +17,7 @@ import type { */ export async function createWebRuntimeSessionTerminal( args: CreateWebRuntimeSessionTerminalArgs, - options: { notifyFailure?: boolean } = {} + options: { notifyFailure?: boolean; withTabId?: boolean } = {} ): Promise { const created = await createWebRuntimeSessionTerminalResult(args) if ( @@ -30,7 +30,9 @@ export async function createWebRuntimeSessionTerminal( worktreeId: args.worktreeId }) } - return created.outcome + return options.withTabId && created.outcome.status === 'created' && created.hostTabId + ? { status: 'created', tabId: toWebTerminalSurfaceTabId(created.hostTabId) } + : created.outcome } export async function createWebRuntimeAgentSessionTerminal( From c2468c54b3b9d1f934c022b2efa7ea42b9a84a03 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:05:26 -0700 Subject: [PATCH 014/207] fix(native-chat): let the terminal host own a switch start until it proves the start over The chat to terminal switch now carries one generation and the owner that creates the terminal. The daemon session and the local process are bound to that generation before any later step can fail, and only their own end, or a proved agent exit with that exact shell alone, releases the conversation. The accepted start is saved on the chat it left, so a restart still knows it may exist; a lost owner never speaks for it. The caller-side settlement and the renderer's guessed tab seed are gone. A switched terminal's conversation is presented from the host's launch identity, stamped with the exact PTY and incarnation, and the switch back retires only the pane that still holds that execution. --- .../server-launch-resume-identity.test.ts | 21 ++ .../server/server-ingest-terminal.ts | 5 +- src/main/daemon/daemon-pty-adapter.ts | 25 ++ .../daemon/daemon-pty-owner-query-protocol.ts | 7 + src/main/daemon/daemon-pty-router.ts | 17 +- src/main/daemon/daemon-pty-spawn-request.ts | 3 + src/main/daemon/daemon-request-router.ts | 19 +- src/main/daemon/daemon-terminal-admission.ts | 3 + .../daemon/degraded-daemon-pty-provider.ts | 18 +- .../terminal-host-concurrent-create.test.ts | 39 +++ .../daemon/terminal-host-create-contract.ts | 2 + .../daemon/terminal-host-session-create.ts | 46 ++- src/main/daemon/terminal-host-switches.ts | 63 ++++ src/main/daemon/terminal-host.ts | 14 +- src/main/daemon/types.ts | 4 + .../pty/host-env/resume-spawn-judgement.ts | 45 +-- .../pty/host-env/saved-resume-admission.ts | 31 +- src/main/ipc/pty/ipc/spawn-commit.ts | 3 +- src/main/ipc/pty/ipc/spawn-run.ts | 6 - src/main/ipc/pty/ipc/spawn-state.ts | 2 - .../pty/pane/resume-launch-identity.test.ts | 7 + .../ipc/pty/pane/resume-launch-identity.ts | 11 +- src/main/ipc/pty/runtime/controller.ts | 9 +- src/main/ipc/pty/runtime/spawn-commit.ts | 3 +- src/main/ipc/pty/runtime/spawn-state.ts | 2 - src/main/ipc/pty/runtime/spawn.ts | 8 +- .../local-pty-provider-spawn-session.test.ts | 19 ++ src/main/providers/local-pty-provider.ts | 5 + .../providers/local-pty-session-activation.ts | 6 +- .../local-pty-shell-ready-startup-command.ts | 6 +- src/main/providers/local-pty-spawn.ts | 21 +- .../providers/local-pty-terminal-switch.ts | 101 ++++++ src/main/providers/pty-provider-contract.ts | 7 + src/main/providers/terminal-switch-owners.ts | 22 ++ .../runtime/agent-session-record-store.ts | 5 + .../agent-session-record-terminal-switch.ts | 20 ++ ...ctured-agent-session-switch-to-terminal.ts | 48 ++- .../runtime-pty-controller-contract.ts | 3 + .../runtime/runtime-terminal-state-records.ts | 3 - .../structured-session-cli-writer-host.ts | 54 +-- .../structured-session-switch-claims.test.ts | 263 +++++++++------ .../structured-session-switch-claims.ts | 311 ++++++++++-------- .../structured-session-switch-observation.ts | 16 + src/main/startup/main-window-agent-status.ts | 2 + .../tab-bar/cli-chat-resume-run.test.ts | 34 +- .../components/tab-bar/cli-chat-resume-run.ts | 31 +- .../tab-bar/cli-chat-resume-source.ts | 44 +-- .../native-chat-cli-switch-run.test.ts | 30 +- .../tab-bar/native-chat-cli-switch-run.ts | 19 +- .../tab-bar/switched-terminal-conversation.ts | 49 --- .../tab-session-history-switch.test.ts | 51 +++ .../pty-connection/pty-exit-hibernate.ts | 1 + .../use-terminal-pane-projection.ts | 10 +- .../agent-status-partial-updates.ts | 9 +- .../src/lib/ai-vault-tab-title-requests.ts | 32 +- .../src/lib/launch-ai-vault-session.ts | 2 +- .../src/runtime/web-runtime-session-types.ts | 4 +- .../runtime/web-runtime-terminal-creation.ts | 6 +- .../agent-status-provider-session-actions.ts | 12 +- .../slices/agent-status-slice-contract.ts | 14 + src/renderer/src/store/slices/agent-status.ts | 1 + .../agent-hook-listener/listener-event.ts | 3 + src/shared/agent-session-record.ts | 14 +- src/shared/agent-status-ipc-payload.ts | 3 + src/shared/terminal-switch.ts | 69 ++++ 65 files changed, 1209 insertions(+), 554 deletions(-) create mode 100644 src/main/daemon/terminal-host-switches.ts create mode 100644 src/main/providers/local-pty-terminal-switch.ts create mode 100644 src/main/providers/terminal-switch-owners.ts create mode 100644 src/main/runtime/agent-session-record-terminal-switch.ts delete mode 100644 src/renderer/src/components/tab-bar/switched-terminal-conversation.ts create mode 100644 src/shared/terminal-switch.ts diff --git a/src/main/agent-hooks/server-launch-resume-identity.test.ts b/src/main/agent-hooks/server-launch-resume-identity.test.ts index 27afee0f2457..d241db1f418b 100644 --- a/src/main/agent-hooks/server-launch-resume-identity.test.ts +++ b/src/main/agent-hooks/server-launch-resume-identity.test.ts @@ -64,6 +64,27 @@ describe('a resume launch seeds the pane identity', () => { ]) }) + it('names a switch start’s exact execution, and a new incarnation there re-seeds it', async () => { + const server = await startServer() + const published = vi.fn() + server.setListener(published) + const seed = (incarnationId: string) => + server.recordLaunchResumeIdentity({ + paneKey: PANE, + agentType: 'codex', + providerSession: RESUMED, + launchedAt: Date.now(), + execution: { ptyId: 'pty-1', incarnationId } + }) + seed('i1') + seed('i1') + seed('i2') + expect(published.mock.calls.map(([row]) => row.launchExecution)).toEqual([ + { ptyId: 'pty-1', incarnationId: 'i1' }, + { ptyId: 'pty-1', incarnationId: 'i2' } + ]) + }) + it("yields to the agent's own report and is replaced by its first hook", async () => { const server = await startServer() const launchedAt = Date.now() - 1_000 diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index ab621097860b..fd64bcfc4ad0 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -17,6 +17,7 @@ import { programStatusYieldsToHookRow } from './program-status/hook-precedence' import type { ProgramStatusFacet } from '../../../shared/program-status/facet' +import type { TerminalExecutionStamp } from '../../../shared/terminal-switch' const PROGRAM_STATUS_FACET_REPUBLISH_MS = 250 @@ -33,6 +34,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges providerSession: AgentProviderSessionMetadata /** When the pane's process launched; a row reported since then is the agent's own. */ launchedAt: number + execution?: TerminalExecutionStamp }): void { const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim()) const parsedPaneKey = parsePaneKey(paneKey) @@ -47,7 +49,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.agentType, previous.providerSession, event.providerSession - ) + ) && previous.launchExecution?.incarnationId === event.execution?.incarnationId : 'receivedAt' in previous && typeof previous.receivedAt === 'number' && previous.receivedAt >= event.launchedAt) @@ -62,6 +64,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId: null, providerSession: event.providerSession, providerSessionOnly: true, + ...(event.execution ? { launchExecution: event.execution } : {}), // Why: hydration keeps only marked identity rows; a host restart before the first turn must not lose it. retainedForLiveness: true, payload: { state: 'done', prompt: '', agentType: event.agentType } diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 3ebccf283115..1593eee3ca78 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,4 +1,6 @@ import { emitPtyListeners, createPtyExitPayload } from './daemon-pty-listener-emission' +import { randomUUID } from 'node:crypto' +import type { TerminalSwitchOwnerAnswer, TerminalSwitchOwners } from '../../shared/terminal-switch' import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' import type { DaemonPtyAdapterOptions } from './daemon-pty-runtime-state' import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' @@ -16,6 +18,29 @@ import type { AutomaticWorkRequestType } from './daemon-automatic-work-protocol' import type { DaemonAsyncIoStatus } from '../../shared/daemon-async-io-health' export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { + /** Asked of this daemon only; a daemon without the request answers nothing, so it owns no switch. */ + readonly terminalSwitches: TerminalSwitchOwners = { + owner: async () => (await this.terminalSwitches.observe(randomUUID(), false))[0]?.owner ?? null, + observe: async (generation, fence) => { + if (!this.client.isConnected()) { + return [] + } + try { + const answer = await this.client.request( + 'observeTerminalSwitch', + { generation, fence }, + 5_000 + ) + return typeof answer?.owner === 'string' ? [answer] : [] + } catch (error) { + if (isUnknownRequestTypeError(error)) { + return [] + } + throw error + } + } + } + constructor(opts: DaemonPtyAdapterOptions) { super(opts) // Registered after the base listener, which marks the sessions awaiting recovery first. diff --git a/src/main/daemon/daemon-pty-owner-query-protocol.ts b/src/main/daemon/daemon-pty-owner-query-protocol.ts index 875f09c6c994..2242ff1c49c4 100644 --- a/src/main/daemon/daemon-pty-owner-query-protocol.ts +++ b/src/main/daemon/daemon-pty-owner-query-protocol.ts @@ -10,3 +10,10 @@ export type SetColorQueryReplyColorsRequest = { type: 'setColorQueryReplyColors' payload: { colors: unknown } } + +/** The daemon's own answer about one chat → terminal switch generation; an unseen one is fenced. */ +export type ObserveTerminalSwitchRequest = { + id: string + type: 'observeTerminalSwitch' + payload: { generation: string; fence?: boolean } +} diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0009cb356bba..48bb1b06a07c 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,4 +1,5 @@ import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation' +import { routedTerminalSwitchOwners } from '../providers/terminal-switch-owners' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { @@ -30,6 +31,10 @@ export class DaemonPtyRouter implements IPtyProvider { private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout private readonly legacyExitProof = new LegacyDaemonExitProof() readonly retirement = new DaemonRouterRetirement(() => this.allAdapters()) + readonly terminalSwitches = routedTerminalSwitchOwners( + () => this.current, + () => this.allAdapters() + ) constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current @@ -110,9 +115,7 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.supportsAgentSessionCreateOperations() } - async attach(id: string): ReturnType { - return await this.adapterFor(id).attach(id) - } + attach = (id: string): ReturnType => this.adapterFor(id).attach(id) hasPty(id: string): boolean { const routed = this.sessionAdapters.get(id) @@ -171,13 +174,9 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).sendSignal(id, signal) } - async getCwd(id: string): Promise { - return this.adapterFor(id).getCwd(id) - } + getCwd = (id: string): Promise => this.adapterFor(id).getCwd(id) - async getInitialCwd(id: string): Promise { - return this.adapterFor(id).getInitialCwd(id) - } + getInitialCwd = (id: string): Promise => this.adapterFor(id).getInitialCwd(id) async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { return (await this.adapterFor(id).getAppliedSize?.(id)) ?? null diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index 6c5fb0e35cac..acdbd47e8b0d 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -143,6 +143,9 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { : {}), ...(!context.attachOnly && opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } + : {}), + ...(!context.attachOnly && opts.switchGeneration + ? { switchGeneration: opts.switchGeneration, switchOwner: opts.switchOwner } : {}) } return opts.signal diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index 73d7703592c2..f79a1379055c 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -62,15 +62,16 @@ export class DaemonRequestRouter { return {} case 'createOrAttach': return this.options.admission.createOrAttach(clientId, request) - case 'cancelCreateOrAttach': - return { - canceled: this.options.preparations.cancel(request.payload.sessionId, { - clientId, - ...(typeof request.payload.requestId === 'string' - ? { requestId: request.payload.requestId } - : {}) - }) - } + case 'cancelCreateOrAttach': { + const { sessionId, requestId } = request.payload + const scope = typeof requestId === 'string' ? { clientId, requestId } : { clientId } + return { canceled: this.options.preparations.cancel(sessionId, scope) } + } + case 'observeTerminalSwitch': + return this.options.host.switches.observe( + request.payload.generation, + request.payload.fence === true + ) case 'closeStartupQueryAuthority': return { appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 04daad020155..43a820b11ae0 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -128,6 +128,9 @@ export class DaemonTerminalAdmission { ? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs } : {}), ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), + ...(typeof payload.switchGeneration === 'string' + ? { switchGeneration: payload.switchGeneration, switchOwner: payload.switchOwner } + : {}), spawnAdmission: { clientId, declaredIntent: isSpawnIntent(payload.spawnIntent) ? payload.spawnIntent : null, diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index bf021bce8f83..ae82d3ae4f15 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,4 +1,5 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { routedTerminalSwitchOwners } from '../providers/terminal-switch-owners' import { combineUnsubscribes } from './combine-unsubscribes' import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' @@ -86,6 +87,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.freshSpawns.canProvideSnapshot(id) spawn = (opts: PtySpawnOptions): Promise => this.ownerRecovery.spawn(opts) + readonly terminalSwitches = routedTerminalSwitchOwners( + () => (this.freshSpawns.routesToFallback ? this.fallback : this.current), + () => this.allProviders() + ) // Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession. attach = (id: string): ReturnType => @@ -144,17 +149,12 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } } - async sendSignal(id: string, signal: string): Promise { - await this.providerFor(id).sendSignal(id, signal) - } + sendSignal = (id: string, signal: string): Promise => + this.providerFor(id).sendSignal(id, signal) - async getCwd(id: string): Promise { - return this.providerFor(id).getCwd(id) - } + getCwd = (id: string): Promise => this.providerFor(id).getCwd(id) - async getInitialCwd(id: string): Promise { - return this.providerFor(id).getInitialCwd(id) - } + getInitialCwd = (id: string): Promise => this.providerFor(id).getInitialCwd(id) async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { return (await this.providerFor(id).getAppliedSize?.(id)) ?? null diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index aa7b9fda956e..f144671f0feb 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -223,3 +223,42 @@ describe('concurrent createOrAttach across the async spawn', () => { expect(host.listSessions()).toEqual([]) }) }) + +describe('a switch start owned by the terminal host', () => { + it('holds its generation from before spawn until its session exits, and never reuses it', async () => { + let releaseSpawn: () => void = () => {} + const gate = new Promise((resolve) => { + releaseSpawn = resolve + }) + const proc = mockSubprocess() + const host = new TerminalHost({ + spawnSubprocess: vi.fn(async () => { + await gate + return proc + }) + }) + const { owner } = host.switches + const start = (sessionId: string, generation: string) => + host.createOrAttach({ + ...createOptions(sessionId), + switchGeneration: generation, + switchOwner: owner + }) + const seen = (generation: string, fence = false) => + host.switches.observe(generation, fence).observation + + const created = start('switch-1', 'g1') + await vi.waitFor(() => expect(seen('g1')).toEqual({ state: 'pending' })) + // A lost reply's retry names the same generation and never starts a second terminal. + await expect(start('switch-2', 'g1')).rejects.toThrow('terminal_switch_generation_refused') + releaseSpawn() + await created + expect(seen('g1', true)).toMatchObject({ state: 'bound', ptyId: 'switch-1', shellPid: 4242 }) + proc.emitExit(0) + await vi.waitFor(() => expect(seen('g1', true)).toEqual({ state: 'ended' })) + // An observation fences an unseen generation, so its late create refuses. + expect(seen('g2', true)).toEqual({ state: 'ended' }) + await expect(start('switch-3', 'g2')).rejects.toThrow('terminal_switch_generation_refused') + await host.dispose() + }) +}) diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 9f6e6fbec1b1..3a17a9c86106 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -35,6 +35,8 @@ export type CreateOrAttachOptions = { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } + switchGeneration?: string + switchOwner?: string streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index ed5a06372c24..e9f88329e475 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,4 +1,5 @@ import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' +import type { TerminalHostSwitches } from './terminal-host-switches' import { discardStagedStartupCommand, stageStartupCommand, @@ -32,6 +33,7 @@ type TerminalHostSessionCreateDependencies = { killedTombstones: TerminalHostTombstones spawnSubprocess: TerminalHostOptions['spawnSubprocess'] spawnGate: TerminalSpawnGate | undefined + switches?: TerminalHostSwitches onDeadSessionRemoved: (sessionId: string) => void onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void onSessionExit: (sessionId: string, generation: string | undefined) => void @@ -151,23 +153,31 @@ async function spawnAndPublishSession( // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. const cwdReadableByDaemon = opts.cwd && !wslDistro ? await isCwdReadableByThisProcess(opts.cwd) : null - const subprocess = await deps.spawnSubprocess({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - shellOverride: opts.shellOverride, - terminalShellArgs: opts.terminalShellArgs, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, - isCanceled: opts.isCanceled, - ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) - }) + deps.switches?.begin(opts.switchGeneration, opts.switchOwner) + let subprocess: Awaited> + try { + subprocess = await deps.spawnSubprocess({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + cwd: opts.cwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + shellOverride: opts.shellOverride, + terminalShellArgs: opts.terminalShellArgs, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + isCanceled: opts.isCanceled, + ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) + }) + } catch (error) { + // A spawn that threw left no subprocess, so nothing can start under this generation. + deps.switches?.endUnstarted(opts.switchGeneration) + throw error + } let staging: StartupCommandStaging | undefined // Why: a fallback shell does not emit the preferred shell's ready marker; @@ -204,6 +214,8 @@ async function spawnAndPublishSession( ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } : {}) }) + // From here only this session's own end ends the switch, canceled or not. + deps.switches?.bind(opts.switchGeneration, session) if (opts.isCanceled?.()) { // Retain cleanup ownership if the native child refuses to exit. diff --git a/src/main/daemon/terminal-host-switches.ts b/src/main/daemon/terminal-host-switches.ts new file mode 100644 index 000000000000..db8ba8c58663 --- /dev/null +++ b/src/main/daemon/terminal-host-switches.ts @@ -0,0 +1,63 @@ +// The daemon's ownership of chat → terminal switch starts. A generation is held from before its +// subprocess spawns, bound to the session the moment it exists, and only that session ends it. +import { randomUUID } from 'node:crypto' +import type { TerminalSwitchOwnerAnswer } from '../../shared/terminal-switch' +import type { Session } from './session' + +type HostSwitch = { state: 'pending' | 'ended' } | { state: 'bound'; session: Session } + +export class TerminalHostSwitches { + readonly owner = `daemon:${randomUUID()}` + private readonly entries = new Map() + + /** Before the subprocess: a foreign owner or an already used or fenced generation refuses. */ + begin(generation: string | undefined, owner: string | undefined): void { + if (generation === undefined) { + return + } + if (owner !== this.owner || this.entries.has(generation)) { + throw new Error('terminal_switch_generation_refused') + } + this.entries.set(generation, { state: 'pending' }) + } + + bind(generation: string | undefined, session: Session): void { + if (generation !== undefined) { + this.entries.set(generation, { state: 'bound', session }) + } + } + + /** The subprocess never spawned, so nothing can start or deliver under this generation. */ + endUnstarted(generation: string | undefined): void { + if (generation !== undefined && this.entries.get(generation)?.state === 'pending') { + this.entries.set(generation, { state: 'ended' }) + } + } + + /** An unseen generation is fenced, so a late create carrying it refuses and absence means no start. */ + observe(generation: string, fence: boolean): TerminalSwitchOwnerAnswer { + const entry: HostSwitch = this.entries.get(generation) ?? { + state: fence ? 'ended' : 'pending' + } + if (fence) { + this.entries.set(generation, entry) + } + if (entry.state !== 'bound') { + return { owner: this.owner, observation: { state: entry.state } } + } + const { session } = entry + return { + owner: this.owner, + observation: session.isAlive + ? { + state: 'bound', + ptyId: session.sessionId, + incarnationId: session.incarnationId, + shellPid: session.pid > 0 ? session.pid : null, + // The startup command is queued behind the shell-ready barrier until it settles. + startupSettled: session.shellState !== 'pending' + } + : { state: 'ended' } + } + } +} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 9a6f5c21c9a8..6588ff785b61 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,4 +1,5 @@ import type { Session } from './session' +import { TerminalHostSwitches } from './terminal-host-switches' import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' import { SessionNotFoundError } from './types' import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' @@ -51,6 +52,7 @@ export class TerminalHost { private disposePromise: Promise | null = null private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations() + readonly switches = new TerminalHostSwitches() private readonly authorityGeneration = randomUUID() private observationEpoch = 0 private readonly retiredIncarnations = new Map< @@ -81,13 +83,8 @@ export class TerminalHost { } this.assertCreateOrAttachAllowed(opts) - let settleCreation: () => void = () => {} - this.pendingCreations.set( - opts.sessionId, - new Promise((resolve) => { - settleCreation = resolve - }) - ) + const creation = Promise.withResolvers() + this.pendingCreations.set(opts.sessionId, creation.promise) try { return await createOrAttachClaimedAgentSession({ options: opts, @@ -109,6 +106,7 @@ export class TerminalHost { killedTombstones: this.killedTombstones, spawnSubprocess: this.spawnOptions.spawnSubprocess, spawnGate: this.spawnOptions.spawnGate, + switches: this.switches, onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), onSessionCreated: (sessionId, generation, isAlive) => this.agentSessionGenerations.remember(sessionId, generation, isAlive), @@ -121,7 +119,7 @@ export class TerminalHost { }) } finally { this.pendingCreations.delete(opts.sessionId) - settleCreation() + creation.resolve() } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 080c474ab033..05cee6524c64 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -107,6 +107,9 @@ export type CreateOrAttachRequest = { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } + /** A switch start; sent only to a daemon that answered `observeTerminalSwitch`. */ + switchGeneration?: string + switchOwner?: string } } @@ -324,6 +327,7 @@ export type DaemonRequest = | TakePendingOutputRequest | PtyOwnerQueryProtocol.CloseStartupQueryAuthorityRequest | PtyOwnerQueryProtocol.SetColorQueryReplyColorsRequest + | PtyOwnerQueryProtocol.ObserveTerminalSwitchRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── diff --git a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts index 0046f85bde3f..1d9ccc79c36d 100644 --- a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts +++ b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts @@ -10,10 +10,8 @@ import type { RefusedPaneResume } from '../../../../shared/agent-status-types' import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import type { - SwitchClaim, - SwitchSpawnOutcome -} from '../../../runtime/session-tabs/structured-session-switch-claims' +import type { SwitchClaim } from '../../../runtime/session-tabs/structured-session-switch-claims' +import { TERMINAL_SWITCH_GENERATION_ENV } from '../../../../shared/terminal-switch' import { agentSessionLeasePossiblyWriting, listStructuredProviderSessionOwnership @@ -42,13 +40,16 @@ export type AdmissionRuntime = { paneKey: string ): Promise openAgentSessionRecordStore(): Promise - acceptSwitchStart?( - agent: string, - id: string, - attempt: string, - sessionId?: string - ): SwitchClaim | null - settleSwitchAttempt?(attempt: string, outcome: SwitchSpawnOutcome): void + acceptSwitchStart?(generation: string, paneKey: string): SwitchClaim | null + saveSwitchClaim?(claim: SwitchClaim): Promise + dispatchSwitchStart?(claim: SwitchClaim): { switchGeneration: string; switchOwner: string } +} + +/** The spawn options a switch admission reads its generation from and hands its owner fields to. */ +export type SwitchSpawnOptions = { + env?: Record + switchGeneration?: string + switchOwner?: string } /** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: @@ -115,31 +116,37 @@ export async function judgeResumes( return admitted() } -/** The terminal half of a chat's switch: refused into a plain shell where Orca can't judge the pane, - * or while a chat may be writing the conversation; otherwise its claim settles with this spawn. */ +/** The terminal half of a chat's switch. Its generation takes this pane and is saved on the chat + * before the owner gets it; a refusal sends nothing, and only the owner then says it never started. */ export async function admitSwitchResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, wslDistro: string | null, - spawn?: { attempt?: string; sessionId?: string } + spawn?: { paneKey: string | null; options: SwitchSpawnOptions } ): Promise { + const generation = args.env?.[TERMINAL_SWITCH_GENERATION_ENV] + delete args.env?.[TERMINAL_SWITCH_GENERATION_ENV] + delete spawn?.options.env?.[TERMINAL_SWITCH_GENERATION_ENV] const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent if (!providerSession || !agent) { throw new Error('switch_start_missing_conversation') } const refused = { agent, providerSession } - const attempt = spawn?.attempt - if (args.connectionId || wslDistro !== null || !runtime?.acceptSwitchStart || !attempt) { + if (args.connectionId || wslDistro !== null || !generation || !spawn?.paneKey) { return { verdict: 'unverifiable', refused } } // Before any await: a chat start whose census ends after this point sees the claim. - if (!runtime.acceptSwitchStart(agent, providerSession.id, attempt, spawn.sessionId)) { + const claim = runtime?.acceptSwitchStart?.(generation, spawn.paneKey) + if (!claim || claim.providerSessionId !== providerSession.id) { return { verdict: 'chat-writing', refused } } + if (!(await runtime?.saveSwitchClaim?.(claim))) { + return { verdict: 'unverifiable', refused } + } const admission = await judgeResumes(runtime, [refused], true) - if (admission.verdict !== 'admitted') { - runtime.settleSwitchAttempt?.(attempt, 'not-started') + if (admission.verdict === 'admitted' && runtime?.dispatchSwitchStart) { + Object.assign(spawn.options, runtime.dispatchSwitchStart(claim)) } return admission } diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index b4f09a31fd06..4a3ff4715321 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -7,14 +7,14 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-type import { agentHookServer } from '../../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import type { SwitchSpawnOutcome } from '../../../runtime/session-tabs/structured-session-switch-claims' import { admitSwitchResumeSpawn, judgeResumes, type AdmissionRuntime, type ResumeSpawnAdmission, type ResumeSpawnArgs, - type ResumeSpawnRefusal + type ResumeSpawnRefusal, + type SwitchSpawnOptions } from './resume-spawn-judgement' export type { @@ -28,7 +28,7 @@ export async function admitResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, wslDistro: string | null, - spawn?: { attempt?: string; sessionId?: string } + spawn?: { paneKey: string | null; options: SwitchSpawnOptions } ): Promise { if (args.resumeOrigin === 'switch') { return admitSwitchResumeSpawn(runtime, args, wslDistro, spawn) @@ -107,17 +107,6 @@ export function dropAgentLaunch(args: ResumeSpawnArgs): void { delete args.resumeProviderSession } -/** Every exit of a switch spawn settles its attempt's claim; `admitSwitchResumeSpawn` explains how. */ -export function settleSwitchSpawn( - runtime: AdmissionRuntime | undefined, - attempt: string | undefined, - outcome: SwitchSpawnOutcome -): void { - if (attempt) { - runtime?.settleSwitchAttempt?.(attempt, outcome) - } -} - export function resumeSpawnRefusal( admission: ResumeSpawnAdmission | null ): ResumeSpawnRefusal | null { @@ -161,24 +150,20 @@ export function savedResumeRecord( : undefined } -/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. A switch's claim - * is owned by this spawn attempt: its host session id, else its pane. */ +/** Runs only in the fresh branch, after stable/claimed owner adoption has settled. */ export async function chooseSavedResumeFreshLaunch( spawn: { deps: { runtime?: AdmissionRuntime } expectedWslDistro: string | null - spawnOptions: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } - effectiveSessionAppId: string | undefined - switchAttempt?: string + spawnOptions: ResumeSpawnArgs & + SwitchSpawnOptions & { startupCommandDelivery?: unknown; startupIngress?: unknown } }, args: ResumeSpawnArgs, paneKey: string | null ): Promise { - const sessionId = spawn.effectiveSessionAppId - spawn.switchAttempt = sessionId ?? paneKey ?? undefined const admission = await admitResumeSpawn(spawn.deps.runtime, args, spawn.expectedWslDistro, { - attempt: spawn.switchAttempt, - sessionId + paneKey, + options: spawn.spawnOptions }) if (resumeSpawnRefusal(admission)) { suppressSavedResumeLaunch(args, spawn.spawnOptions) diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 318915331061..789d1047484c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -177,7 +177,8 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise | undefined effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined diff --git a/src/main/ipc/pty/pane/resume-launch-identity.test.ts b/src/main/ipc/pty/pane/resume-launch-identity.test.ts index 782118dfc5a0..f6cd27f3612b 100644 --- a/src/main/ipc/pty/pane/resume-launch-identity.test.ts +++ b/src/main/ipc/pty/pane/resume-launch-identity.test.ts @@ -42,6 +42,13 @@ describe('seedResumeLaunchIdentityFromSpawn', () => { }) }) + it('names the exact execution only for a switch start', () => { + seed({ switchGeneration: 'g1', result: { id: 'pty-1', incarnationId: 'i1' } }) + expect(recordLaunchResumeIdentity).toHaveBeenCalledWith( + expect.objectContaining({ execution: { ptyId: 'pty-1', incarnationId: 'i1' } }) + ) + }) + it.each([ ['a reattached process', { result: { id: 'pty-1', isReattach: true } }], ['a Codex resume that fell back to a fresh session', { resumeArgvDropped: true }], diff --git a/src/main/ipc/pty/pane/resume-launch-identity.ts b/src/main/ipc/pty/pane/resume-launch-identity.ts index ab0945d0fdbb..0757be7c7ca1 100644 --- a/src/main/ipc/pty/pane/resume-launch-identity.ts +++ b/src/main/ipc/pty/pane/resume-launch-identity.ts @@ -4,6 +4,7 @@ import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' +import type { TerminalExecutionStamp } from '../../../../shared/terminal-switch' /** * Record a fresh resume launch's provider session in the hook store, so every reader (phone tab @@ -20,6 +21,8 @@ export function seedResumeLaunchIdentityFromSpawn(args: { /** The Codex resume fell back to a fresh session, so the pane does not hold this one. */ resumeArgvDropped: boolean result: PtySpawnResult + /** Set on a chat → terminal switch start: its row then names the exact execution. */ + switchGeneration?: string }): void { // Why: a reattached process already reported its own identity; an SSH pane's rows belong to // its relay, which owns that host's status. @@ -32,10 +35,12 @@ export function seedResumeLaunchIdentityFromSpawn(args: { ) { return } + const { id: ptyId, incarnationId } = args.result seedResumeLaunchIdentity({ ...args, paneKey: args.paneKey, - launchedAt: args.launchedAt.getTime() + launchedAt: args.launchedAt.getTime(), + ...(args.switchGeneration && incarnationId ? { execution: { ptyId, incarnationId } } : {}) }) } @@ -49,6 +54,7 @@ export function seedResumeLaunchIdentity(args: { resumeProviderSession: unknown worktreeId: unknown launchedAt: number + execution?: TerminalExecutionStamp }): void { const agent = args.launchAgent const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) @@ -64,6 +70,7 @@ export function seedResumeLaunchIdentity(args: { ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), agentType: agent, providerSession, - launchedAt: args.launchedAt + launchedAt: args.launchedAt, + ...(args.execution ? { execution: args.execution } : {}) }) } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index f80e78897bf9..52bb65941796 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -1,4 +1,6 @@ -import { localProvider } from '../provider/registry' +import { getProvider, localProvider } from '../provider/registry' +import { routedTerminalSwitchOwners } from '../../../providers/terminal-switch-owners' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { getLocalAutomaticWorkStatus, resumeLocalAutomaticWork @@ -74,6 +76,11 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), + // Resolved per call: the local route can move between the daemon and its in-process fallback. + terminalSwitches: routedTerminalSwitchOwners( + () => getProvider(LOCAL_EXECUTION_HOST_ID), + () => [getProvider(LOCAL_EXECUTION_HOST_ID)] + ), listProcesses: (hostId, opts) => listProcessesFromRuntimeController(deps, hostId, opts), listProcessesWithHostScope: (opts) => listProcessesWithHostScopeFromRuntimeController(deps, opts), diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 8f8bfef1b120..12f4cf4cd903 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -244,7 +244,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { worktreeId: args.worktreeId, launchedAt: ctx.codexHomeLaunchStartedAt, resumeArgvDropped: ctx.codexResumeArgvDropped, - result: ctx.result + result: ctx.result, + switchGeneration: ctx.spawnOptions.switchGeneration }) // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 9473741d3a1a..5ca761ba57ab 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -44,8 +44,6 @@ export type RuntimePtySpawnState = { requestedSessionId: string | undefined sessionId: string | undefined effectiveSessionAppId: string | undefined - /** A switch spawn's claim identity, kept apart from the admission a reattach clears. */ - switchAttempt?: string isNewDaemonSession: boolean expectedWslDistro: string | null /** The host's verdict on the spawn's resume; a refused one starts the pane's plain shell. */ diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 90e82e81ea0a..e75366b4adc6 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -4,8 +4,7 @@ import { savedResumeRecord, savedResumeResult, resumeSpawnRefusal, - forgetRefusedResume, - settleSwitchSpawn + forgetRefusedResume } from '../host-env/saved-resume-admission' import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' @@ -133,13 +132,8 @@ export async function spawnPtyFromRuntimeController( if (!ctx.result.isReattach) { deps.runtime?.rememberSavedTerminalLaunchResult?.(committed.id, committed) } - settleSwitchSpawn(deps.runtime, ctx.switchAttempt, { ptyId: committed.id }) return committed } catch (err) { - settleSwitchSpawn(deps.runtime, ctx.switchAttempt, { - // A daemon session may exist even with no reply; an in-process spawn that returned none made none. - unknown: ctx.result.id || ctx.effectiveSessionAppId - }) releaseRuntimeSpawnPreSpawnHiddenMark(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index a6eb775f461f..80b34937a253 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -610,5 +610,24 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledOnce() expect(committed).toHaveBeenCalledOnce() }) + + it('keeps a switch start bound to its process past a failed publication, until it exits', async () => { + spawnMock.mockClear() + const switchOwner = (await provider.terminalSwitches.owner()) ?? '' + const seen = async () => (await provider.terminalSwitches.observe('g-local', true))[0] + provider.configure({ + onSpawned: () => { + throw new Error('post-spawn publication failed') + } + }) + const start = () => + provider.spawn({ cols: 80, rows: 24, switchGeneration: 'g-local', switchOwner }) + await expect(start()).rejects.toThrow('post-spawn publication failed') + expect((await seen())?.observation).toMatchObject({ state: 'bound', shellPid: mockProc.pid }) + await expect(start()).rejects.toThrow('terminal_switch_generation_refused') + expect(spawnMock).toHaveBeenCalledOnce() + exitCb?.({ exitCode: 0 }) + expect((await seen())?.observation).toEqual({ state: 'ended' }) + }) }) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index a0111670bfd9..6241d0aff92f 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -53,6 +53,7 @@ import { } from './local-pty-session-operations' import { spawnLocalPty } from './local-pty-spawn' import { cancelAllPendingLocalPtySpawns } from './local-pty-spawn-state' +import { localTerminalSwitchOwner, observeLocalTerminalSwitch } from './local-pty-terminal-switch' import { killAllLocalPtys, killOrphanedLocalPtys, shutdownLocalPty } from './local-pty-termination' export type { LocalPtyProviderOptions } from './local-pty-provider-types' @@ -82,6 +83,10 @@ export class LocalPtyProvider implements IPtyProvider { spawn(args: PtySpawnOptions): Promise { return spawnLocalPty(args, () => this.opts) } + readonly terminalSwitches = { + owner: async () => localTerminalSwitchOwner(), + observe: observeLocalTerminalSwitch + } // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. async attach(_id: string): Promise {} diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index e7782b34bb8d..cdea1a8f881b 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -1,4 +1,5 @@ import type * as pty from 'node-pty' +import { settleLocalTerminalSwitchStartup } from './local-pty-terminal-switch' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { discardStagedStartupCommand, @@ -206,9 +207,12 @@ export function activateLocalPtySession(args: { readiness.setStartupCommandCleanup(cleanup) }, { - bracketedPasteSafe + bracketedPasteSafe, + onSettled: () => settleLocalTerminalSwitchStartup(id) } ) + } else { + settleLocalTerminalSwitchStartup(id) } // Why: publish the OS pid for the memory collector; proc.pid can be briefly 0/undefined before node-pty sees the child. diff --git a/src/main/providers/local-pty-shell-ready-startup-command.ts b/src/main/providers/local-pty-shell-ready-startup-command.ts index 5fefb5db5fac..71b3b38c34cd 100644 --- a/src/main/providers/local-pty-shell-ready-startup-command.ts +++ b/src/main/providers/local-pty-shell-ready-startup-command.ts @@ -18,13 +18,16 @@ export function writeStartupCommandWhenShellReady( startupCommand: string, onExit: (cleanup: () => void) => void, // Why: only shells with bracketed-paste active (see isBracketedPasteSafeShell) accept the wrapper; others use the raw path so ESC[200~ isn't echoed. - options: { bracketedPasteSafe?: boolean } = {} + options: { bracketedPasteSafe?: boolean; onSettled?: () => void } = {} ): void { let sent = false let postReadyTimer: ReturnType | null = null let postReadyDataDisposable: { dispose: () => void } | null = null const cleanup = (): void => { + if (!sent) { + options.onSettled?.() + } sent = true if (postReadyTimer !== null) { clearTimeout(postReadyTimer) @@ -52,6 +55,7 @@ export function writeStartupCommandWhenShellReady( bracketedPasteSafe: options.bracketedPasteSafe === true }) ) + options.onSettled?.() } const schedulePostReadyFlush = (): void => { diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 50e01861b8a2..f8e6e75053e3 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -1,4 +1,9 @@ import { randomUUID } from 'node:crypto' +import { + beginLocalTerminalSwitch, + commitLocalTerminalSwitch, + endUnstartedLocalTerminalSwitch +} from './local-pty-terminal-switch' import { win32 as pathWin32 } from 'node:path' import { mkdir } from 'node:fs/promises' import { JCODE_RUNTIME_DIR_ENV_KEY } from '../../shared/jcode-runtime-dir' @@ -56,7 +61,9 @@ export async function spawnLocalPty( } } const id = allocatePtyId(reattachId ?? undefined) - return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { + beginLocalTerminalSwitch(args.switchGeneration, args.switchOwner) + let committed = false + const spawned = runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { const incarnationId = randomUUID() let plan = createLocalPtyLaunchPlan(args, getOptions) if (plan instanceof DeferredLocalPtyLaunchPlan) { @@ -137,6 +144,12 @@ export async function spawnLocalPty( throw error } args.onPtySpawnCommitted?.() + committed = true + commitLocalTerminalSwitch(args.switchGeneration, { + id, + incarnationId, + proc: spawnResult.process + }) plan.shellPath = spawnResult.shellPath // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { @@ -172,4 +185,10 @@ export async function spawnLocalPty( }) }) }) + // A spawn that never committed a process (thrown, canceled, or joined a winner) started nothing. + return spawned.finally(() => { + if (!committed) { + endUnstartedLocalTerminalSwitch(args.switchGeneration) + } + }) } diff --git a/src/main/providers/local-pty-terminal-switch.ts b/src/main/providers/local-pty-terminal-switch.ts new file mode 100644 index 000000000000..2981f8ad66bd --- /dev/null +++ b/src/main/providers/local-pty-terminal-switch.ts @@ -0,0 +1,101 @@ +// The in-process owner of a chat → terminal switch start. A generation is bound before any physical +// work and to the process the moment it exists, so a publication that throws later cannot hide it. +import { randomUUID } from 'node:crypto' +import type { IPty } from 'node-pty' +import type { TerminalSwitchOwnerAnswer } from '../../shared/terminal-switch' + +const OWNER = `local:${randomUUID()}` + +type LocalSwitch = + | { state: 'pending' | 'ended' } + | { + state: 'bound' + id: string + incarnationId: string + pid: number + exited: boolean + startupSettled: boolean + } + +const switches = new Map() + +export function localTerminalSwitchOwner(): string { + return OWNER +} + +/** Before any physical work: a foreign owner or an already used or fenced generation refuses. */ +export function beginLocalTerminalSwitch(generation: string | undefined, owner?: string): void { + if (generation === undefined) { + return + } + if (owner !== OWNER || switches.has(generation)) { + throw new Error('terminal_switch_generation_refused') + } + switches.set(generation, { state: 'pending' }) +} + +/** The process exists: from here only its own exit ends the switch. */ +export function commitLocalTerminalSwitch( + generation: string | undefined, + execution: { id: string; incarnationId: string; proc: IPty } +): void { + if (generation === undefined) { + return + } + const bound: LocalSwitch = { + state: 'bound', + id: execution.id, + incarnationId: execution.incarnationId, + pid: execution.proc.pid, + exited: false, + startupSettled: false + } + switches.set(generation, bound) + execution.proc.onExit(() => { + bound.exited = true + }) +} + +/** A start that threw before its process existed started nothing. */ +export function endUnstartedLocalTerminalSwitch(generation: string | undefined): void { + if (generation !== undefined && switches.get(generation)?.state === 'pending') { + switches.set(generation, { state: 'ended' }) + } +} + +/** The startup command was written, revoked, or never owed: nothing can still deliver it. */ +export function settleLocalTerminalSwitchStartup(id: string): void { + for (const entry of switches.values()) { + if (entry.state === 'bound' && entry.id === id) { + entry.startupSettled = true + } + } +} + +/** An unseen generation is fenced, so a late start carrying it refuses and absence means no start. */ +export async function observeLocalTerminalSwitch( + generation: string, + fence: boolean +): Promise { + const entry: LocalSwitch = switches.get(generation) ?? { state: fence ? 'ended' : 'pending' } + if (fence) { + switches.set(generation, entry) + } + const observation = + entry.state !== 'bound' + ? { state: entry.state } + : entry.exited + ? { state: 'ended' as const } + : { + state: 'bound' as const, + ptyId: entry.id, + incarnationId: entry.incarnationId, + shellPid: entry.pid > 0 ? entry.pid : null, + startupSettled: entry.startupSettled + } + return [{ owner: OWNER, observation }] +} + +export function resetLocalTerminalSwitchesForTest(): void { + switches.clear() +} diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index cd68eb5df862..53522f753b4c 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,4 +1,5 @@ import type { SpawnIntent } from '../../shared/host-spawn-admission' +import type { TerminalSwitchOwners } from '../../shared/terminal-switch' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentWorkspaceTrustSpawnRequest } from '../../shared/agent-workspace-trust-spawn-request' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' @@ -118,6 +119,10 @@ export type PtySpawnOptions = { onPtySpawnCommitted?: () => void /** Cancels only before physical dispatch; operation identity fences later ambiguity. */ signal?: AbortSignal + /** A chat → terminal switch's start: the owner binds this generation to this one fresh spawn and + * refuses it when `switchOwner` names another owner or the generation was already used. */ + switchGeneration?: string + switchOwner?: string } export type { PtyProcessInfo, PtySpawnResult } @@ -137,6 +142,8 @@ export type IPtyProvider = { /** Re-probes a degraded durable host before main commits to fallback spawn semantics. */ recoverFreshSpawnRouting?: () => Promise spawn(opts: PtySpawnOptions): Promise + /** Present only where an owner can hold and observe a chat → terminal switch start. */ + terminalSwitches?: TerminalSwitchOwners /** Process-owner cleanup for history stored outside the workspace tree. */ deleteWorktreeHistory?: (worktreeId: string) => Promise /** Whether this spawn target can append the Git guard after its final env merge. */ diff --git a/src/main/providers/terminal-switch-owners.ts b/src/main/providers/terminal-switch-owners.ts new file mode 100644 index 000000000000..37207763bd5a --- /dev/null +++ b/src/main/providers/terminal-switch-owners.ts @@ -0,0 +1,22 @@ +import type { TerminalSwitchOwners } from '../../shared/terminal-switch' + +type Reach = { terminalSwitches?: TerminalSwitchOwners } + +/** A routing provider's owners: fresh starts go to one; any of them may hold an older start. */ +export function routedTerminalSwitchOwners( + fresh: () => Reach, + all: () => readonly Reach[] +): TerminalSwitchOwners { + return { + owner: async () => (await fresh().terminalSwitches?.owner()) ?? null, + // An owner that cannot answer is left out, so the caller reads the start as unverifiable. + observe: async (generation, fence) => + ( + await Promise.all( + all().map( + (owner) => owner.terminalSwitches?.observe(generation, fence).catch(() => []) ?? [] + ) + ) + ).flat() + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 849096dfceae..174d66f774bd 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,6 +1,8 @@ /** Durable single-writer session records and their operation ledger, as rows in the host's chat * journal database. */ +import { setAgentSessionRecordTerminalSwitch } from './agent-session-record-terminal-switch' +import type { TerminalSwitchWrite } from '../../shared/terminal-switch' import { backfillAgentSessionPermissionModes } from './agent-session-permission-backfill' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts' @@ -181,6 +183,9 @@ export class AgentSessionRecordStore { compareAndSetConversationName: CompareAndSetConversationName = (sessionId, name, expected) => compareAndSetAgentSessionRecordName((apply) => this.mutate(sessionId, apply), name, expected) + setTerminalSwitch = (sessionId: string, write: TerminalSwitchWrite) => + this.mutate(sessionId, (r) => setAgentSessionRecordTerminalSwitch(r, write, Date.now())) + /** Unfenced like the name: it records where a launch ran and never contends with the lease. */ pinLaunchDirectory = (sessionId: string, launchDirectory: string): Promise => this.mutate(sessionId, (record) => diff --git a/src/main/runtime/agent-session-record-terminal-switch.ts b/src/main/runtime/agent-session-record-terminal-switch.ts new file mode 100644 index 000000000000..8303517df198 --- /dev/null +++ b/src/main/runtime/agent-session-record-terminal-switch.ts @@ -0,0 +1,20 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { TerminalSwitchWrite } from '../../shared/terminal-switch' + +/** Unfenced like the name: the descriptor is the switch owner's obligation, not the writer lease. + * A clear names its generation, so an old completion never clears a newer switch. */ +export function setAgentSessionRecordTerminalSwitch( + record: AgentSessionRecord, + descriptor: TerminalSwitchWrite, + now: number +): AgentSessionRecord { + if ('clear' in descriptor) { + if (record.terminalSwitch?.generation !== descriptor.clear) { + return record + } + const next = { ...record, updatedAt: now } + delete next.terminalSwitch + return next + } + return { ...record, terminalSwitch: descriptor, updatedAt: now } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts index d3ece1c07326..5c6680d970c2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts @@ -1,9 +1,10 @@ // `agentSession.switchToTerminal` — the chat's half of moving its conversation into a terminal on -// the same id. In the chat's ordered step it refuses unless idle, registers the switch's claim, -// stops the agent with proven exit while the tab stays, and answers the facts a terminal resume -// needs. The claim refuses a chat start from any view until the terminal it names can be judged. +// the same id. Before any stop it checks the route can hold the start and prepares the recipe; then, +// in the chat's ordered step, it refuses unless idle, registers one switch generation and stops the +// agent with proven exit. Only that generation's terminal owner can later end the switch. // Gated per agent by `agent-session.structured.switch.v1:`; a client never falls back to close. +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' import type { AiVaultChatForkSource } from '../../../../shared/ai-vault-resume-preparation' import { @@ -24,7 +25,10 @@ export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ name: 'agentSession.switchToTerminal', permission: 'workspace', params: OptionsParams, - handler: async (params, ctx): Promise<{ source: AiVaultChatForkSource }> => { + handler: async ( + params, + ctx + ): Promise<{ source: AiVaultChatForkSource; generation: string }> => { requireStructuredCapability(ctx) await ensureStructuredHostInstalled(ctx) const host = requireStructuredSessionHost(ctx, params.sessionId) @@ -32,27 +36,41 @@ export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ if (!record || !inPlaceSwitchEnabled(record.provider)) { throw new Error('This chat cannot switch to a terminal on this host.') } - // Reads only: a chat that can't be resumed in a terminal is refused before anything stops. + // Before anything stops: the source, a route that can hold the start, and its recipe. const source = await resolveStructuredChatCliSource( { sessionId: record.sessionId, workspaceId: record.location.workspaceId }, false ) - // A switch terminal whose agent provably ended no longer holds the conversation. + const owner = await ctx.runtime.terminalSwitchOwner() + if (!owner) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + const prepared = await prepareStructuredChatCliTranscript( + source, + (args) => ctx.runtime.prepareAiVaultSessionResume(args), + false + ) + if (!prepared.cliForkSource) { + throw new Error('This chat has no saved conversation available on this host.') + } + // A switch whose owner proved its start over no longer holds the conversation. await ctx.runtime.releaseEndedSwitchStarts(source.agent, source.providerSessionId) let claim: SwitchClaim | undefined try { await host.switchToTerminal(params.sessionId, () => { - claim = ctx.runtime.registerSwitchClaim(source.agent, source.providerSessionId) + claim = ctx.runtime.registerSwitchClaim( + source.agent, + source.providerSessionId, + record.sessionId, + owner + ) }) - const prepared = await prepareStructuredChatCliTranscript( - source, - (args) => ctx.runtime.prepareAiVaultSessionResume(args), - false - ) - if (!prepared.cliForkSource) { - throw new Error('This chat has no saved conversation available on this host.') + if (!claim) { + throw new Error('This chat could not switch to a terminal.') } - return { source: prepared.cliForkSource } + return { source: prepared.cliForkSource, generation: claim.generation } } catch (error) { // No recipe left this host, so nothing can start under the claim. if (claim) { diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 36093a85c70a..7183210b21c3 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -1,4 +1,5 @@ import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' +import type { TerminalSwitchOwners } from '../../shared/terminal-switch' import type { AutomaticWorkResumeOutcome, AutomaticWorkStatus, @@ -149,6 +150,8 @@ export type RuntimePtyController = { resize?(ptyId: string, cols: number, rows: number): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null + /** This machine's terminal owners, for a chat → terminal switch start. */ + terminalSwitches?: TerminalSwitchOwners /** Omitting the host lists every registered host. */ listProcesses?( hostId?: ExecutionHostId, diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 3bc5ba90cc66..2e1fd1a1fc96 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -16,7 +16,6 @@ import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' import type { TerminalTailWaitState } from './terminal-wait-tail-state' import type { TerminalCommandPaint } from './terminal-command-paint' import type { CliWriterConversation } from './structured-agent-cli-writers' -import type { SwitchStartProof } from './session-tabs/structured-session-switch-claims' import type { PtyShellOwnershipMirror } from './pty-shell-ownership-mirror' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' @@ -82,8 +81,6 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { incarnationId: PtyIncarnationId | null byKey: Map } - /** The switch's own start of a conversation in this execution; ends only on an observed exit. */ - switchStart?: SwitchStartProof foregroundAgent: TerminalAgent | null connected: boolean disconnectedAt: number | null diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 8b8a8be6c98a..677355129b6e 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -4,9 +4,12 @@ import { } from '../../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { OrcaRuntimeWithStructuredSessionSwitchClaims } from './structured-session-switch-claims' import { switchStartProcessVerdict } from './structured-session-switch-observation' +import { + OrcaRuntimeWithStructuredSessionSwitchClaims, + switchHeldRefusal, + switchMovingRefusal +} from './structured-session-switch-claims' import { getProviderForPty } from '../../ipc/pty/provider/registry' import { LocalPtyProvider } from '../../providers/local-pty-provider' import { inspectPtyProviderProcess } from '../../providers/pty-process-inspection' @@ -132,7 +135,7 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca providerSessionId: string, source?: { workspaceId: string; paneKey: string } ): Promise { - await this.reconcileSwitchAttempts() + await this.loadSwitchClaims() this.assertNoPendingSwitch(agent, providerSessionId) await this.assertSwitchStartsEnded(agent, providerSessionId) await assertCliConversationWritersExited( @@ -179,31 +182,42 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca source && { paneKey: source.paneKey, worktreeId: source.workspaceId } ) // A switch spawn admitted during the census either registered first or read this chat's reservation. - this.assertNoPendingSwitch(agent, providerSessionId) + this.assertNoPendingSwitch(agent, providerSessionId, true) } async releaseEndedSwitchStarts(agent: string, id: string): Promise { - await this.reconcileSwitchAttempts() + await this.loadSwitchClaims() await this.assertSwitchStartsEnded(agent, id).catch(() => undefined) } - /** A switch's terminal holds its conversation until the agent it started there is proven gone. */ + /** A switch's start holds its conversation until its owner proves it over, or its startup has + * settled, every agent recorded there is proved exited and that exact shell is alone. */ private async assertSwitchStartsEnded(agent: string, id: string): Promise { - for (const { ptyId, proof } of this.boundSwitchStarts(agent, id)) { - const pane = this.cliWriterPtyPane(ptyId) - const verdicts = await Promise.all(proof.processes.map(switchStartProcessVerdict)) - if ( - !pane || - verdicts.length === 0 || - verdicts.some((verdict) => verdict !== 'exited') || - !(await this.cliWriterShellAlone(pane)) || - this.ptysById.get(ptyId)?.switchStart !== proof - ) { - throw agentSessionRefusalError('agent_session_conflict', { - reason: 'conversationHeldElsewhere' - }) + for (const claim of this.switchClaimsFor(agent, id)) { + if (!claim.accepted) { + throw switchMovingRefusal() + } + const seen = await this.observeSwitch(claim, true) + if (seen.state === 'bound') { + await this.noteSwitchBound(claim, seen) + } + const pane = seen.state === 'bound' ? this.cliWriterPtyPane(seen.ptyId) : null + const agentsEnded = + seen.state === 'bound' && + seen.startupSettled && + claim.processes.length > 0 && + pane?.incarnationId === seen.incarnationId && + (await Promise.all(claim.processes.map(switchStartProcessVerdict))).every( + (verdict) => verdict === 'exited' + ) && + (await this.cliWriterShellAlone(pane)) + if (seen.state === 'ended' || agentsEnded) { + await this.releaseSwitchClaim(claim) + continue } - delete this.ptysById.get(ptyId)?.switchStart + throw seen.state === 'pending' || (seen.state === 'bound' && !seen.startupSettled) + ? switchMovingRefusal() + : switchHeldRefusal() } } diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts index 03586a60123c..5f484e17ff90 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -1,11 +1,19 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import '../rpc/unused-default-rpc-methods.test-fixture' import { OrcaRuntimeService } from '../orca-runtime' import { admitSwitchResumeSpawn } from '../../ipc/pty/host-env/resume-spawn-judgement' +import type { AdmissionRuntime } from '../../ipc/pty/host-env/resume-spawn-judgement' +import { TerminalHostSwitches } from '../../daemon/terminal-host-switches' +import type { Session } from '../../daemon/session' import { switchStartProcessVerdict } from './structured-session-switch-observation' -import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import { codexChatHolding } from '../../ai-vault/structured-session-ownership.test-support' -import type { AdmissionRuntime } from '../../ipc/pty/host-env/resume-spawn-judgement' +import { + TERMINAL_SWITCH_GENERATION_ENV, + type TerminalSwitchDescriptor, + type TerminalSwitchWrite +} from '../../../shared/terminal-switch' +import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' +import type { SwitchClaim } from './structured-session-switch-claims' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -15,15 +23,25 @@ vi.mock('electron', () => ({ })) vi.mock('./structured-session-switch-observation', () => ({ readSwitchPaneAgentProcesses: vi.fn(async () => []), + readSwitchShellProcess: vi.fn(async (pid: number) => ({ + pid, + platform: 'linux', + startTime: 's' + })), switchStartProcessVerdict: vi.fn(async (): Promise => 'live') })) const C = 'thread-c' -const SPAWN = { attempt: 'pane-1' } const LEAF = '11111111-1111-4111-8111-111111111111' -const AGENT = { pid: 999_991, platform: process.platform, startTime: 'started' } as const +const PANE = `tab-1:${LEAF}` +const SHELL = { pid: 4242, platform: 'linux', startTime: 's' } as const +const AGENT = { pid: 999_991, platform: 'linux', startTime: 'started' } as const let runtime: OrcaRuntimeService +let host: TerminalHostSwitches let shellAlone: boolean +let ownerReachable: boolean +/** The chat records' saved switch descriptors, keyed by chat session id. */ +let saved: Map const chatStart = () => runtime.assertStructuredAdoptionTerminalWritersExited('codex', C) const reason = (promise: Promise) => @@ -31,22 +49,42 @@ const reason = (promise: Promise) => () => 'admitted', (error: { refusal?: { details?: { reason?: string } } }) => error.refusal?.details?.reason ) -function hook(process?: typeof AGENT): void { - runtime.recordCliWriterConversation({ - paneKey: `tab-1:${LEAF}`, - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - providerSession: { key: 'session_id', id: C }, - payload: { state: 'done', prompt: '', agentType: 'codex' }, - ...(process ? { agentPresence: { agent: 'codex', process } } : {}) - }) -} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner reads only these four session facts. +const session = (facts: Partial) => ({ ...facts }) as Session beforeEach(() => { shellAlone = true + ownerReachable = true + saved = new Map() + host = new TerminalHostSwitches() runtime = new OrcaRuntimeService() runtime.registerPty('pty-1', 'wt-1', null, { tabId: 'tab-1', leafId: LEAF, incarnationId: 'i1' }) + runtime.setPtyController({ + terminalSwitches: { + owner: async () => host.owner, + observe: async (generation, fence) => { + if (!ownerReachable) { + throw new Error('host unreachable') + } + return [host.observe(generation, fence)] + } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads only its owner contract. + } as never) + vi.spyOn(runtime, 'openAgentSessionRecordStore').mockResolvedValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads records and writes descriptors only. + { + listRecords: () => + [...saved].map(([sessionId, terminalSwitch]) => ({ sessionId, terminalSwitch })), + setTerminalSwitch: async (sessionId: string, write: TerminalSwitchWrite) => { + if (!('clear' in write)) { + saved.set(sessionId, write) + } else if (saved.get(sessionId)?.generation === write.clear) { + saved.delete(sessionId) + } + } + } as never + ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census's one process-table read, replaced by the pane's modeled state. vi.spyOn( runtime as never as { cliWriterShellAlone: () => Promise }, @@ -54,32 +92,51 @@ beforeEach(() => { ).mockImplementation(async () => shellAlone) vi.mocked(switchStartProcessVerdict).mockResolvedValue('live') }) -afterEach(() => vi.useRealTimers()) -describe('the switch claim', () => { - it('refuses a chat start until a spawn takes it; an accepted start never lapses', async () => { - vi.useFakeTimers() - runtime.registerSwitchClaim('codex', C) - expect(await reason(chatStart())).toBe('conversationMovingToTerminal') - vi.advanceTimersByTime(61_000) - expect(await reason(chatStart())).toBe('admitted') +async function acceptedSwitch(): Promise { + const claim = runtime.registerSwitchClaim('codex', C, 'chat-1', host.owner) + runtime.acceptSwitchStart(claim.generation, PANE) + await runtime.saveSwitchClaim(claim) + return claim +} - runtime.registerSwitchClaim('codex', C) - runtime.acceptSwitchStart('codex', C, 'pty-1', 'pty-1') - // A spawn slower than the window keeps refusing until it settles. - vi.advanceTimersByTime(120_000) +describe('a chat → terminal switch start', () => { + it('is held from the recipe until its owner proves the exact execution over', async () => { + const claim = runtime.registerSwitchClaim('codex', C, 'chat-1', host.owner) expect(await reason(chatStart())).toBe('conversationMovingToTerminal') - }) + expect(() => runtime.registerSwitchClaim('codex', C, 'chat-1', host.owner)).toThrow( + 'agent_session_conflict' + ) + expect(runtime.acceptSwitchStart(claim.generation, PANE)).toBe(claim) + // One pane takes a generation; a retry there joins it. + expect(runtime.acceptSwitchStart(claim.generation, 'tab-2:other')).toBeNull() + expect(runtime.acceptSwitchStart(claim.generation, PANE)).toBe(claim) + await runtime.saveSwitchClaim(claim) + host.begin(claim.generation, host.owner) + expect(await reason(chatStart())).toBe('conversationMovingToTerminal') + expect(() => host.begin(claim.generation, host.owner)).toThrow( + 'terminal_switch_generation_refused' + ) - it('binds to the switch terminal, ignores identity-only replays, and ends on a proven exit', async () => { - runtime.acceptSwitchStart('codex', C, 'pty-1', 'pty-1') - runtime.settleSwitchAttempt('pty-1', { ptyId: 'pty-1' }) - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - // The launch identity names the conversation without proving a start or an exit. - hook() + const shell = session({ isAlive: true, sessionId: 'pty-1', incarnationId: 'i1', pid: 4242 }) + host.bind(claim.generation, shell) + // Bound with no agent recorded: only the owner's end releases it. expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - hook(AGENT) + expect(saved.get('chat-1')).toMatchObject({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL }) + Object.assign(shell, { isAlive: false }) + expect(await reason(chatStart())).toBe('admitted') + expect(saved.has('chat-1')).toBe(false) + }) + + it('releases on a proved agent exit with that exact shell alone, keeping the shell', async () => { + const claim = await acceptedSwitch() + host.begin(claim.generation, host.owner) + host.bind( + claim.generation, + session({ isAlive: true, sessionId: 'pty-1', incarnationId: 'i1', pid: 4242 }) + ) expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + runtime['noteSwitchStartProcess']('pty-1', { agent: 'codex', id: C }, AGENT) vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') shellAlone = false expect(await reason(chatStart())).toBe('conversationHeldElsewhere') @@ -87,69 +144,61 @@ describe('the switch claim', () => { expect(await reason(chatStart())).toBe('admitted') }) - it('refuses a second switch of the conversation while the first terminal may hold it', () => { - runtime.acceptSwitchStart('codex', C, 'pty-1', 'pty-1') - runtime.settleSwitchAttempt('pty-1', { ptyId: 'pty-1' }) - expect(() => runtime.registerSwitchClaim('codex', C)).toThrow('agent_session_conflict') - }) -}) - -describe('a switch spawn attempt owns its claim', () => { - const accept = (attempt: string) => runtime.acceptSwitchStart('codex', C, attempt, attempt) - - it('binds a reattach result to its PTY, and that pane exiting releases the conversation', async () => { - expect(accept('pty-1')).not.toBeNull() - runtime.settleSwitchAttempt('pty-1', { ptyId: 'pty-1' }) - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - await runtime.onPtyExit('pty-1', 0, 'i1') - expect(await reason(chatStart())).toBe('admitted') - }) - - it('keeps a lost spawn reply until the host proves its session absent', async () => { - let listed: { id: string }[] | Error = [{ id: 'lost' }] - runtime.setPtyController({ - listProcesses: async () => { - if (listed instanceof Error) { - throw listed - } - return listed - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reconciliation reads only listProcesses. - } as never) - accept('lost') - runtime.settleSwitchAttempt('lost', { unknown: 'lost' }) - expect(await reason(chatStart())).toBe('conversationMovingToTerminal') - listed = new Error('host unreachable') - expect(await reason(chatStart())).toBe('conversationMovingToTerminal') - listed = [] + it('fences a saved start no terminal took, so it can never start later', async () => { + const claim = await acceptedSwitch() expect(await reason(chatStart())).toBe('admitted') + expect(() => host.begin(claim.generation, host.owner)).toThrow( + 'terminal_switch_generation_refused' + ) }) - it('refuses a delayed second client before and after the first binds; a retry joins', () => { - runtime.registerSwitchClaim('codex', C) - const b = accept('pty-b') - expect(accept('pty-a')).toBeNull() - expect(accept('pty-b')).toBe(b) - runtime.registerPty('pty-b', 'wt-1', null, { - tabId: 'tab-2', - leafId: LEAF, - incarnationId: 'i2' + it('survives a restart: a lost owner never speaks, only the recorded execution can end it', async () => { + saved.set('chat-1', { + generation: 'g-old', + agent: 'codex', + providerSessionId: C, + owner: 'daemon:gone', + paneKey: PANE, + ptyId: 'pty-1', + incarnationId: 'i1', + shell: SHELL, + processes: [AGENT] }) - runtime.settleSwitchAttempt('pty-b', { ptyId: 'pty-b' }) - expect(accept('pty-a')).toBeNull() + ownerReachable = false + vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + ownerReachable = true + vi.mocked(switchStartProcessVerdict).mockResolvedValue('live') + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') + expect(await reason(chatStart())).toBe('admitted') + expect(saved.has('chat-1')).toBe(false) }) }) describe('a switch spawn', () => { - const args = { + const args = (generation = 'g1') => ({ launchAgent: 'codex' as const, resumeProviderSession: { key: 'session_id' as const, id: C }, - resumeOrigin: 'switch' + resumeOrigin: 'switch', + env: { [TERMINAL_SWITCH_GENERATION_ENV]: generation, KEEP: '1' } + }) + const claim: SwitchClaim = { + generation: 'g1', + agent: 'codex', + providerSessionId: C, + owner: 'daemon:o', + paneKey: PANE, + processes: [], + chatSessionId: 'chat-1', + at: 0, + accepted: true } - const admission = (writing: boolean) => + const admission = (writing: boolean, savedOk = true) => ({ - acceptSwitchStart: vi.fn(() => ({ agent: 'codex', id: C, at: 0, accepted: true })), - settleSwitchAttempt: vi.fn(), + acceptSwitchStart: vi.fn(() => claim), + saveSwitchClaim: vi.fn(async () => savedOk), + dispatchSwitchStart: vi.fn(() => ({ switchGeneration: 'g1', switchOwner: 'daemon:o' })), // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a switch spawn reads only the store's records and tab index. openAgentSessionRecordStore: vi.fn( async () => @@ -160,31 +209,45 @@ describe('a switch spawn', () => { ) }) satisfies AdmissionRuntime - it('claims before reading the chat, and a writing chat refuses it into a plain shell', async () => { + it('hands the owner its generation only once the start is saved and the chat is quiet', async () => { const quiet = admission(false) - expect(await admitSwitchResumeSpawn(quiet, args, null, SPAWN)).toMatchObject({ - verdict: 'admitted' - }) - expect(quiet.acceptSwitchStart).toHaveBeenCalledWith('codex', C, 'pane-1', undefined) + const spawnArgs = args() + const options: { env?: Record; switchGeneration?: string } = { + env: { ...spawnArgs.env } + } + expect( + await admitSwitchResumeSpawn(quiet, spawnArgs, null, { paneKey: PANE, options }) + ).toMatchObject({ verdict: 'admitted' }) + expect(quiet.acceptSwitchStart).toHaveBeenCalledWith('g1', PANE) + expect(options).toEqual({ env: { KEEP: '1' }, switchGeneration: 'g1', switchOwner: 'daemon:o' }) + expect(spawnArgs.env).toEqual({ KEEP: '1' }) + const writing = admission(true) - const refused = admitSwitchResumeSpawn(writing, args, null, SPAWN) - expect(writing.acceptSwitchStart.mock.invocationCallOrder[0]).toBeLessThan( - writing.openAgentSessionRecordStore.mock.invocationCallOrder[0]! - ) + const refused = admitSwitchResumeSpawn(writing, args(), null, { paneKey: PANE, options: {} }) + // Accepted before the first await, so a chat start's census sees it. + expect(writing.acceptSwitchStart).toHaveBeenCalledOnce() expect(await refused).toMatchObject({ verdict: 'chat-writing' }) - expect(writing.settleSwitchAttempt).toHaveBeenCalledWith('pane-1', 'not-started') + expect(writing.dispatchSwitchStart).not.toHaveBeenCalled() + + const unsaved = admission(false, false) + expect( + await admitSwitchResumeSpawn(unsaved, args(), null, { paneKey: PANE, options: {} }) + ).toMatchObject({ verdict: 'unverifiable' }) + expect(unsaved.dispatchSwitchStart).not.toHaveBeenCalled() }) it('refuses where it cannot judge the pane, and a switch without its conversation', async () => { const quiet = admission(false) + const spawn = { paneKey: PANE, options: {} } expect( - await admitSwitchResumeSpawn(quiet, { ...args, connectionId: 'ssh' }, null, SPAWN) + await admitSwitchResumeSpawn(quiet, { ...args(), connectionId: 'ssh' }, null, spawn) ).toMatchObject({ verdict: 'unverifiable' }) - expect(await admitSwitchResumeSpawn(quiet, args, 'Ubuntu', SPAWN)).toMatchObject({ + expect(await admitSwitchResumeSpawn(quiet, args(), 'Ubuntu', spawn)).toMatchObject({ verdict: 'unverifiable' }) await expect( - admitSwitchResumeSpawn(quiet, { ...args, resumeProviderSession: undefined }, null, SPAWN) + admitSwitchResumeSpawn(quiet, { ...args(), resumeProviderSession: undefined }, null, spawn) ).rejects.toThrow('switch_start_missing_conversation') + expect(quiet.acceptSwitchStart).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts index 42bac3577ff0..13f28ebf3c85 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -1,206 +1,239 @@ +// The conversation index of chat → terminal switch starts. It projects the owner's own answer and +// never settles a start from a caller's success, exception or a session list's absence. +import { randomUUID } from 'node:crypto' import type { AgentProcessIdentity } from '../../../shared/agent-process-presence' import { isSameAgentProcess } from '../../../shared/agent-process-presence' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { PtyIncarnationId } from '../../../shared/pty-incarnation' import { chatAgentsShareConversations } from '../../../shared/structured-agent-cli-conversation-adoption' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { + isTerminalSwitchDescriptor, + type TerminalSwitchDescriptor, + type TerminalSwitchObservation +} from '../../../shared/terminal-switch' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' -import { readSwitchPaneAgentProcesses } from './structured-session-switch-observation' +import { + readSwitchPaneAgentProcesses, + readSwitchShellProcess, + switchStartProcessVerdict +} from './structured-session-switch-observation' -/** A recipe no terminal took yet may lapse; expiry only ends a refusal, the spawn re-checks the chat. */ +/** A recipe no terminal took may lapse; once taken, only its owner ends it. */ const UNACCEPTED_SWITCH_CLAIM_MS = 60_000 const SWITCH_START_WATCH_POLLS = 120 const SWITCH_START_WATCH_INTERVAL_MS = 1_000 -/** A chat → terminal switch from the chat's stop until its terminal execution exists. */ -export type SwitchClaim = { - agent: string - id: string +/** One switch, from the chat's stop. Accepted, it is saved on the chat it left. */ +export type SwitchClaim = TerminalSwitchDescriptor & { + chatSessionId: string at: number - /** A spawn took it: it never lapses, and settles only with that spawn. */ accepted: boolean - /** The accepting spawn's identity (its host session, else its pane); only it joins or settles. */ - attempt?: string - /** The host session to reconcile when the spawn ended without a result. */ - sessionId?: string - unsettled?: true } -/** A switch spawn's end: its PTY, a proved non-start, or a session the host may still hold. */ -export type SwitchSpawnOutcome = { ptyId: string } | 'not-started' | { unknown: string | undefined } - -/** The switch's start bound to the execution it spawned. */ -export type SwitchStartProof = { - incarnationId: PtyIncarnationId - agent: string - id: string - /** The agent processes seen in the pane; none seen keeps the proof until the pane goes. */ - processes: AgentProcessIdentity[] -} - -const moving = () => +export const switchMovingRefusal = () => agentSessionRefusalError('agent_session_operation_invalid', { reason: 'conversationMovingToTerminal' }) +export const switchHeldRefusal = () => + agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere' }) export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaRuntimeWithGetWorktreePs { - private readonly switchClaims = new Set() + private readonly switchClaims = new Map() + private switchClaimsLoaded: Promise | null = null - private liveSwitchClaim(agent: string, id: string): SwitchClaim | undefined { - let live: SwitchClaim | undefined - for (const claim of this.switchClaims) { + protected switchClaimsFor(agent: string, id: string): SwitchClaim[] { + return [...this.switchClaims.values()].filter((claim) => { if (!claim.accepted && Date.now() - claim.at >= UNACCEPTED_SWITCH_CLAIM_MS) { - this.switchClaims.delete(claim) - } else if (claim.id === id && chatAgentsShareConversations(claim.agent, agent)) { - live ??= claim + this.switchClaims.delete(claim.generation) + return false } - } - return live + return claim.providerSessionId === id && chatAgentsShareConversations(claim.agent, agent) + }) } - /** Throws while a switch of this conversation has not started its terminal yet. */ - protected assertNoPendingSwitch(agent: string, id: string): void { - if (this.liveSwitchClaim(agent, id)) { - throw moving() + /** Accepted switches saved before a restart stay owed until their owner proves them over. */ + protected loadSwitchClaims(): Promise { + this.switchClaimsLoaded ??= this.openAgentSessionRecordStore().then( + (store) => { + for (const { sessionId, terminalSwitch } of store.listRecords()) { + if (isTerminalSwitchDescriptor(terminalSwitch)) { + const claim = { ...terminalSwitch, chatSessionId: sessionId, at: 0, accepted: true } + this.switchClaims.set( + terminalSwitch.generation, + this.switchClaims.get(claim.generation) ?? claim + ) + } + } + }, + (error: unknown) => { + this.switchClaimsLoaded = null + throw error + } + ) + return this.switchClaimsLoaded + } + + /** Throws while a switch of this conversation exists: one no terminal took yet, or with `all`, any. */ + protected assertNoPendingSwitch(agent: string, id: string, all = false): void { + if (this.switchClaimsFor(agent, id).some((claim) => all || !claim.accepted)) { + throw switchMovingRefusal() } } - protected boundSwitchStarts(agent: string, id: string) { - return [...this.ptysById.values()].flatMap((pty) => { - const proof = pty.switchStart - return proof && - proof.incarnationId === pty.incarnationId && - proof.id === id && - chatAgentsShareConversations(proof.agent, agent) && - this.getPtyLivenessVerdict(pty.ptyId)?.status !== 'exited' - ? [{ ptyId: pty.ptyId, proof }] - : [] - }) + /** The owner a fresh switch start here would go to; null when this route cannot hold one. */ + async terminalSwitchOwner(): Promise { + return (await this.ptyController?.terminalSwitches?.owner().catch(() => null)) ?? null } - /** Synchronous, in the chat's ordered step before its stop: a second switch of the same - * conversation, from any client, refuses while the first may still hold it. */ - registerSwitchClaim(agent: string, id: string): SwitchClaim { - if (this.liveSwitchClaim(agent, id) || this.boundSwitchStarts(agent, id).length > 0) { - throw agentSessionRefusalError('agent_session_conflict', { - reason: 'conversationHeldElsewhere' - }) + /** In the chat's ordered step before its stop: one switch of a conversation at a time. */ + registerSwitchClaim( + agent: string, + id: string, + chatSessionId: string, + owner: string + ): SwitchClaim { + if (this.switchClaimsFor(agent, id).length > 0) { + throw switchHeldRefusal() + } + const claim: SwitchClaim = { + generation: randomUUID(), + agent, + providerSessionId: id, + owner, + paneKey: '', + processes: [], + chatSessionId, + at: Date.now(), + accepted: false } - const claim = { agent, id, at: Date.now(), accepted: false } - this.switchClaims.add(claim) + this.switchClaims.set(claim.generation, claim) return claim } + /** Only a recipe no terminal took can be withdrawn. */ dropSwitchClaim(claim: SwitchClaim): void { - this.switchClaims.delete(claim) + if (!claim.accepted) { + this.switchClaims.delete(claim.generation) + } } - /** Synchronous, before the switch spawn's first await, so a chat start censusing after it refuses. - * Null refuses: another attempt took this conversation, or a switched terminal may hold it. */ - acceptSwitchStart( - agent: string, - id: string, - attempt: string, - sessionId: string | undefined - ): SwitchClaim | null { - const live = this.liveSwitchClaim(agent, id) - if (live?.accepted ? live.attempt !== attempt : !live && this.boundSwitchStarts(agent, id)[0]) { + /** Synchronous, before the switch spawn's first await. A generation takes one pane; a retry there + * joins it, and an expired or unknown recipe is refused, never reminted. */ + acceptSwitchStart(generation: string, paneKey: string): SwitchClaim | null { + const claim = this.switchClaims.get(generation) + if ( + !claim || + (claim.accepted + ? claim.paneKey !== paneKey + : Date.now() - claim.at >= UNACCEPTED_SWITCH_CLAIM_MS) + ) { return null } - const claim = live ?? { agent, id, at: Date.now(), accepted: true } - Object.assign(claim, { accepted: true, attempt, sessionId: sessionId ?? claim.sessionId }) - delete claim.unsettled - this.switchClaims.add(claim) + Object.assign(claim, { accepted: true, paneKey }) return claim } - /** Only a PTY binds and only a proved non-start drops; an unknown end waits for the host. */ - settleSwitchAttempt(attempt: string, outcome: SwitchSpawnOutcome): void { - const claim = [...this.switchClaims].find((c) => c.accepted && c.attempt === attempt) - const sessionId = - outcome === 'not-started' ? undefined : 'ptyId' in outcome ? outcome.ptyId : outcome.unknown - if (claim && !sessionId) { - this.switchClaims.delete(claim) - } else if (claim) { - Object.assign(claim, { sessionId, unsettled: true }) - this.bindSwitchStart(claim) + /** Saved before dispatch, so a restart still knows the start may exist. */ + async saveSwitchClaim(claim: SwitchClaim): Promise { + const { chatSessionId, at: _at, accepted: _accepted, ...descriptor } = claim + const store = await this.openAgentSessionRecordStore() + return store.setTerminalSwitch(chatSessionId, descriptor).then( + () => true, + () => false + ) + } + + /** The owner's fields for the dispatch; evidence collection starts with it and decides nothing. */ + dispatchSwitchStart(claim: SwitchClaim): { switchGeneration: string; switchOwner: string } { + void this.watchSwitchStart(claim).catch((error: unknown) => + console.warn('[agent-session] could not watch a switched terminal start', error) + ) + return { switchGeneration: claim.generation, switchOwner: claim.owner } + } + + /** The owner's word; when it is gone, only the execution it recorded can prove the start over. + * `fence` makes an unseen generation unusable, so its absence is a proved non-start. */ + protected async observeSwitch( + claim: SwitchClaim, + fence: boolean + ): Promise { + const answers = await this.ptyController?.terminalSwitches + ?.observe(claim.generation, fence) + .catch(() => null) + const own = answers?.find((answer) => answer.owner === claim.owner) + if (own || !answers) { + return own?.observation ?? { state: 'unverifiable' } } + const recorded = claim.shell ? [claim.shell, ...claim.processes] : [] + const verdicts = await Promise.all(recorded.map(switchStartProcessVerdict)) + return recorded.length > 0 && verdicts.every((verdict) => verdict === 'exited') + ? { state: 'ended' } + : { state: 'unverifiable' } } - /** Unsettled attempts against the host's own session list; a failed read keeps them. */ - protected async reconcileSwitchAttempts(): Promise { - const open = [...this.switchClaims].filter((c) => c.unsettled && !this.bindSwitchStart(c)) - if (open.length === 0) { + /** The exact execution the owner bound, saved with its shell's identity. */ + protected async noteSwitchBound( + claim: SwitchClaim, + bound: Extract + ): Promise { + if (claim.ptyId === bound.ptyId && claim.incarnationId === bound.incarnationId) { return } - const listed = await this.ptyController - ?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) - .catch(() => null) - for (const claim of open) { - if (listed && !this.bindSwitchStart(claim) && !listed.some((p) => p.id === claim.sessionId)) { - this.switchClaims.delete(claim) - } + const shell = bound.shellPid ? await readSwitchShellProcess(bound.shellPid) : null + Object.assign(claim, { ptyId: bound.ptyId, incarnationId: bound.incarnationId }) + if (shell) { + claim.shell = shell } + await this.saveSwitchClaim(claim) } - private bindSwitchStart(claim: SwitchClaim): boolean { - const pty = claim.sessionId ? this.ptysById.get(claim.sessionId) : undefined - if (!pty?.incarnationId || !this.switchClaims.delete(claim)) { - return false - } - const key = `${claim.agent}\0${claim.id}` - const reported = - pty.cliConversations?.incarnationId === pty.incarnationId - ? pty.cliConversations.byKey.get(key)?.process - : undefined - const proof: SwitchStartProof = { - incarnationId: pty.incarnationId, - agent: claim.agent, - id: claim.id, - processes: reported ? [reported] : [] + protected async releaseSwitchClaim(claim: SwitchClaim): Promise { + if (this.switchClaims.get(claim.generation) !== claim) { + return } - pty.switchStart = proof - void this.watchSwitchStart(pty.ptyId, proof).catch((error: unknown) => - console.warn('[agent-session] could not watch a switched terminal start', error) - ) - return true + this.switchClaims.delete(claim.generation) + const store = await this.openAgentSessionRecordStore() + await store + .setTerminalSwitch(claim.chatSessionId, { clear: claim.generation }) + .catch((error: unknown) => + console.warn('[agent-session] could not clear a finished switch', error) + ) } - /** An authentic hook's process joins the proof; identity-only rows carry none. */ + /** An authentic hook's process joins the evidence; identity-only rows carry none. */ protected noteSwitchStartProcess( ptyId: string, conversation: { agent: string; id: string }, process: AgentProcessIdentity ): void { - const pty = this.ptysById.get(ptyId) - const proof = pty?.switchStart - if ( - proof && - proof.incarnationId === pty.incarnationId && - proof.id === conversation.id && - chatAgentsShareConversations(proof.agent, conversation.agent) && - !proof.processes.some((known) => isSameAgentProcess(known, process)) - ) { - proof.processes.push(process) + for (const claim of this.switchClaimsFor(conversation.agent, conversation.id)) { + if ( + claim.ptyId === ptyId && + !claim.processes.some((known) => isSameAgentProcess(known, process)) + ) { + claim.processes.push(process) + void this.saveSwitchClaim(claim) + } } } - /** Bounded evidence collection only: it records the agent's identity and never admits anything. */ - private async watchSwitchStart(ptyId: string, proof: SwitchStartProof): Promise { - for (let poll = 0; poll < SWITCH_START_WATCH_POLLS; poll++) { + /** Bounded evidence collection only: it records identities and never fences or releases. */ + private async watchSwitchStart(claim: SwitchClaim): Promise { + for (let poll = 0; poll < SWITCH_START_WATCH_POLLS && claim.processes.length === 0; poll++) { await new Promise((resolve) => setTimeout(resolve, SWITCH_START_WATCH_INTERVAL_MS).unref?.()) - if (this.ptysById.get(ptyId)?.switchStart !== proof || proof.processes.length > 0) { + if (this.switchClaims.get(claim.generation) !== claim) { return } - const entry = (await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID))?.find( - (candidate) => candidate.id === ptyId - ) - if (!entry?.rootProcessId || entry.incarnationId !== proof.incarnationId) { - continue + const seen = await this.observeSwitch(claim, false) + if (seen.state === 'ended') { + return } - const seen = await readSwitchPaneAgentProcesses(entry.rootProcessId, proof.id) - if (this.ptysById.get(ptyId)?.switchStart === proof && proof.processes.length === 0) { - proof.processes.push(...seen) + if (seen.state === 'bound' && seen.shellPid) { + await this.noteSwitchBound(claim, seen) + const found = await readSwitchPaneAgentProcesses(seen.shellPid, claim.providerSessionId) + if (found.length > 0 && claim.processes.length === 0) { + claim.processes.push(...found) + await this.saveSwitchClaim(claim) + } } } } diff --git a/src/main/runtime/session-tabs/structured-session-switch-observation.ts b/src/main/runtime/session-tabs/structured-session-switch-observation.ts index 8ab7ef5a843c..b2968739d7bf 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-observation.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-observation.ts @@ -54,6 +54,22 @@ export async function readSwitchPaneAgentProcesses( return identities.flat() } +/** The shell's own identity, so a later owner loss can still be judged by its process. */ +export async function readSwitchShellProcess(pid: number): Promise { + if (process.platform === 'win32') { + const created = isWindowsProcessStartTimeAvailable() + ? readWindowsProcessCreationTime(pid) + : null + return created === null ? null : { pid, platform: 'win32', startTime: String(created) } + } + const platform = process.platform + if (platform !== 'darwin' && platform !== 'linux') { + return null + } + const observed = await readAgentProcess(pid) + return observed.verdict === 'live' ? { pid, platform, startTime: observed.startTime } : null +} + /** `exited` only on proof: the pid is gone or now names a different process. */ export async function switchStartProcessVerdict( identity: AgentProcessIdentity diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index fb4e94e2461b..749652e70918 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -25,6 +25,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt launchToken, providerSession, providerSessionOnly, + launchExecution, promptInteractionKey, restoredUnconfirmed, observation, @@ -56,6 +57,7 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(observation ? { observation } : {}), + ...(launchExecution ? { launchExecution } : {}), providerSessionOnly: true }) return diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 0e8df40006d9..9f796ea118bc 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -14,9 +14,16 @@ const mocks = vi.hoisted(() => { toast: vi.fn(), activate: vi.fn(async () => true), activateVerified: vi.fn(async () => 'activated'), - switchCapability: vi.fn(async () => false) + switchCapability: vi.fn(async () => false), + exitWatchers: new Map void>() } }) +vi.mock('../terminal-pane/pty-dispatcher', () => ({ + subscribeToPtyExit: (ptyId: string, watcher: () => void) => { + mocks.exitWatchers.set(ptyId, watcher) + return () => mocks.exitWatchers.delete(ptyId) + } +})) vi.mock('./cli-chat-resume-source', () => ({ resolveCliChatResumeSource: () => mocks.source })) vi.mock('@/runtime/structured-agent-session-client', () => ({ supportsStructuredAgentSessionCliSource: mocks.sourceCapability, @@ -86,7 +93,8 @@ const SOURCE: CliChatResumeSource = { paneKey: 'tab-1:leaf', agent: 'claude', providerSessionId: 'provider-1', - executionHostId: 'local' + executionHostId: 'local', + ptyId: 'pty-1' } const tabs = () => useAppStore.getState().unifiedTabsByWorktree['wt-1'] ?? [] afterEach(() => vi.unstubAllGlobals()) @@ -266,6 +274,16 @@ describe('CLI identity resume through the existing launch owner', () => { ) const leafId = '11111111-1111-4111-8111-111111111111' mocks.source = { ...SOURCE, paneKey: `tab-1:${leafId}` } + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } + }) const closed = vi.fn() window.addEventListener('orca-close-terminal-pane', closed) // A host without the switch only shows the chat; it never receives the switch. @@ -280,15 +298,19 @@ describe('CLI identity resume through the existing launch owner', () => { expect(closed).not.toHaveBeenCalled() switched = { ok: true, sessionId: 'owner-chat' } + // The source PTY exits during the chat's readiness: a reused id is another execution, so it stays. + mocks.activateVerified.mockImplementationOnce(async () => { + mocks.exitWatchers.get('pty-1')?.() + return 'activated' + }) + await runCliChatResume(mocks.source) + expect(closed).not.toHaveBeenCalled() await runCliChatResume(mocks.source) window.removeEventListener('orca-close-terminal-pane', closed) const switches = mocks.call.mock.calls.filter( ([, method]) => method === 'agentSession.switchToChat' ) - expect(switches).toEqual([ - [{ kind: 'local' }, 'agentSession.switchToChat', { sessionId: 'owner-chat' }], - [{ kind: 'local' }, 'agentSession.switchToChat', { sessionId: 'owner-chat' }] - ]) + expect(switches).toHaveLength(3) expect(mocks.activateVerified).toHaveBeenCalledWith({ structuredSession: owner }, undefined, { kind: 'local' }) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 5b02ef8065c0..37729ff45ee2 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -15,13 +15,19 @@ import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured- import { resolveCliChatResumeSource, type CliChatResumeSource } from './cli-chat-resume-source' import { reportCliChatResumeFailure } from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' -import { forgetSwitchedTerminalConversation } from './switched-terminal-conversation' +import { panePtyId } from '@/lib/ai-vault-tab-title-requests' +import { subscribeToPtyExit } from '../terminal-pane/pty-dispatcher' -/** The chat took the conversation, so the terminal pane it came from closes (its tab with it). */ -function retireSwitchedTerminalPane(source: CliChatResumeSource): void { +/** The chat took the conversation, so the terminal pane it came from closes (its tab with it): + * only while that pane still holds the execution it came from, never a sibling or replacement. */ +function retireSwitchedTerminalPane(source: CliChatResumeSource, sourceExited: boolean): void { const pane = parsePaneKey(source.paneKey) - forgetSwitchedTerminalConversation(source.tabId) - if (pane) { + if ( + pane && + !sourceExited && + source.ptyId && + panePtyId(useAppStore.getState(), source.paneKey) === source.ptyId + ) { const detail: CloseTerminalPaneDetail = { tabId: pane.tabId, leafId: pane.leafId } window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) } @@ -71,6 +77,7 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise { + sourceExited = true + }) + : () => {} const stopSource = useAppStore.subscribe(() => { if (!isCurrent()) { controller.abort() @@ -159,25 +173,26 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise + resolvePaneLastAgentConversation( + state, + { worktreeId: args.tab.worktreeId, paneKey }, + adoptsCliConversations + ) const conversation = args.paneKey - ? resolvePaneLastAgentConversation( - state, - { worktreeId: args.tab.worktreeId, paneKey: args.paneKey }, - adoptsCliConversations - ) - : collectAgentPaneConversations(state, adoptsCliConversations).find( + ? paneConversation(args.paneKey) + : (collectAgentPaneConversations(state, adoptsCliConversations).find( (candidate) => candidate.tabId === args.tab.id - ) - const layout = state.terminalLayoutsByTabId[args.tab.id] - const leafId = args.paneKey ? parsePaneKey(args.paneKey)?.leafId : layout?.activeLeafId - const seeded = switchedTerminalConversation(args.tab.id, leafId, layout) - if (!conversation) { - return seeded && leafId - ? { - tabId: args.tab.id, - workspaceId: args.tab.worktreeId, - paneKey: makePaneKey(args.tab.id, leafId), - agent: seeded.agent, - providerSessionId: seeded.providerSessionId, - executionHostId: seeded.executionHostId - } - : null - } + ) ?? (activeLeafId ? paneConversation(makePaneKey(args.tab.id, activeLeafId)) : null)) if ( + !conversation || conversation.tabId !== args.tab.id || conversation.worktreeId !== args.tab.worktreeId || // A paired host moves a terminal's conversation only once it switches that agent in place. @@ -70,6 +61,7 @@ export function resolveCliChatResumeSource( paneKey: conversation.paneKey, agent: conversation.agent, providerSessionId: conversation.providerSession.id, - executionHostId: conversation.executionHostId + executionHostId: conversation.executionHostId, + ptyId: panePtyId(state, conversation.paneKey) } } diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts index 84959c59c2f5..997dafdc65b0 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts @@ -29,7 +29,6 @@ vi.mock('@/lib/ai-vault-resume-command', () => ({ })) import { runNativeChatCliSwitch } from './native-chat-cli-switch-run' -import { switchedTerminalConversation } from './switched-terminal-conversation' const SWITCH = { sessionId: 'chat-1', @@ -51,7 +50,7 @@ const SOURCE = { beforeEach(() => { vi.clearAllMocks() mocks.supported = true - mocks.call.mockResolvedValue({ source: SOURCE }) + mocks.call.mockResolvedValue({ source: SOURCE, generation: 'g1' }) }) it('stops the chat on its host, opens the same-id resume in its slot, then closes the chat tab', async () => { @@ -64,7 +63,8 @@ it('stops the chat on its host, opens the same-id resume in its slot, then close agent: 'codex', worktreeId: 'wt-1', command: 'codex resume thread-c', - env: { CODEX_HOME: '/accounts/a/home' }, + // The host's switch generation rides the launch to the owner that creates the terminal. + env: { CODEX_HOME: '/accounts/a/home', ORCA_TERMINAL_SWITCH_GENERATION: 'g1' }, providerSession: { key: 'session_id', id: 'thread-c' }, afterTabId: 'chat-tab', resumeOrigin: 'switch' @@ -96,27 +96,3 @@ it('keeps the chat when the host refuses a busy chat, and says why', async () => expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() expect(mocks.toast).toHaveBeenCalledWith('Wait for the reply to finish or stop it, then switch.') }) - -it('offers the switch back on a paired terminal before any prompt, for its own pane only', async () => { - mocks.launch.mockReturnValueOnce({ - tabId: null, - runtimeLaunch: Promise.resolve({ status: 'created', tabId: 'web-terminal-t' }) - }) - await runNativeChatCliSwitch({ ...SWITCH, executionHostId: 'runtime:server-1' }) - const layout = (ptyIdsByLeafId: Record) => ({ - root: null, - activeLeafId: 'L1', - expandedLeafId: null, - ptyIdsByLeafId - }) - expect(switchedTerminalConversation('web-terminal-t', 'L1', layout({ L1: 'p1' }))).toMatchObject({ - providerSessionId: 'thread-c' - }) - // A split's new plain shell never takes the switched pane's conversation. - expect(switchedTerminalConversation('web-terminal-t', 'L2', layout({ L1: 'p1', L2: 'p2' }))).toBe( - undefined - ) - // A replaced PTY in the switched pane ends it. - expect(switchedTerminalConversation('web-terminal-t', 'L1', layout({ L1: 'p9' }))).toBe(undefined) - expect(switchedTerminalConversation('web-terminal-t', 'L1', layout({ L1: 'p1' }))).toBe(undefined) -}) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts index f3f228132951..3e3cd3b67e74 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts @@ -15,8 +15,7 @@ import { AiVaultChatForkSourceSchema } from '../../../../shared/ai-vault-chat-fo import type { AiVaultAgent } from '../../../../shared/ai-vault-types' import { agentLabel } from '../../../../shared/ai-vault-session-filters' import { parseExecutionHostId } from '../../../../shared/execution-host' -import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' -import { seedSwitchedTerminalConversation } from './switched-terminal-conversation' +import { TERMINAL_SWITCH_GENERATION_ENV } from '../../../../shared/terminal-switch' const pending = new Set() const listeners = new Set<() => void>() @@ -84,12 +83,15 @@ export async function runNativeChatCliSwitch(args: { if (!(await supportsInPlaceSwitch(target, args.agent))) { throw new Error(nativeChatCliSwitchFailureText(null, args.agent)) } - const answered = await callStructuredAgentSession<{ source: unknown }>( + const answered = await callStructuredAgentSession<{ source: unknown; generation?: unknown }>( target, 'agentSession.switchToTerminal', { sessionId: args.sessionId } ) const source = AiVaultChatForkSourceSchema.parse(answered.source) + if (typeof answered.generation !== 'string') { + throw new Error(nativeChatCliSwitchFailureText(null, args.agent)) + } const state = useAppStore.getState() const startup = buildAiVaultResumeStartupForWorktree({ state, @@ -101,6 +103,8 @@ export async function runNativeChatCliSwitch(args: { agent: source.agent, worktreeId: args.workspaceId, ...startup, + // The host's terminal owner binds this generation to the one start it may protect. + env: { ...startup.env, [TERMINAL_SWITCH_GENERATION_ENV]: answered.generation }, ...(args.chatTabId ? { afterTabId: args.chatTabId } : {}), resumeOrigin: 'switch' }) @@ -108,15 +112,6 @@ export async function runNativeChatCliSwitch(args: { if (outcome && outcome.status !== 'created') { throw new Error(outcome.status === 'failed' ? outcome.message : undefined) } - // A paired host's terminal is named by its create result, so the switch back works before a prompt. - const tabId = launch.tabId ?? (outcome?.status === 'created' ? outcome.tabId : undefined) - if (tabId && adoptsCliConversations(source.agent)) { - seedSwitchedTerminalConversation(tabId, { - agent: source.agent, - providerSessionId: source.sessionId, - executionHostId: hostId - }) - } // The terminal exists, so the chat tab retires; its host close finds the agent already stopped. if (args.chatTabId) { useAppStore.getState().closeUnifiedTab(args.chatTabId) diff --git a/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts b/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts deleted file mode 100644 index 949be7bc75fc..000000000000 --- a/src/renderer/src/components/tab-bar/switched-terminal-conversation.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import type { CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' - -/** The conversation a switch put in a terminal tab, until its agent reports one. Presentation only: - * an agent's first report comes with its first prompt, and the switch back is offered before it. */ -type SwitchedTerminalConversation = { - agent: CliConversationRowAgent - providerSessionId: string - executionHostId: ExecutionHostId - /** The switch's own pane and PTY, latched when the new tab's only leaf first shows them. */ - leafId?: string - ptyId?: string -} - -const byTabId = new Map() - -export function seedSwitchedTerminalConversation( - tabId: string, - conversation: SwitchedTerminalConversation -): void { - byTabId.set(tabId, conversation) -} - -/** Only for the switch's own leaf and PTY; a replaced PTY or a gone leaf ends the seed. */ -export function switchedTerminalConversation( - tabId: string, - leafId: string | null | undefined, - layout: TerminalLayoutSnapshot | undefined -): SwitchedTerminalConversation | undefined { - const seed = byTabId.get(tabId) - const leaves = Object.keys(layout?.ptyIdsByLeafId ?? {}) - if (seed && !seed.leafId && leaves.length === 1) { - seed.leafId = leaves[0] - } - const ptyId = seed?.leafId ? layout?.ptyIdsByLeafId?.[seed.leafId] : undefined - if (seed?.leafId && ptyId && seed.ptyId === undefined) { - seed.ptyId = ptyId - } - if (seed?.ptyId && ptyId !== seed.ptyId) { - byTabId.delete(tabId) - return undefined - } - return seed?.leafId && seed.leafId === leafId ? seed : undefined -} - -export function forgetSwitchedTerminalConversation(tabId: string): void { - byTabId.delete(tabId) -} diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 2bd676760973..03166433fe50 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -5,6 +5,7 @@ import { makeRepo, makeTerminalTab, makeWorktree } from '../worktree-jump-palett import { makeAgentStatusEntry } from '@/runtime/sync-runtime-graph-test-harness' import { collectAiVaultTitleRequests } from '@/lib/ai-vault-tab-title-requests' import { resolveCliChatResumeSource } from './cli-chat-resume-source' +import { applyProviderSessionOnlyUpdate } from '@/hooks/ipc-events/agent-status-partial-updates' import type { AiVaultListArgs, AiVaultSession } from '../../../../shared/ai-vault-types' import { getDefaultSettings } from '../../../../shared/constants' import { @@ -504,6 +505,56 @@ describe('one pane of a terminal tab', () => { false ) }) + it('presents a switched terminal’s host identity before any prompt, for its own PTY only', () => { + makeState() + const [L1, L2] = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' + ] + const pane = `term-1:${L1}` + const layout = (ptyIdsByLeafId: Record) => + useAppStore.setState({ + terminalLayoutsByTabId: { + 'term-1': { root: null, activeLeafId: L1, expandedLeafId: null, ptyIdsByLeafId } + } + }) + layout({ [L1]: 'pty-1', [L2]: 'pty-2' }) + applyProviderSessionOnlyUpdate({ + store: useAppStore.getState(), + batch: undefined, + paneKey: pane, + routing: { tabId: 'term-1', worktreeId: WORKTREE_ID }, + data: { + paneKey: pane, + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + state: 'done', + prompt: '', + agentType: 'codex', + providerSession: { key: 'session_id', id: 'thread-c' }, + providerSessionOnly: true, + launchExecution: { ptyId: 'pty-1', incarnationId: 'i1' } + } + }) + const state = useAppStore.getState() + // Presentation only: no status row and no sleeping recovery record. + expect(state.sleepingAgentSessionsByPaneKey).toEqual({}) + expect(state.agentStatusByPaneKey).toEqual({}) + expect( + resolveCliChatResumeSource(state, { + tab: { id: 'term-1', worktreeId: WORKTREE_ID }, + paneKey: pane + }) + ).toMatchObject({ agent: 'codex', providerSessionId: 'thread-c', ptyId: 'pty-1' }) + expect(paneGate(state, `term-1:${L2}`)).toBe(false) + layout({ [L1]: 'pty-9' }) + expect(paneGate(useAppStore.getState(), pane)).toBe(false) + // Its PTY exiting ends it, so a later incarnation under the same id never inherits it. + layout({ [L1]: 'pty-1' }) + useAppStore.getState().setLaunchConversation(pane, null) + expect(paneGate(useAppStore.getState(), pane)).toBe(false) + }) }) describe('findTabSessionHistoryRow', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts index c86eb39ebab1..8132c951fe53 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -257,6 +257,7 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { // entry so the hover UI only shows what is running *now*. useAppStore.getState().removeAgentStatus(session.cacheKey) useAppStore.getState().clearPaneForegroundAgent(session.cacheKey) + useAppStore.getState().setLaunchConversation(session.cacheKey, null) // The runtime graph is the CLI's source for live terminal bindings, so // we must republish when a pane loses its PTY instead of waiting for a // broader layout change that may never happen. diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index d944faa58cde..e36be126e8a6 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -1,4 +1,3 @@ -import { switchedTerminalConversation } from '../tab-bar/switched-terminal-conversation' import { useCallback, useEffect } from 'react' import type { CSSProperties } from 'react' import { @@ -189,11 +188,10 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll activePaneCanResumeInChat: !activePaneIsChatLeaf && activeLeafId !== null && - (!!switchedTerminalConversation(tabId, activeLeafId, state.terminalLayoutsByTabId[tabId]) || - canPaneOfferResumeInNewChat(state, { - worktreeId, - paneKey: makePaneKey(tabId, activeLeafId) - })) + canPaneOfferResumeInNewChat(state, { + worktreeId, + paneKey: makePaneKey(tabId, activeLeafId) + }) })) ) const contextMenuIsChatView = effectiveChatViewMode && contextMenuLeafId === chatLeafId diff --git a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts index f8dc3bd7815e..bf6ec09d2dce 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts @@ -1,5 +1,6 @@ import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' +import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import type { AppState } from '@/store/types' import type { AgentProgramStatusUpdate, @@ -49,7 +50,13 @@ export function applyProviderSessionOnlyUpdate(args: { }): AgentStatusApplyResult { const { store, batch, data, paneKey, routing } = args if (!data.providerSession || data.agentType !== 'pi') { - return 'dropped' + const { agentType: agent, providerSession, launchExecution: execution } = data + if (!providerSession || !execution || !adoptsCliConversations(agent)) { + return 'dropped' + } + // Why not a sleeping record: this identity only presents the switched terminal's conversation. + store.setLaunchConversation(paneKey, { agent, providerSession, execution }) + return 'applied' } const update: AgentProviderSessionUpdate = { kind: 'providerSession', diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index da315267f73b..a1312a5a8ce4 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -168,8 +168,14 @@ export function resolvePaneAgentConversation( return request } -/** As above, or the conversation the pane's agent last reported once it exited: a switch is - * offered when the agent is gone, which is when it can succeed. */ +/** The PTY a pane is bound to now. */ +export function panePtyId(state: AppState, paneKey: string): string | undefined { + const pane = parsePaneKey(paneKey) + return pane ? state.terminalLayoutsByTabId[pane.tabId]?.ptyIdsByLeafId?.[pane.leafId] : undefined +} + +/** As above, or the conversation the pane's agent last reported once it exited, or the one the + * host launched in this exact PTY: a switch is offered when the agent is gone. */ export function resolvePaneLastAgentConversation( state: AppState, pane: { worktreeId: string; paneKey: string }, @@ -177,18 +183,28 @@ export function resolvePaneLastAgentConversation( ): AgentPaneConversation | null { const live = resolvePaneAgentConversation(state, pane, isAgent) const retained = state.retainedAgentsByPaneKey[pane.paneKey] - if (live || retained?.entry.paneKey !== pane.paneKey || retained.worktreeId !== pane.worktreeId) { + const launch = state.launchConversationByPaneKey[pane.paneKey] + const tab = (state.tabsByWorktree[pane.worktreeId] ?? []).find( + (candidate) => candidate.id === parsePaneKey(pane.paneKey)?.tabId + ) + const source = + retained?.entry.paneKey === pane.paneKey && retained.worktreeId === pane.worktreeId + ? retained + : tab && launch && launch.execution.ptyId === panePtyId(state, pane.paneKey) + ? { tab, agentType: launch.agent, entry: launch } + : null + if (live || !source) { return live } const candidates = new Map>() - registerCandidate(state, new Map([[retained.tab.id, retained.tab]]), candidates, isAgent, { - agent: retained.agentType, + registerCandidate(state, new Map([[source.tab.id, source.tab]]), candidates, isAgent, { + agent: source.agentType, paneKey: pane.paneKey, priority: 10, - providerSession: retained.entry.providerSession, + providerSession: source.entry.providerSession, refresh: false, - tabId: retained.tab.id, - worktreeId: retained.worktreeId + tabId: source.tab.id, + worktreeId: pane.worktreeId }) const [candidate] = candidates.values() if (!candidate) { diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 0fac9335e160..4646748c8793 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -67,7 +67,7 @@ export function launchAiVaultSessionInNewTab(args: { ...(args.launchConfig ? { agentArgs: args.launchConfig.agentArgs } : {}), activate: true }, - { notifyFailure: false, ...(args.resumeOrigin ? { withTabId: true } : {}) } + { notifyFailure: false } ) const observedRuntimeLaunch = runtimeLaunch.then((outcome) => { if (outcome.status === 'created') { diff --git a/src/renderer/src/runtime/web-runtime-session-types.ts b/src/renderer/src/runtime/web-runtime-session-types.ts index 3287a64ec521..a09629f34a61 100644 --- a/src/renderer/src/runtime/web-runtime-session-types.ts +++ b/src/renderer/src/runtime/web-runtime-session-types.ts @@ -11,8 +11,8 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import type { TuiAgent } from '../../../shared/tui-agent' export type WebRuntimeTerminalCreateOutcome = - /** `tabId`: this client's tab for the host's new terminal, when the host named it. */ - { status: 'created'; tabId?: string } | { status: 'failed'; message: string } + | { status: 'created' } + | { status: 'failed'; message: string } export type CreateWebRuntimeSessionTerminalArgs = { worktreeId: string diff --git a/src/renderer/src/runtime/web-runtime-terminal-creation.ts b/src/renderer/src/runtime/web-runtime-terminal-creation.ts index 1670bf75fef5..bde5df66a78c 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-creation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-creation.ts @@ -17,7 +17,7 @@ import type { */ export async function createWebRuntimeSessionTerminal( args: CreateWebRuntimeSessionTerminalArgs, - options: { notifyFailure?: boolean; withTabId?: boolean } = {} + options: { notifyFailure?: boolean } = {} ): Promise { const created = await createWebRuntimeSessionTerminalResult(args) if ( @@ -30,9 +30,7 @@ export async function createWebRuntimeSessionTerminal( worktreeId: args.worktreeId }) } - return options.withTabId && created.outcome.status === 'created' && created.hostTabId - ? { status: 'created', tabId: toWebTerminalSurfaceTabId(created.hostTabId) } - : created.outcome + return created.outcome } export async function createWebRuntimeAgentSessionTerminal( diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 5a08e59b8edf..91b82a6afb8f 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -26,9 +26,19 @@ import { settleAttentionRemovedBy } from './workspace-attention/attention-remova export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime -): Pick { +): Pick { const { get, set, requestFreshness } = runtime return { + setLaunchConversation: (paneKey, conversation) => { + if (conversation || paneKey in get().launchConversationByPaneKey) { + set((s) => { + const { [paneKey]: _old, ...rest } = s.launchConversationByPaneKey + return { + launchConversationByPaneKey: conversation ? { ...rest, [paneKey]: conversation } : rest + } + }) + } + }, recordAgentProviderSession: ( paneKey: string, agent: ResumableTuiAgent, diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index bc6575c8e3eb..346f1e2e5b1e 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -29,6 +29,15 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' +import type { TerminalExecutionStamp } from '../../../../shared/terminal-switch' +import type { CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' + +/** A switched terminal's conversation, before its agent reports one. Presentation only. */ +export type LaunchConversation = { + agent: CliConversationRowAgent + providerSession: AgentProviderSessionMetadata + execution: TerminalExecutionStamp +} export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ @@ -51,6 +60,11 @@ export type AgentStatusSlice = { /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ sleepingAgentSessionsByPaneKey: Record + /** The host's switched-terminal identity rows; never a sleeping record or a status. */ + launchConversationByPaneKey: Record + /** Null when the pane's PTY exits: a later incarnation, even under the same id, is not it. */ + setLaunchConversation: (paneKey: string, conversation: LaunchConversation | null) => void + /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ agentLaunchConfigByPaneKey: Record diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index b08ed80b5ea2..d05a94f5851c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -108,6 +108,7 @@ export const createAgentStatusSlice: StateCreator 0 && value.length <= max -} +const isBoundedString = (value: unknown, max: number): value is string => + typeof value === 'string' && value.length > 0 && value.length <= max export function isAgentSessionId(value: unknown): value is string { return typeof value === 'string' && SESSION_ID_PATTERN.test(value) @@ -200,9 +202,8 @@ const SCOPE_KEY_SEPARATOR = '\u0000' * different sessions; collapsing them would let one host adjudicate another host's lease. */ export function agentSessionScopeKey(location: AgentSessionExecutionLocation): string { - return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join( - SCOPE_KEY_SEPARATOR - ) + const { executionHostId, wslDistro, workspaceId } = location + return [executionHostId, wslDistro ?? '', workspaceId].join(SCOPE_KEY_SEPARATOR) } export function agentSessionExecutionLocationsEqual( @@ -373,6 +374,7 @@ export function isPersistedAgentSessionRecord( (record.conversationName === undefined || isAgentSessionConversationName(record.conversationName)) && (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && + (record.terminalSwitch === undefined || isTerminalSwitchDescriptor(record.terminalSwitch)) && !Object.hasOwn(record, 'launchEnv') && isPersistedAgentSessionLease(record.lease) && record.lease.sessionId === record.sessionId && diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 0efbc6d7f7a9..5d7ab2c227ef 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -5,6 +5,7 @@ import type { StructuredHostStatus } from './agent-hook-listener/listener-event' import type { ProgramStatusFacet } from './program-status/facet' +import type { TerminalExecutionStamp } from './terminal-switch' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { WithAgentStatusObservation } from './agent-status-observation' import type { @@ -64,6 +65,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { providerSession?: AgentProviderSessionMetadata /** Resume identity update only; the status-shaped fields are transport placeholders. */ providerSessionOnly?: boolean + /** The switched terminal execution an identity-only row describes. */ + launchExecution?: TerminalExecutionStamp /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string /** See AgentStatusEntry.programStatus. Live-only; older peers ignore it. */ diff --git a/src/shared/terminal-switch.ts b/src/shared/terminal-switch.ts new file mode 100644 index 000000000000..0801c83ced9d --- /dev/null +++ b/src/shared/terminal-switch.ts @@ -0,0 +1,69 @@ +// A chat → terminal switch's start, owned by the execution host that creates the terminal: the +// recipe names one generation and that owner, and only the owner can say the start is over. +import { readAgentProcessIdentity, type AgentProcessIdentity } from './agent-process-presence' + +/** Carries the generation from the recipe to the spawn that honours it; the spawn strips it. */ +export const TERMINAL_SWITCH_GENERATION_ENV = 'ORCA_TERMINAL_SWITCH_GENERATION' + +/** What the owner knows of one generation. `bound` is the exact execution it created. */ +export type TerminalSwitchObservation = + | { state: 'pending' } + | { + state: 'bound' + ptyId: string + incarnationId: string + shellPid: number | null + /** The startup command was written or revoked; no producer can still deliver it. */ + startupSettled: boolean + } + | { state: 'ended' } + | { state: 'unverifiable' } + +/** The owner's answer, tagged with who answered: a different owner never speaks for this one. */ +export type TerminalSwitchOwnerAnswer = { owner: string; observation: TerminalSwitchObservation } + +/** The owners a provider reaches: who takes its next fresh start, and every owner's answer. */ +export type TerminalSwitchOwners = { + owner: () => Promise + /** `fence`: an unseen generation becomes unusable and reads ended; without it, pending. */ + observe: (generation: string, fence: boolean) => Promise +} + +/** Saved on the chat a switch left, from acceptance until the owner proves the start over. + * Identity only: never command text, environment, credentials or a running state. */ +export type TerminalSwitchDescriptor = { + generation: string + agent: string + providerSessionId: string + owner: string + paneKey: string + ptyId?: string + incarnationId?: string + shell?: AgentProcessIdentity + processes: AgentProcessIdentity[] +} + +/** The exact execution a switch's launch identity row describes; presentation binds to it. */ +export type TerminalExecutionStamp = { ptyId: string; incarnationId: string } + +/** A save, or a clear that names the generation it ends. */ +export type TerminalSwitchWrite = TerminalSwitchDescriptor | { clear: string } + +const MAX = 512 +const bounded = (value: unknown): value is string => + typeof value === 'string' && value.length > 0 && value.length <= MAX + +export function isTerminalSwitchDescriptor(value: unknown): value is TerminalSwitchDescriptor { + if (typeof value !== 'object' || value === null) { + return false + } + const d: Partial> = value + return ( + [d.generation, d.agent, d.providerSessionId, d.owner, d.paneKey].every(bounded) && + [d.ptyId, d.incarnationId].every((field) => field === undefined || bounded(field)) && + (d.shell === undefined || readAgentProcessIdentity(d.shell) !== undefined) && + Array.isArray(d.processes) && + d.processes.length <= 16 && + d.processes.every((process) => readAgentProcessIdentity(process) !== undefined) + ) +} From 96ed17893ea867513576d22746ad48428d4aaa4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:08:06 -0700 Subject: [PATCH 015/207] test(native-chat): expect the switch menu's in-place and fork labels --- .../tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx index 889722ee1034..7164bac2963a 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx @@ -115,7 +115,7 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { mocks.subject = CHAT_SUBJECT renderItemsNow('orca-chat-1') expect(mocks.listSessions).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to Terminal' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New Terminal' })) expect(mocks.runNativeChatCliFork).toHaveBeenCalledWith( expect.objectContaining({ subject: CHAT_SUBJECT, @@ -136,7 +136,7 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { } mocks.cliSource = source renderItemsNow() - fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to Chat' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to native chat' })) await act(async () => {}) expect(mocks.runCliChatResume).toHaveBeenCalledWith(source) expect(mocks.listSessions).not.toHaveBeenCalled() @@ -150,8 +150,8 @@ it('keeps an accepted native action visible with shared disabled spinner feedbac mocks.pendingListeners.forEach((listener) => listener()) }) await renderItems('orca-chat-1') - fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to Terminal' })) - const item = screen.getByRole('menuitem', { name: 'Switch to Terminal' }) + fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New Terminal' })) + const item = screen.getByRole('menuitem', { name: 'Resume in New Terminal' }) expect(item.getAttribute('aria-busy')).toBe('true') expect(item.getAttribute('data-disabled')).not.toBeNull() expect(item.querySelector('.animate-spin')).not.toBeNull() From 487db363367527eb2dfd9bcfadcc4fecd20b1d16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:12:21 -0700 Subject: [PATCH 016/207] fix: consolidate terminal launch imports --- .../ipc/pty/runtime/spawn-preflight-saved-resume.test.ts | 7 +++++-- .../structured-agent-session-provider-open.test.ts | 3 +-- src/main/runtime/runtime-terminal-state-records.ts | 6 ++++-- src/preload/api/pty-api.ts | 3 ++- src/preload/api/pty-bridge-session-control.ts | 3 ++- .../src/components/terminal-pane/pty-connection-types.ts | 2 +- .../src/components/terminal-pane/pty-transport-types.ts | 2 +- .../terminal-pane/remote-runtime-pty-transport.ts | 6 ++++-- src/renderer/src/store/terminals/terminal-state.ts | 2 +- src/shared/runtime-terminal-contracts.ts | 2 +- 10 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index b25197d6a90d..f83496bde996 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -11,11 +11,14 @@ import { import { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { finishPtyShutdown } from '../provider/liveness' -import { admitColdRestoreReattach, admitResumeSpawn } from '../host-env/saved-resume-admission' import { listStructuredProviderSessionOwnership } from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' import type { PtyRuntimeControllerDeps } from './controller-deps' import { executeRuntimePtySpawn } from './spawn-execute' -import { savedResumeResult } from '../host-env/saved-resume-admission' +import { + admitColdRestoreReattach, + admitResumeSpawn, + savedResumeResult +} from '../host-env/saved-resume-admission' import { createRuntimePtySpawnState } from './spawn-state' const forgetRefusedPaneResume = vi.hoisted(() => vi.fn()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 4c534eeb4c71..26e66bfacb0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -1,5 +1,4 @@ import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { expect, it, vi } from 'vitest' import type { StructuredAgentSessionProviderOpen, @@ -12,7 +11,7 @@ import { envelope, hostTestState } from './structured-agent-session-host-test-harness' -import { hostTestMessage } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data' const OPENED: StructuredAgentSessionProviderOpenResult = { reportedOptions: { model: 'gpt-test' } diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 2e1fd1a1fc96..6b36edea7dd7 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -1,7 +1,9 @@ -import type { TerminalLaunchResult } from '../../shared/agent-session-resume' import type { AgentStatus } from '../../shared/agent-detection' import type { AgentStatusState } from '../../shared/agent-status-types' -import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { + TerminalLaunchResult, + SleepingAgentLaunchConfig +} from '../../shared/agent-session-resume' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { RuntimeSyncedLeaf } from '../../shared/runtime-types' import type { RuntimeTerminalRowMetadata } from '../../shared/runtime-terminal-contracts' diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index afa346330e7a..2f4aba50975a 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -1,6 +1,7 @@ -import type { SavedResumeDecision, TerminalLaunchResult } from '../../shared/agent-session-resume' import type { SpawnIntent } from '../../shared/host-spawn-admission' import type { + SavedResumeDecision, + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 0a32ecaae9a2..89d0ef82d830 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -1,4 +1,3 @@ -import type { SavedResumeDecision, TerminalLaunchResult } from '../../shared/agent-session-resume' import type { SpawnIntent } from '../../shared/host-spawn-admission' import { ipcRenderer } from 'electron' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' @@ -9,6 +8,8 @@ import type { TerminalLeafMoveResult } from '../../shared/terminal-leaf-move' import type { + SavedResumeDecision, + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 6d2fb3583ffe..a37bd3d835f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -1,4 +1,3 @@ -import type { SavedResumeStartupSource } from '../../../../shared/agent-session-resume' import type { WorkspaceAttentionSlice } from '@/store/slices/workspace-attention/workspace-attention-contract' import type { NotificationWorkspaceOwner } from '../../../../shared/notification-source' import type { PtyTransport, PtyTransportRecoveryState } from './pty-transport-types' @@ -12,6 +11,7 @@ import type { StartupCommandDelivery } from '../../../../shared/codex-startup-de import type { TuiAgent } from '../../../../shared/tui-agent' import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { + SavedResumeStartupSource, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' 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 b14f471f2394..ba2ea6730a65 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -1,5 +1,4 @@ import type { TerminalAttachmentLaunchResult } from '../../../../shared/terminal-attachment-launch-result' -import type { TerminalLaunchResult } from '../../../../shared/agent-session-resume' import type { AgentIdleEdgeMeta } from '../../../../shared/terminal-output-side-effects' import type { RestoreSpawnIntent, @@ -7,6 +6,7 @@ import type { } from '../../../../shared/host-spawn-admission' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index ce9a4a0a6c9d..2c62bc87fdc1 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -2,7 +2,6 @@ import { parseTerminalAttachmentLaunchResult } from '../../../../shared/terminal import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' -import { SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { readTerminalSpawnDeferral } from '../../../../shared/host-spawn-admission' import { remoteRestoreSpawnIntentParams } from '../../runtime/remote-restore-spawn-intent' import { createAgentSessionKeyboardOptions } from '@/runtime/agent-session-keyboard-capability' @@ -38,7 +37,10 @@ import type { RuntimeTerminalResolvePane, RuntimeTerminalSend } from '../../../../shared/runtime-types' -import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + SAVED_RESUME_ORIGIN_RUNTIME_CAPABILITY, + TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability' import { isTerminalInputTooLargeWithDeferredMeasurement, diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 4c27bff02c43..d7ca92d5ffdc 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -1,8 +1,8 @@ -import type { SavedResumeStartupSource } from '../../../../shared/agent-session-resume' import type { ClosedTerminalTabTombstonesByTabId } from '../../../../shared/closed-terminal-tab-tombstones' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { + SavedResumeStartupSource, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 30f161c9219e..4da42bb236ae 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,5 +1,5 @@ -import type { TerminalLaunchResult } from './agent-session-resume' import type { + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from './agent-session-resume' From 7969c0965c4acf8131a37662b84dbec19335ecaa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:30:15 -0700 Subject: [PATCH 017/207] fix(native-chat): keep a switch start protected until its agents, not just its shell, are proved over - Every release needs each recorded agent proved exited; the owner's root exit alone no longer clears it, and the daemon reports startup settled only once its write gate flushes. - Descriptor writes are compare-and-set on generation, so a retired switch's late evidence never replaces or resurrects one. - After full owner loss, a host reboot since acceptance ends a start with no recorded identity; evidence a save missed is retried. - An owner that cannot answer fails the routed read and holds; only every reachable owner answering without it counts as loss. - The local owner binds the process before any callback or cancellation cleanup can throw. - Snapshots carry the launch stamp, the paired projection drops a stamp naming a replaced incarnation, and the switch back retires a pane only while its host still reports the incarnation it started with. --- .../server-launch-resume-identity.test.ts | 4 + .../server/server-status-identity.ts | 1 + src/main/daemon/daemon-pty-adapter.ts | 2 +- src/main/daemon/session.ts | 2 +- src/main/daemon/terminal-host-switches.ts | 4 +- .../local-pty-provider-spawn-session.test.ts | 19 ++-- src/main/providers/local-pty-spawn.ts | 13 +-- src/main/providers/terminal-switch-owners.ts | 6 +- .../agent-session-record-terminal-switch.ts | 13 ++- .../runtime-mobile-session-projection.ts | 17 ++-- .../structured-session-cli-writer-host.ts | 27 +++--- .../structured-session-switch-claims.test.ts | 96 +++++++++++++------ .../structured-session-switch-claims.ts | 48 ++++++++-- .../tab-bar/cli-chat-resume-run.test.ts | 32 ++++++- .../components/tab-bar/cli-chat-resume-run.ts | 31 ++++-- src/shared/terminal-switch.ts | 11 ++- 16 files changed, 233 insertions(+), 93 deletions(-) diff --git a/src/main/agent-hooks/server-launch-resume-identity.test.ts b/src/main/agent-hooks/server-launch-resume-identity.test.ts index d241db1f418b..60b6fee29b09 100644 --- a/src/main/agent-hooks/server-launch-resume-identity.test.ts +++ b/src/main/agent-hooks/server-launch-resume-identity.test.ts @@ -83,6 +83,10 @@ describe('a resume launch seeds the pane identity', () => { { ptyId: 'pty-1', incarnationId: 'i1' }, { ptyId: 'pty-1', incarnationId: 'i2' } ]) + // A reloaded renderer reads the same stamp from the snapshot. + expect(server.getStatusSnapshotForPane(PANE)).toEqual([ + expect.objectContaining({ launchExecution: { ptyId: 'pty-1', incarnationId: 'i2' } }) + ]) }) it("yields to the agent's own report and is replaced by its first hook", async () => { diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 0e798e742302..d3dd3464d8f2 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -63,6 +63,7 @@ export function toAgentStatusIpcPayload( ...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), + ...(entry.launchExecution ? { launchExecution: entry.launchExecution } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(entry.observation ? { observation: entry.observation } : {}), diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 1593eee3ca78..c52792872dc1 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -23,7 +23,7 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro owner: async () => (await this.terminalSwitches.observe(randomUUID(), false))[0]?.owner ?? null, observe: async (generation, fence) => { if (!this.client.isConnected()) { - return [] + throw new Error('terminal_switch_owner_unreachable') } try { const answer = await this.client.request( diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 845608425caa..d23c96066f62 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -34,7 +34,7 @@ export class Session { private readonly onSessionExit?: (code: number) => void private readonly output: SessionOutputPlane private readonly producerPause: SessionProducerPause - private readonly shellReady: SessionShellReadyBarrier + readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController /** Public so the creating host can print its own notice as terminal output. */ readonly startupIngress: PtyStartupIngress diff --git a/src/main/daemon/terminal-host-switches.ts b/src/main/daemon/terminal-host-switches.ts index db8ba8c58663..0ea1edc94b4b 100644 --- a/src/main/daemon/terminal-host-switches.ts +++ b/src/main/daemon/terminal-host-switches.ts @@ -54,8 +54,8 @@ export class TerminalHostSwitches { ptyId: session.sessionId, incarnationId: session.incarnationId, shellPid: session.pid > 0 ? session.pid : null, - // The startup command is queued behind the shell-ready barrier until it settles. - startupSettled: session.shellState !== 'pending' + // The startup command is queued behind the shell-ready barrier until it flushes. + startupSettled: !session.shellReady.isGatingWrites } : { state: 'ended' } } diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 80b34937a253..661b0f5ac250 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -611,18 +611,21 @@ describe('LocalPtyProvider', () => { expect(committed).toHaveBeenCalledOnce() }) - it('keeps a switch start bound to its process past a failed publication, until it exits', async () => { + it('keeps a switch start bound to its process past a failed commit callback, until it exits', async () => { spawnMock.mockClear() const switchOwner = (await provider.terminalSwitches.owner()) ?? '' const seen = async () => (await provider.terminalSwitches.observe('g-local', true))[0] - provider.configure({ - onSpawned: () => { - throw new Error('post-spawn publication failed') - } - }) const start = () => - provider.spawn({ cols: 80, rows: 24, switchGeneration: 'g-local', switchOwner }) - await expect(start()).rejects.toThrow('post-spawn publication failed') + provider.spawn({ + cols: 80, + rows: 24, + switchGeneration: 'g-local', + switchOwner, + onPtySpawnCommitted: () => { + throw new Error('commit callback failed') + } + }) + await expect(start()).rejects.toThrow('commit callback failed') expect((await seen())?.observation).toMatchObject({ state: 'bound', shellPid: mockProc.pid }) await expect(start()).rejects.toThrow('terminal_switch_generation_refused') expect(spawnMock).toHaveBeenCalledOnce() diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index f8e6e75053e3..a1fe6222a2ad 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -133,6 +133,13 @@ export async function spawnLocalPty( windowsFallbackAttempts: plan.windowsFallbackAttempts }) const spawnResult = pendingSpawn instanceof Promise ? await pendingSpawn : pendingSpawn + // The process exists: from here only its own exit ends the switch, whatever throws next. + committed = true + commitLocalTerminalSwitch(args.switchGeneration, { + id, + incarnationId, + proc: spawnResult.process + }) try { checkCanceled() } catch (error) { @@ -144,12 +151,6 @@ export async function spawnLocalPty( throw error } args.onPtySpawnCommitted?.() - committed = true - commitLocalTerminalSwitch(args.switchGeneration, { - id, - incarnationId, - proc: spawnResult.process - }) plan.shellPath = spawnResult.shellPath // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { diff --git a/src/main/providers/terminal-switch-owners.ts b/src/main/providers/terminal-switch-owners.ts index 37207763bd5a..ca600b02e2ea 100644 --- a/src/main/providers/terminal-switch-owners.ts +++ b/src/main/providers/terminal-switch-owners.ts @@ -9,13 +9,11 @@ export function routedTerminalSwitchOwners( ): TerminalSwitchOwners { return { owner: async () => (await fresh().terminalSwitches?.owner()) ?? null, - // An owner that cannot answer is left out, so the caller reads the start as unverifiable. + // One owner that cannot answer fails the whole read: it may be the one that holds the start. observe: async (generation, fence) => ( await Promise.all( - all().map( - (owner) => owner.terminalSwitches?.observe(generation, fence).catch(() => []) ?? [] - ) + all().map((owner) => owner.terminalSwitches?.observe(generation, fence) ?? []) ) ).flat() } diff --git a/src/main/runtime/agent-session-record-terminal-switch.ts b/src/main/runtime/agent-session-record-terminal-switch.ts index 8303517df198..77c53f801bd5 100644 --- a/src/main/runtime/agent-session-record-terminal-switch.ts +++ b/src/main/runtime/agent-session-record-terminal-switch.ts @@ -2,19 +2,22 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { TerminalSwitchWrite } from '../../shared/terminal-switch' /** Unfenced like the name: the descriptor is the switch owner's obligation, not the writer lease. - * A clear names its generation, so an old completion never clears a newer switch. */ + * Compare-and-set on generation, so a stale collector never resurrects or replaces another. */ export function setAgentSessionRecordTerminalSwitch( record: AgentSessionRecord, - descriptor: TerminalSwitchWrite, + write: TerminalSwitchWrite, now: number ): AgentSessionRecord { - if ('clear' in descriptor) { - if (record.terminalSwitch?.generation !== descriptor.clear) { + const current = record.terminalSwitch?.generation + if ('clear' in write) { + if (current !== write.clear) { return record } const next = { ...record, updatedAt: now } delete next.terminalSwitch return next } - return { ...record, terminalSwitch: descriptor, updatedAt: now } + const descriptor = 'accept' in write ? write.accept : write.update + const applies = current === descriptor.generation || ('accept' in write && current === undefined) + return applies ? { ...record, terminalSwitch: descriptor, updatedAt: now } : record } diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index f31994b57b29..ece53a621ef6 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -35,18 +35,21 @@ export function projectRuntimeMobileSessionTabs( // and embedders that have not adopted the narrow getter. let hookRowsByPaneKey: Map | null = null const hookRowsForPane = new Map() + // A switch's launch identity names one exact execution; a replacement there never wears it. + const current = ({ launchExecution: stamp }: AgentStatusIpcPayload) => + !stamp || host.ptysById.get(stamp.ptyId)?.incarnationId === stamp.incarnationId const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { const cached = hookRowsForPane.get(paneKey) if (cached) { return cached } - const direct = host.getProviderSessionRows(paneKey) - if (direct) { - hookRowsForPane.set(paneKey, direct) - return direct - } - hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot()) - const rows = hookRowsByPaneKey.get(paneKey) ?? [] + const rows = ( + host.getProviderSessionRows(paneKey) ?? + (hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot())).get( + paneKey + ) ?? + [] + ).filter(current) hookRowsForPane.set(paneKey, rows) return rows } diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 677355129b6e..4caaa68e404d 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -190,8 +190,8 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca await this.assertSwitchStartsEnded(agent, id).catch(() => undefined) } - /** A switch's start holds its conversation until its owner proves it over, or its startup has - * settled, every agent recorded there is proved exited and that exact shell is alone. */ + /** A switch's start holds its conversation until every agent recorded there is proved exited and + * either its owner proves the execution ended, or its startup settled with that exact shell alone. */ private async assertSwitchStartsEnded(agent: string, id: string): Promise { for (const claim of this.switchClaimsFor(agent, id)) { if (!claim.accepted) { @@ -202,16 +202,19 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca await this.noteSwitchBound(claim, seen) } const pane = seen.state === 'bound' ? this.cliWriterPtyPane(seen.ptyId) : null - const agentsEnded = - seen.state === 'bound' && - seen.startupSettled && - claim.processes.length > 0 && - pane?.incarnationId === seen.incarnationId && - (await Promise.all(claim.processes.map(switchStartProcessVerdict))).every( - (verdict) => verdict === 'exited' - ) && - (await this.cliWriterShellAlone(pane)) - if (seen.state === 'ended' || agentsEnded) { + // A root exit is not the agents' exit: a detached one outlives its shell. + const agentsExited = ( + await Promise.all(claim.processes.map(switchStartProcessVerdict)) + ).every((verdict) => verdict === 'exited') + const over = + agentsExited && + (seen.state === 'ended' || + (seen.state === 'bound' && + seen.startupSettled && + claim.processes.length > 0 && + pane?.incarnationId === seen.incarnationId && + (await this.cliWriterShellAlone(pane)))) + if (over) { await this.releaseSwitchClaim(claim) continue } diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts index 5f484e17ff90..a7de70be471a 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -12,6 +12,8 @@ import { type TerminalSwitchDescriptor, type TerminalSwitchWrite } from '../../../shared/terminal-switch' +import { setAgentSessionRecordTerminalSwitch } from '../agent-session-record-terminal-switch' +import { routedTerminalSwitchOwners } from '../../providers/terminal-switch-owners' import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import type { SwitchClaim } from './structured-session-switch-claims' @@ -21,6 +23,9 @@ vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() }, app: { getPath: vi.fn(() => '/tmp') } })) +vi.mock('../../agent-hooks/managed-hook-owner-identity', () => ({ + readBootIdentity: async () => boot +})) vi.mock('./structured-session-switch-observation', () => ({ readSwitchPaneAgentProcesses: vi.fn(async () => []), readSwitchShellProcess: vi.fn(async (pid: number) => ({ @@ -40,6 +45,7 @@ let runtime: OrcaRuntimeService let host: TerminalHostSwitches let shellAlone: boolean let ownerReachable: boolean +let boot = 'boot-1' /** The chat records' saved switch descriptors, keyed by chat session id. */ let saved: Map @@ -49,44 +55,60 @@ const reason = (promise: Promise) => () => 'admitted', (error: { refusal?: { details?: { reason?: string } } }) => error.refusal?.details?.reason ) -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner reads only these four session facts. -const session = (facts: Partial) => ({ ...facts }) as Session +const session = (facts: Partial) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner reads only these session facts. + ({ shellReady: { isGatingWrites: false }, ...facts }) as Session +const live = () => session({ isAlive: true, sessionId: 'pty-1', incarnationId: 'i1', pid: 4242 }) beforeEach(() => { shellAlone = true ownerReachable = true + boot = 'boot-1' saved = new Map() host = new TerminalHostSwitches() runtime = new OrcaRuntimeService() runtime.registerPty('pty-1', 'wt-1', null, { tabId: 'tab-1', leafId: LEAF, incarnationId: 'i1' }) - runtime.setPtyController({ + const reach = { terminalSwitches: { owner: async () => host.owner, - observe: async (generation, fence) => { + observe: async (generation: string, fence: boolean) => { if (!ownerReachable) { - throw new Error('host unreachable') + throw new Error('terminal_switch_owner_unreachable') } return [host.observe(generation, fence)] } } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads only its owner contract. - } as never) + } + // The production routing: an unreachable owner must stay unreachable through it. + const terminalSwitches = routedTerminalSwitchOwners( + () => reach, + () => [reach, {}] + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads only its owner contract. + runtime.setPtyController({ terminalSwitches } as never) vi.spyOn(runtime, 'openAgentSessionRecordStore').mockResolvedValue( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads records and writes descriptors only. { listRecords: () => [...saved].map(([sessionId, terminalSwitch]) => ({ sessionId, terminalSwitch })), setTerminalSwitch: async (sessionId: string, write: TerminalSwitchWrite) => { - if (!('clear' in write)) { - saved.set(sessionId, write) - } else if (saved.get(sessionId)?.generation === write.clear) { + const current = saved.get(sessionId) + const record = { + ...codexChatHolding(sessionId, [C]), + ...(current ? { terminalSwitch: current } : {}) + } + const next = setAgentSessionRecordTerminalSwitch(record, write, 0) + if (next.terminalSwitch) { + saved.set(sessionId, next.terminalSwitch) + } else { saved.delete(sessionId) } + return next } } as never ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census's one process-table read, replaced by the pane's modeled state. vi.spyOn( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census's one process-table read, replaced by the pane's modeled state. runtime as never as { cliWriterShellAlone: () => Promise }, 'cliWriterShellAlone' ).mockImplementation(async () => shellAlone) @@ -118,12 +140,16 @@ describe('a chat → terminal switch start', () => { 'terminal_switch_generation_refused' ) - const shell = session({ isAlive: true, sessionId: 'pty-1', incarnationId: 'i1', pid: 4242 }) + const shell = live() host.bind(claim.generation, shell) // Bound with no agent recorded: only the owner's end releases it. expect(await reason(chatStart())).toBe('conversationHeldElsewhere') expect(saved.get('chat-1')).toMatchObject({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL }) + runtime['noteSwitchStartProcess']('pty-1', { agent: 'codex', id: C }, AGENT) + // The shell exiting is not its detached agent exiting. Object.assign(shell, { isAlive: false }) + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') expect(await reason(chatStart())).toBe('admitted') expect(saved.has('chat-1')).toBe(false) }) @@ -131,10 +157,7 @@ describe('a chat → terminal switch start', () => { it('releases on a proved agent exit with that exact shell alone, keeping the shell', async () => { const claim = await acceptedSwitch() host.begin(claim.generation, host.owner) - host.bind( - claim.generation, - session({ isAlive: true, sessionId: 'pty-1', incarnationId: 'i1', pid: 4242 }) - ) + host.bind(claim.generation, live()) expect(await reason(chatStart())).toBe('conversationHeldElsewhere') runtime['noteSwitchStartProcess']('pty-1', { agent: 'codex', id: C }, AGENT) vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') @@ -144,6 +167,15 @@ describe('a chat → terminal switch start', () => { expect(await reason(chatStart())).toBe('admitted') }) + it('never lets a retired switch’s late evidence replace the next one', async () => { + const a = await acceptedSwitch() + expect(await reason(chatStart())).toBe('admitted') + const b = await acceptedSwitch() + a.processes.push(AGENT) + expect(await runtime.saveSwitchClaim(a)).toBe(false) + expect(saved.get('chat-1')?.generation).toBe(b.generation) + }) + it('fences a saved start no terminal took, so it can never start later', async () => { const claim = await acceptedSwitch() expect(await reason(chatStart())).toBe('admitted') @@ -152,18 +184,19 @@ describe('a chat → terminal switch start', () => { ) }) - it('survives a restart: a lost owner never speaks, only the recorded execution can end it', async () => { - saved.set('chat-1', { - generation: 'g-old', - agent: 'codex', - providerSessionId: C, - owner: 'daemon:gone', - paneKey: PANE, - ptyId: 'pty-1', - incarnationId: 'i1', - shell: SHELL, - processes: [AGENT] - }) + it('survives a restart: an unreachable owner holds; a lost one yields only to proof', async () => { + const lost = (extra: Partial) => + saved.set('chat-1', { + generation: 'g-old', + agent: 'codex', + providerSessionId: C, + owner: 'daemon:gone', + paneKey: PANE, + processes: [], + boot: 'boot-1', + ...extra + }) + lost({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL, processes: [AGENT] }) ownerReachable = false vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') expect(await reason(chatStart())).toBe('conversationHeldElsewhere') @@ -172,6 +205,13 @@ describe('a chat → terminal switch start', () => { expect(await reason(chatStart())).toBe('conversationHeldElsewhere') vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') expect(await reason(chatStart())).toBe('admitted') + + // A crash before any identity was learned stays held until the host has rebooted. + lost({}) + runtime['switchClaimsLoaded'] = null + expect(await reason(chatStart())).toBe('conversationHeldElsewhere') + boot = 'boot-2' + expect(await reason(chatStart())).toBe('admitted') expect(saved.has('chat-1')).toBe(false) }) }) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts index 13f28ebf3c85..b8533dd6fdd3 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -11,6 +11,7 @@ import { type TerminalSwitchObservation } from '../../../shared/terminal-switch' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' +import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' import { readSwitchPaneAgentProcesses, readSwitchShellProcess, @@ -27,6 +28,10 @@ export type SwitchClaim = TerminalSwitchDescriptor & { chatSessionId: string at: number accepted: boolean + /** The descriptor holds this generation; later evidence only updates it. */ + saved: boolean + /** Evidence learned but not yet saved; the next observation retries it. */ + unsaved?: boolean } export const switchMovingRefusal = () => @@ -56,7 +61,13 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR (store) => { for (const { sessionId, terminalSwitch } of store.listRecords()) { if (isTerminalSwitchDescriptor(terminalSwitch)) { - const claim = { ...terminalSwitch, chatSessionId: sessionId, at: 0, accepted: true } + const claim = { + ...terminalSwitch, + chatSessionId: sessionId, + at: 0, + accepted: true, + saved: true + } this.switchClaims.set( terminalSwitch.generation, this.switchClaims.get(claim.generation) ?? claim @@ -103,7 +114,8 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR processes: [], chatSessionId, at: Date.now(), - accepted: false + accepted: false, + saved: false } this.switchClaims.set(claim.generation, claim) return claim @@ -132,14 +144,21 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR return claim } - /** Saved before dispatch, so a restart still knows the start may exist. */ + /** Saved before dispatch, so a restart still knows the start may exist. A retired claim's + * late evidence never writes, and the store only updates the generation it holds. */ async saveSwitchClaim(claim: SwitchClaim): Promise { - const { chatSessionId, at: _at, accepted: _accepted, ...descriptor } = claim + claim.boot ??= await readBootIdentity() + const { chatSessionId, at: _at, accepted: _a, saved, unsaved: _u, ...descriptor } = claim + if (saved && this.switchClaims.get(claim.generation) !== claim) { + return false + } const store = await this.openAgentSessionRecordStore() - return store.setTerminalSwitch(chatSessionId, descriptor).then( - () => true, - () => false - ) + const next = await store + .setTerminalSwitch(chatSessionId, saved ? { update: descriptor } : { accept: descriptor }) + .catch(() => null) + const ok = next?.terminalSwitch?.generation === claim.generation + Object.assign(claim, { saved: saved || ok, unsaved: !ok }) + return ok } /** The owner's fields for the dispatch; evidence collection starts with it and decides nothing. */ @@ -160,9 +179,15 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR ?.observe(claim.generation, fence) .catch(() => null) const own = answers?.find((answer) => answer.owner === claim.owner) + // An owner that could not answer is not a lost one. if (own || !answers) { return own?.observation ?? { state: 'unverifiable' } } + // Every reachable owner answered and none is this one: only the execution can end it. + const boot = claim.boot ? await readBootIdentity() : undefined + if (boot && boot !== claim.boot) { + return { state: 'ended' } + } const recorded = claim.shell ? [claim.shell, ...claim.processes] : [] const verdicts = await Promise.all(recorded.map(switchStartProcessVerdict)) return recorded.length > 0 && verdicts.every((verdict) => verdict === 'exited') @@ -175,7 +200,12 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR claim: SwitchClaim, bound: Extract ): Promise { - if (claim.ptyId === bound.ptyId && claim.incarnationId === bound.incarnationId) { + if ( + claim.ptyId === bound.ptyId && + claim.incarnationId === bound.incarnationId && + (claim.shell || !bound.shellPid) && + !claim.unsaved + ) { return } const shell = bound.shellPid ? await readSwitchShellProcess(bound.shellPid) : null diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 9f796ea118bc..c068e1f348e2 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -15,9 +15,13 @@ const mocks = vi.hoisted(() => { activate: vi.fn(async () => true), activateVerified: vi.fn(async () => 'activated'), switchCapability: vi.fn(async () => false), - exitWatchers: new Map void>() + exitWatchers: new Map void>(), + inspect: vi.fn(async (_ptyId: string): Promise<{ foregroundProcessEvidence?: unknown }> => ({})) } }) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: mocks.inspect +})) vi.mock('../terminal-pane/pty-dispatcher', () => ({ subscribeToPtyExit: (ptyId: string, watcher: () => void) => { mocks.exitWatchers.set(ptyId, watcher) @@ -317,6 +321,32 @@ describe('CLI identity resume through the existing launch owner', () => { expect(closed).toHaveBeenCalledOnce() expect(closed.mock.calls[0]?.[0].detail).toEqual({ tabId: 'tab-1', leafId }) expect(tabs()).toEqual([]) + + // A paired source retires only while its host still reports the incarnation it started with. + const remote = 'remote:env@@handle-1' + mocks.source = { ...mocks.source, ptyId: remote } + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: remote } + } + } + }) + const host = (...incarnations: string[]) => + incarnations.forEach((ptyIncarnationId) => + mocks.inspect.mockResolvedValueOnce({ foregroundProcessEvidence: { ptyIncarnationId } }) + ) + window.addEventListener('orca-close-terminal-pane', closed) + host('i1', 'i2') + await runCliChatResume(mocks.source) + expect(closed).toHaveBeenCalledOnce() + host('i1', 'i1') + await runCliChatResume(mocks.source) + window.removeEventListener('orca-close-terminal-pane', closed) + expect(closed).toHaveBeenCalledTimes(2) }) it('keeps the eager History joiner failed and retryable after the hidden starter is refused', async () => { diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 37729ff45ee2..f81b2b805921 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -17,15 +17,33 @@ import { reportCliChatResumeFailure } from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' import { panePtyId } from '@/lib/ai-vault-tab-title-requests' import { subscribeToPtyExit } from '../terminal-pane/pty-dispatcher' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' + +/** The incarnation the execution host reports for a PTY; null when it reports none. */ +const hostPtyIncarnation = (ptyId: string): Promise => + inspectRuntimeTerminalProcess(ptyId).then( + (inspection) => inspection.foregroundProcessEvidence?.ptyIncarnationId ?? null, + () => null + ) /** The chat took the conversation, so the terminal pane it came from closes (its tab with it): - * only while that pane still holds the execution it came from, never a sibling or replacement. */ -function retireSwitchedTerminalPane(source: CliChatResumeSource, sourceExited: boolean): void { + * only while the host still reports the execution it came from, never a sibling or replacement. + * A local PTY whose host reports no incarnation is fenced by its exit, which precedes reuse. */ +async function retireSwitchedTerminalPane( + source: CliChatResumeSource, + sourceExited: () => boolean, + expected: Promise +): Promise { const pane = parsePaneKey(source.paneKey) + const incarnation = await expected if ( pane && - !sourceExited && source.ptyId && + (incarnation + ? (await hostPtyIncarnation(source.ptyId)) === incarnation + : !isRemoteRuntimePtyId(source.ptyId)) && + !sourceExited() && panePtyId(useAppStore.getState(), source.paneKey) === source.ptyId ) { const detail: CloseTerminalPaneDetail = { tabId: pane.tabId, leafId: pane.leafId } @@ -97,6 +115,7 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise { sourceExited = true @@ -173,21 +192,21 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise sourceExited, sourceIncarnation) } else if ( result.kind === 'failed' && !result.notified && pending.get(key) === controller && (await reportCliChatResumeFailure(source, result.error, target)) ) { - retireSwitchedTerminalPane(source, sourceExited) + await retireSwitchedTerminalPane(source, () => sourceExited, sourceIncarnation) } } catch (error) { if ( pending.get(key) === controller && (await reportCliChatResumeFailure(source, error, target)) ) { - retireSwitchedTerminalPane(source, sourceExited) + await retireSwitchedTerminalPane(source, () => sourceExited, sourceIncarnation) } } finally { clearTimeout(timer) diff --git a/src/shared/terminal-switch.ts b/src/shared/terminal-switch.ts index 0801c83ced9d..76596d545a5f 100644 --- a/src/shared/terminal-switch.ts +++ b/src/shared/terminal-switch.ts @@ -41,13 +41,18 @@ export type TerminalSwitchDescriptor = { incarnationId?: string shell?: AgentProcessIdentity processes: AgentProcessIdentity[] + /** The execution host's boot when accepted: a later boot proves its processes gone. */ + boot?: string } /** The exact execution a switch's launch identity row describes; presentation binds to it. */ export type TerminalExecutionStamp = { ptyId: string; incarnationId: string } -/** A save, or a clear that names the generation it ends. */ -export type TerminalSwitchWrite = TerminalSwitchDescriptor | { clear: string } +/** Each names its generation: an acceptance takes an empty slot, an update or a clear only its own. */ +export type TerminalSwitchWrite = + | { accept: TerminalSwitchDescriptor } + | { update: TerminalSwitchDescriptor } + | { clear: string } const MAX = 512 const bounded = (value: unknown): value is string => @@ -60,7 +65,7 @@ export function isTerminalSwitchDescriptor(value: unknown): value is TerminalSwi const d: Partial> = value return ( [d.generation, d.agent, d.providerSessionId, d.owner, d.paneKey].every(bounded) && - [d.ptyId, d.incarnationId].every((field) => field === undefined || bounded(field)) && + [d.ptyId, d.incarnationId, d.boot].every((field) => field === undefined || bounded(field)) && (d.shell === undefined || readAgentProcessIdentity(d.shell) !== undefined) && Array.isArray(d.processes) && d.processes.length <= 16 && From 0fd319b22e2a9729aa49bc566d5c067507af1816 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:32:19 -0700 Subject: [PATCH 018/207] test: document snapshot runtime fixture boundary --- .../methods/terminal-stream-extraction-characterization.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts index 97a4b1100b65..1066394cbf8e 100644 --- a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -120,6 +120,7 @@ describe('terminal stream extraction characterization', () => { pendingOutputOverflowed: true, outputBatcher: { push: vi.fn(), flush: vi.fn() } } as unknown as TerminalMultiplexStream + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture supplies the runtime reads made by initial snapshot publication. const runtime = { getPtyExecutionHostMetadata: () => ({}), getTerminalLaunchResult: () => undefined, From 839ff7f4630a14b908de6de53792641c34d11ed8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:38:43 -0700 Subject: [PATCH 019/207] fix(native-chat): prove a later boot on every platform and restamp a reattached switch terminal The saved switch keeps the host boot as its id where the OS has one plus the boot time derived from uptime, so Windows can also prove a later boot (by more than 60 seconds). A reattached switch terminal regains its exact-execution stamp from the saved switch, without persisting it. --- .../structured-session-switch-boot.ts | 23 ++++++++++ .../structured-session-switch-claims.test.ts | 44 ++++++++++++++++++- .../structured-session-switch-claims.ts | 43 ++++++++++++++++-- 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/session-tabs/structured-session-switch-boot.ts diff --git a/src/main/runtime/session-tabs/structured-session-switch-boot.ts b/src/main/runtime/session-tabs/structured-session-switch-boot.ts new file mode 100644 index 000000000000..301bba19f4e7 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-boot.ts @@ -0,0 +1,23 @@ +import { uptime } from 'node:os' +import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' + +/** A later boot by more than this; clock steps and uptime rounding stay under it. */ +const LATER_BOOT_MARGIN_MS = 60_000 + +/** The execution host's boot: its id where the OS has one, and the derived boot time everywhere. */ +export async function readSwitchHostBoot(): Promise { + return `${(await readBootIdentity()) ?? ''}@${Math.round(Date.now() - uptime() * 1000)}` +} + +const parse = (boot: string): [string, number] => { + const at = boot.lastIndexOf('@') + return at === -1 ? ['', Number.NaN] : [boot.slice(0, at), Number(boot.slice(at + 1))] +} + +/** A later boot proves every process of the saved one gone; an unreadable value never does. + * Windows Fast Startup keeps uptime across a shutdown, so that boot reads as the same one. */ +export function isLaterSwitchHostBoot(saved: string, current: string): boolean { + const [savedId, savedAt] = parse(saved) + const [id, at] = parse(current) + return savedId && id ? savedId !== id : at - savedAt > LATER_BOOT_MARGIN_MS +} diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts index a7de70be471a..152d4eed2408 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -14,6 +14,8 @@ import { } from '../../../shared/terminal-switch' import { setAgentSessionRecordTerminalSwitch } from '../agent-session-record-terminal-switch' import { routedTerminalSwitchOwners } from '../../providers/terminal-switch-owners' +import { isLaterSwitchHostBoot } from './structured-session-switch-boot' +import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import type { SwitchClaim } from './structured-session-switch-claims' @@ -26,6 +28,7 @@ vi.mock('electron', () => ({ vi.mock('../../agent-hooks/managed-hook-owner-identity', () => ({ readBootIdentity: async () => boot })) +vi.mock('../../ipc/pty/pane/resume-launch-identity', () => ({ seedResumeLaunchIdentity: vi.fn() })) vi.mock('./structured-session-switch-observation', () => ({ readSwitchPaneAgentProcesses: vi.fn(async () => []), readSwitchShellProcess: vi.fn(async (pid: number) => ({ @@ -193,7 +196,7 @@ describe('a chat → terminal switch start', () => { owner: 'daemon:gone', paneKey: PANE, processes: [], - boot: 'boot-1', + boot: 'boot-1@0', ...extra }) lost({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL, processes: [AGENT] }) @@ -214,6 +217,45 @@ describe('a chat → terminal switch start', () => { expect(await reason(chatStart())).toBe('admitted') expect(saved.has('chat-1')).toBe(false) }) + + it('reads a later boot from the id where there is one, else from the derived boot time', () => { + expect(isLaterSwitchHostBoot('a@0', 'a@999999')).toBe(false) + expect(isLaterSwitchHostBoot('a@0', 'b@0')).toBe(true) + // Windows: no id, so only a boot time clearly past rounding and clock steps counts. + expect(isLaterSwitchHostBoot('@1000', '@61000')).toBe(false) + expect(isLaterSwitchHostBoot('@1000', '@3600000')).toBe(true) + expect(isLaterSwitchHostBoot('unreadable', '@3600000')).toBe(false) + }) + + it('restamps a reattached switch terminal from the saved switch after a restart', async () => { + const claim = await acceptedSwitch() + Object.assign(claim, { ptyId: 'pty-1', incarnationId: 'i1' }) + const providerSession = { key: 'session_id' as const, id: C } + vi.spyOn(runtime, 'getAgentProviderSessionRows').mockReturnValue([ + { + paneKey: PANE, + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + state: 'done', + prompt: '', + providerSession + } + ]) + const reattach = (incarnationId: string) => + runtime.registerPty('pty-1', 'wt-1', null, { + tabId: 'tab-1', + leafId: LEAF, + incarnationId, + providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' } + }) + reattach('i2') + reattach('i1') + await vi.waitFor(() => expect(seedResumeLaunchIdentity).toHaveBeenCalledOnce()) + expect(seedResumeLaunchIdentity).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, execution: { ptyId: 'pty-1', incarnationId: 'i1' } }) + ) + }) }) describe('a switch spawn', () => { diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts index b8533dd6fdd3..4f62f7ce8874 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -11,7 +11,9 @@ import { type TerminalSwitchObservation } from '../../../shared/terminal-switch' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' -import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' +import { isLaterSwitchHostBoot, readSwitchHostBoot } from './structured-session-switch-boot' +import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' +import { adoptsCliConversations } from '../../../shared/structured-agent-cli-conversation-adoption' import { readSwitchPaneAgentProcesses, readSwitchShellProcess, @@ -83,6 +85,40 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR return this.switchClaimsLoaded } + /** A reattached switch terminal regains its exact-execution stamp on the host's identity row, + * rebuilt from the saved switch: the stamp itself is never persisted. */ + override registerPty(...args: Parameters): void { + super.registerPty(...args) + const [ptyId, worktreeId, , binding] = args + const reattached = binding?.providerReattachLaunchIdentity + if (!reattached || !adoptsCliConversations(reattached.launchAgent)) { + return + } + const { incarnationId } = reattached + void this.loadSwitchClaims().then( + () => { + for (const claim of this.switchClaims.values()) { + const row = this.getAgentProviderSessionRows().find( + (candidate) => + candidate.paneKey === claim.paneKey && + candidate.providerSession?.id === claim.providerSessionId + ) + if (claim.ptyId === ptyId && claim.incarnationId === incarnationId && row) { + seedResumeLaunchIdentity({ + paneKey: claim.paneKey, + launchAgent: claim.agent, + resumeProviderSession: row.providerSession, + worktreeId, + launchedAt: 0, + execution: { ptyId, incarnationId } + }) + } + } + }, + () => undefined + ) + } + /** Throws while a switch of this conversation exists: one no terminal took yet, or with `all`, any. */ protected assertNoPendingSwitch(agent: string, id: string, all = false): void { if (this.switchClaimsFor(agent, id).some((claim) => all || !claim.accepted)) { @@ -147,7 +183,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR /** Saved before dispatch, so a restart still knows the start may exist. A retired claim's * late evidence never writes, and the store only updates the generation it holds. */ async saveSwitchClaim(claim: SwitchClaim): Promise { - claim.boot ??= await readBootIdentity() + claim.boot ??= await readSwitchHostBoot() const { chatSessionId, at: _at, accepted: _a, saved, unsaved: _u, ...descriptor } = claim if (saved && this.switchClaims.get(claim.generation) !== claim) { return false @@ -184,8 +220,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR return own?.observation ?? { state: 'unverifiable' } } // Every reachable owner answered and none is this one: only the execution can end it. - const boot = claim.boot ? await readBootIdentity() : undefined - if (boot && boot !== claim.boot) { + if (claim.boot && isLaterSwitchHostBoot(claim.boot, await readSwitchHostBoot())) { return { state: 'ended' } } const recorded = claim.shell ? [claim.shell, ...claim.processes] : [] From 406b81930a5b4e5dcf09ff98ec122f5e2fbf8af9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:47:54 -0700 Subject: [PATCH 020/207] fix: preserve terminal launch identity types --- .../agent-hooks/server/server-tab-cleanup.ts | 4 ++++ .../ipc/pty/host-env/saved-resume-admission.ts | 1 + src/main/ipc/pty/ipc/spawn-begin.ts | 6 +++--- src/main/ipc/pty/ipc/spawn-options.ts | 5 +++-- .../runtime/spawn-preflight-saved-resume.test.ts | 1 + .../empty-reattach-retire-evidence.test.ts | 1 + ...ch-result-handler-park-reveal-verdict.test.ts | 1 + .../terminal-launch-result-policy.ts | 16 +++++----------- ...-runtime-pty-transport-create-handoff.test.ts | 3 +++ 9 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index fb9c9be59691..c21177a8bca8 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -16,6 +16,10 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { const clear = { paneKey: ownerKey, refusedResume: refused } if ( row && + 'receivedAt' in row && + typeof row.receivedAt === 'number' && + 'stateStartedAt' in row && + typeof row.stateStartedAt === 'number' && refused.expectedStatus && row.receivedAt <= refused.expectedStatus.receivedAt && row.stateStartedAt === refused.expectedStatus.stateStartedAt && diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index 65296323394c..af11bc574e6e 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -102,6 +102,7 @@ export async function admitColdRestoreReattach( row.state !== 'done' && !row.structuredHost && !row.connectionId && + row.agentType && row.providerSession ? [{ agent: row.agentType, providerSession: row.providerSession }] : [] diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 716ef5852152..160843f771e4 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -3,7 +3,6 @@ import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launc import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' import { agentLaunchPaneLiveness } from '../pane/agent-launch-pane-evidence' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' -import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' import { createPtySpawnTiming } from '../../pty-spawn-timing' import { allocatePtyLifecycleSequence } from '../host-env/types' @@ -13,7 +12,8 @@ import { makePaneSpawnReservationKey, reservePaneSpawn, paneSpawnReservationsByOwnerKey, - pendingRuntimePaneCreatesByOwnerKey + pendingRuntimePaneCreatesByOwnerKey, + type PaneSpawnReservationResult } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' @@ -39,7 +39,7 @@ export function resolveEarlyPaneSpawnReservationKey(args: PtySpawnIpcArgs): stri export async function beginPtyIpcSpawn( ctx: PtyIpcSpawnState -): Promise { +): Promise { const args = ctx.args // A pane an agent launch laid out attaches to its agent or says why it can't, never runs a shell. // A replacing spawn already holds the pane: the user restarted it, and that is theirs to run. diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 31dfdfc6c177..db9ea2ddd49f 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -5,7 +5,8 @@ import { makePaneSpawnReservationKey, reservePaneSpawn, paneSpawnReservationsByOwnerKey, - pendingRuntimePaneCreatesByOwnerKey + pendingRuntimePaneCreatesByOwnerKey, + type PaneSpawnReservationResult } from '../pane/spawn-reservation' import { assemblePtySpawnEnvDeletions, @@ -18,7 +19,7 @@ import type { PtyIpcSpawnState } from './spawn-state' /** Window lane: the shared spawn options plus this lane's pane reservation. */ export async function buildPtyIpcSpawnOptions( ctx: PtyIpcSpawnState -): Promise<{ isReattach: true } | null> { +): Promise { const args = ctx.args const settings = ctx.deps.getSettings?.() const deletions = assemblePtySpawnEnvDeletions({ diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index f83496bde996..d7be1bc34484 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -64,6 +64,7 @@ function chatHost() { getAgentProviderSessionRows: () => [ { paneKey: 'tab-1:leaf', + connectionId: null, agentType: 'claude' as const, state: 'working' as const, prompt: '', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts index 086cfb820811..b264a057f118 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.test.ts @@ -27,6 +27,7 @@ function resumeStartup( return { command: 'codex resume conv-1', agent: 'codex', + resumeOrigin: 'saved', resumeProviderSession: { key: 'session_id', id: 'conv-1' }, launchConfig: { agentArgs: '', agentEnv: {} }, launchToken: 'token-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts index 7d38b77b4864..f0f47c9867e8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler-park-reveal-verdict.test.ts @@ -356,6 +356,7 @@ describe('handleReattachResult empty reattach under a hibernation note', () => { const startup: ColdRestoreAgentResumeStartup = { command: 'codex resume conv-1', agent: 'codex', + resumeOrigin: 'saved', resumeProviderSession: record.providerSession, launchConfig: { agentArgs: '', agentEnv: {} }, launchToken: 'token-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts index 487be5006014..49400e22ba4e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts @@ -7,18 +7,12 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { PtyPaneStartup } from '../pty-connection-types' -type LaunchResultSession = Pick< - ConnectPanePtySession, - | 'cacheKey' - | 'paneStartup' - | 'cancelSavedResumeStartup' - | 'registerEffectiveLaunchConfig' - | 'showSessionRestoredBanner' -> - type SavedStartup = ColdRestoreAgentResumeStartup | PtyPaneStartup -export function captureTerminalLaunchContext(session: LaunchResultSession, startup: SavedStartup) { +export function captureTerminalLaunchContext( + session: ConnectPanePtySession, + startup: SavedStartup +) { const state = useAppStore.getState() return { startup, @@ -31,7 +25,7 @@ export function captureTerminalLaunchContext(session: LaunchResultSession, start export type TerminalLaunchContext = ReturnType export function applyTerminalLaunchResult( - session: LaunchResultSession, + session: ConnectPanePtySession, result: TerminalLaunchResult & { isReattach?: boolean }, context: TerminalLaunchContext, kind: 'creator' | 'observer' | 'candidate' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts index 2fe8fc0daf27..1cdd5cfc05c6 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts @@ -531,6 +531,9 @@ describe('createRemoteRuntimePtyTransport', () => { const onConnect = vi.fn() const result = await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) try { + if (!result || typeof result === 'string') { + throw new Error('Expected a terminal connection result') + } expect(runtimeCall).toHaveBeenCalledWith( expect.objectContaining({ method: 'terminal.create' }) ) From e8ce63031d7e54fa09ecf6aa88da38b1ba8c27c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:54:18 -0700 Subject: [PATCH 021/207] fix(native-chat): no clock-only reboot release, host-fenced paired retirement, owner-recovered restamp - Only a changed OS boot id proves a later boot; Windows (no id) never releases on a wall clock. - A paired switch back closes its terminal through the host's terminal.close under the incarnation it started with; the host refuses a replacement under the same handle. - After a restart, a reattached switch terminal asks the original owner for its binding, saves it, and restamps only while that exact execution is still the pane's. - Saved evidence is a copy, so the frozen journal row never freezes the live claim's processes. - A failed local commit callback stops that exact process; the switch ends only on its exit. --- .../local-pty-provider-spawn-session.test.ts | 9 ++- src/main/providers/local-pty-spawn.ts | 14 +++- ...-runtime-terminal-close-continuity.test.ts | 10 +++ .../terminal/terminal-lifecycle-methods.ts | 2 +- .../explicit-terminal-close-stops.ts | 6 +- .../structured-session-switch-boot.ts | 23 ------ .../structured-session-switch-claims.test.ts | 56 +++++++------- .../structured-session-switch-claims.ts | 76 ++++++++++++------- .../tab-bar/cli-chat-resume-run.test.ts | 34 ++++----- .../components/tab-bar/cli-chat-resume-run.ts | 19 +++-- 10 files changed, 137 insertions(+), 112 deletions(-) delete mode 100644 src/main/runtime/session-tabs/structured-session-switch-boot.ts diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 661b0f5ac250..6795733230ea 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -611,8 +611,9 @@ describe('LocalPtyProvider', () => { expect(committed).toHaveBeenCalledOnce() }) - it('keeps a switch start bound to its process past a failed commit callback, until it exits', async () => { + it('stops the process of a switch start whose commit callback fails, and ends on its exit', async () => { spawnMock.mockClear() + const { kill } = mockProc const switchOwner = (await provider.terminalSwitches.owner()) ?? '' const seen = async () => (await provider.terminalSwitches.observe('g-local', true))[0] const start = () => @@ -626,11 +627,11 @@ describe('LocalPtyProvider', () => { } }) await expect(start()).rejects.toThrow('commit callback failed') - expect((await seen())?.observation).toMatchObject({ state: 'bound', shellPid: mockProc.pid }) + // The failure stops that exact process, and its own exit (not the throw) ends the switch. + expect(kill).toHaveBeenCalledWith('SIGKILL') + expect((await seen())?.observation).toEqual({ state: 'ended' }) await expect(start()).rejects.toThrow('terminal_switch_generation_refused') expect(spawnMock).toHaveBeenCalledOnce() - exitCb?.({ exitCode: 0 }) - expect((await seen())?.observation).toEqual({ state: 'ended' }) }) }) }) diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index a1fe6222a2ad..7e2e903a60d9 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -150,7 +150,19 @@ export async function spawnLocalPty( } throw error } - args.onPtySpawnCommitted?.() + try { + args.onPtySpawnCommitted?.() + } catch (error) { + // A switch keeps its claim until this exact process exits; stop it so that exit comes. + if (args.switchGeneration !== undefined) { + try { + spawnResult.process.kill('SIGKILL') + } finally { + destroyPtyProcess(spawnResult.process) + } + } + throw error + } plan.shellPath = spawnResult.shellPath // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index 2e33b46e8512..e3bd90aef740 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -284,6 +284,16 @@ describe('terminal close and handle incarnation continuity', () => { expect(canary.ptyId).toBe(survivor.ptyId) }) + it('closes nothing when its handle now runs a later incarnation than the caller expected', async () => { + const harness = createHarness() + const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + await expect(harness.runtime.closeTerminal(handle, 'earlier-incarnation')).rejects.toThrow( + 'terminal_handle_stale' + ) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + it('does not kill the final PTY when durable tab retirement is rejected', async () => { const harness = createHarness() const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 56439e190811..0d0c11121726 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -213,7 +213,7 @@ export const TERMINAL_LIFECYCLE_METHODS = [ context, 'terminal', params.terminal, - () => context.runtime.closeTerminal(params.terminal) + () => context.runtime.closeTerminal(params.terminal, params.expectedIncarnationId) ) }) }), diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts index 32c9fa839ea3..9ae667fbb600 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts @@ -130,8 +130,12 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun }) } - async closeTerminal(handle: string): Promise { + async closeTerminal(handle: string, incarnation?: string): Promise { const pty = this.getLivePtyForHandle(handle) + // The handle's fence: a replacement execution under the same handle is never closed. + if (incarnation && pty?.pty.incarnationId !== incarnation) { + throw new Error('terminal_handle_stale') + } this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { const closeAuthority: RuntimePtyTabCloseAuthority = { diff --git a/src/main/runtime/session-tabs/structured-session-switch-boot.ts b/src/main/runtime/session-tabs/structured-session-switch-boot.ts deleted file mode 100644 index 301bba19f4e7..000000000000 --- a/src/main/runtime/session-tabs/structured-session-switch-boot.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { uptime } from 'node:os' -import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' - -/** A later boot by more than this; clock steps and uptime rounding stay under it. */ -const LATER_BOOT_MARGIN_MS = 60_000 - -/** The execution host's boot: its id where the OS has one, and the derived boot time everywhere. */ -export async function readSwitchHostBoot(): Promise { - return `${(await readBootIdentity()) ?? ''}@${Math.round(Date.now() - uptime() * 1000)}` -} - -const parse = (boot: string): [string, number] => { - const at = boot.lastIndexOf('@') - return at === -1 ? ['', Number.NaN] : [boot.slice(0, at), Number(boot.slice(at + 1))] -} - -/** A later boot proves every process of the saved one gone; an unreadable value never does. - * Windows Fast Startup keeps uptime across a shutdown, so that boot reads as the same one. */ -export function isLaterSwitchHostBoot(saved: string, current: string): boolean { - const [savedId, savedAt] = parse(saved) - const [id, at] = parse(current) - return savedId && id ? savedId !== id : at - savedAt > LATER_BOOT_MARGIN_MS -} diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts index 152d4eed2408..10f18d97986f 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -14,7 +14,6 @@ import { } from '../../../shared/terminal-switch' import { setAgentSessionRecordTerminalSwitch } from '../agent-session-record-terminal-switch' import { routedTerminalSwitchOwners } from '../../providers/terminal-switch-owners' -import { isLaterSwitchHostBoot } from './structured-session-switch-boot' import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import type { SwitchClaim } from './structured-session-switch-claims' @@ -48,7 +47,7 @@ let runtime: OrcaRuntimeService let host: TerminalHostSwitches let shellAlone: boolean let ownerReachable: boolean -let boot = 'boot-1' +let boot: string | undefined = 'boot-1' /** The chat records' saved switch descriptors, keyed by chat session id. */ let saved: Map @@ -100,7 +99,9 @@ beforeEach(() => { ...codexChatHolding(sessionId, [C]), ...(current ? { terminalSwitch: current } : {}) } - const next = setAgentSessionRecordTerminalSwitch(record, write, 0) + // The journal store freezes what it commits. + const next = Object.freeze(setAgentSessionRecordTerminalSwitch(record, write, 0)) + Object.freeze(next.terminalSwitch?.processes) if (next.terminalSwitch) { saved.set(sessionId, next.terminalSwitch) } else { @@ -196,7 +197,7 @@ describe('a chat → terminal switch start', () => { owner: 'daemon:gone', paneKey: PANE, processes: [], - boot: 'boot-1@0', + boot: 'boot-1', ...extra }) lost({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL, processes: [AGENT] }) @@ -209,39 +210,22 @@ describe('a chat → terminal switch start', () => { vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') expect(await reason(chatStart())).toBe('admitted') - // A crash before any identity was learned stays held until the host has rebooted. + // A crash before any identity was learned stays held until an OS boot id changes; no id + // (Windows) never proves a reboot, however far the clock moves. lost({}) runtime['switchClaimsLoaded'] = null + boot = undefined expect(await reason(chatStart())).toBe('conversationHeldElsewhere') boot = 'boot-2' expect(await reason(chatStart())).toBe('admitted') expect(saved.has('chat-1')).toBe(false) }) - it('reads a later boot from the id where there is one, else from the derived boot time', () => { - expect(isLaterSwitchHostBoot('a@0', 'a@999999')).toBe(false) - expect(isLaterSwitchHostBoot('a@0', 'b@0')).toBe(true) - // Windows: no id, so only a boot time clearly past rounding and clock steps counts. - expect(isLaterSwitchHostBoot('@1000', '@61000')).toBe(false) - expect(isLaterSwitchHostBoot('@1000', '@3600000')).toBe(true) - expect(isLaterSwitchHostBoot('unreadable', '@3600000')).toBe(false) - }) - - it('restamps a reattached switch terminal from the saved switch after a restart', async () => { + it('rebuilds a reattached switch terminal’s stamp from its owner, never onto a replacement', async () => { const claim = await acceptedSwitch() - Object.assign(claim, { ptyId: 'pty-1', incarnationId: 'i1' }) - const providerSession = { key: 'session_id' as const, id: C } - vi.spyOn(runtime, 'getAgentProviderSessionRows').mockReturnValue([ - { - paneKey: PANE, - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - state: 'done', - prompt: '', - providerSession - } - ]) + host.begin(claim.generation, host.owner) + host.bind(claim.generation, live()) + vi.mocked(seedResumeLaunchIdentity).mockClear() const reattach = (incarnationId: string) => runtime.registerPty('pty-1', 'wt-1', null, { tabId: 'tab-1', @@ -249,11 +233,23 @@ describe('a chat → terminal switch start', () => { incarnationId, providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' } }) - reattach('i2') + // Replaced before the owner's answer lands: nothing is stamped onto i2. + reattach('i1') + runtime.registerPty('pty-1', 'wt-1', null, { + tabId: 'tab-1', + leafId: LEAF, + incarnationId: 'i2' + }) + await vi.waitFor(() => expect(saved.get('chat-1')?.ptyId).toBe('pty-1')) + expect(seedResumeLaunchIdentity).not.toHaveBeenCalled() reattach('i1') await vi.waitFor(() => expect(seedResumeLaunchIdentity).toHaveBeenCalledOnce()) expect(seedResumeLaunchIdentity).toHaveBeenCalledWith( - expect.objectContaining({ paneKey: PANE, execution: { ptyId: 'pty-1', incarnationId: 'i1' } }) + expect.objectContaining({ + paneKey: PANE, + resumeProviderSession: { key: 'session_id', id: C }, + execution: { ptyId: 'pty-1', incarnationId: 'i1' } + }) ) }) }) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts index 4f62f7ce8874..61ceecd713ca 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -11,7 +11,7 @@ import { type TerminalSwitchObservation } from '../../../shared/terminal-switch' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' -import { isLaterSwitchHostBoot, readSwitchHostBoot } from './structured-session-switch-boot' +import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' import { adoptsCliConversations } from '../../../shared/structured-agent-cli-conversation-adoption' import { @@ -65,6 +65,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR if (isTerminalSwitchDescriptor(terminalSwitch)) { const claim = { ...terminalSwitch, + processes: [...terminalSwitch.processes], chatSessionId: sessionId, at: 0, accepted: true, @@ -85,38 +86,53 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR return this.switchClaimsLoaded } - /** A reattached switch terminal regains its exact-execution stamp on the host's identity row, - * rebuilt from the saved switch: the stamp itself is never persisted. */ + /** After a restart, a reattached switch terminal asks the original owner for its binding; + * the stamp is rebuilt from the saved switch and that answer, never persisted itself. */ override registerPty(...args: Parameters): void { super.registerPty(...args) - const [ptyId, worktreeId, , binding] = args - const reattached = binding?.providerReattachLaunchIdentity + const reattached = args[3]?.providerReattachLaunchIdentity if (!reattached || !adoptsCliConversations(reattached.launchAgent)) { return } - const { incarnationId } = reattached - void this.loadSwitchClaims().then( - () => { + void this.loadSwitchClaims() + .then(async () => { for (const claim of this.switchClaims.values()) { - const row = this.getAgentProviderSessionRows().find( - (candidate) => - candidate.paneKey === claim.paneKey && - candidate.providerSession?.id === claim.providerSessionId - ) - if (claim.ptyId === ptyId && claim.incarnationId === incarnationId && row) { - seedResumeLaunchIdentity({ - paneKey: claim.paneKey, - launchAgent: claim.agent, - resumeProviderSession: row.providerSession, - worktreeId, - launchedAt: 0, - execution: { ptyId, incarnationId } - }) + const seen = claim.accepted ? await this.observeSwitch(claim, false) : null + if (seen?.state === 'bound' && seen.ptyId === args[0]) { + await this.noteSwitchBound(claim, seen) } } - }, - () => undefined + }) + .catch(() => undefined) + } + + /** Presents a switch's conversation on its pane, only while that pane runs its exact execution. */ + private presentSwitchBinding(claim: SwitchClaim): void { + const pty = claim.ptyId ? this.ptysById.get(claim.ptyId) : undefined + if ( + !pty?.incarnationId || + pty.incarnationId !== claim.incarnationId || + pty.paneKey !== claim.paneKey || + this.switchClaims.get(claim.generation) !== claim + ) { + return + } + const row = this.getAgentProviderSessionRows().find( + (candidate) => + candidate.paneKey === claim.paneKey && + candidate.providerSession?.id === claim.providerSessionId ) + seedResumeLaunchIdentity({ + paneKey: claim.paneKey, + launchAgent: claim.agent, + resumeProviderSession: row?.providerSession ?? { + key: 'session_id', + id: claim.providerSessionId + }, + worktreeId: pty.worktreeId, + launchedAt: 0, + execution: { ptyId: pty.ptyId, incarnationId: pty.incarnationId } + }) } /** Throws while a switch of this conversation exists: one no terminal took yet, or with `all`, any. */ @@ -183,14 +199,16 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR /** Saved before dispatch, so a restart still knows the start may exist. A retired claim's * late evidence never writes, and the store only updates the generation it holds. */ async saveSwitchClaim(claim: SwitchClaim): Promise { - claim.boot ??= await readSwitchHostBoot() + claim.boot ??= await readBootIdentity() const { chatSessionId, at: _at, accepted: _a, saved, unsaved: _u, ...descriptor } = claim if (saved && this.switchClaims.get(claim.generation) !== claim) { return false } const store = await this.openAgentSessionRecordStore() + // A copy: the store freezes what it commits, and this claim keeps collecting evidence. + const snapshot = { ...descriptor, processes: [...descriptor.processes] } const next = await store - .setTerminalSwitch(chatSessionId, saved ? { update: descriptor } : { accept: descriptor }) + .setTerminalSwitch(chatSessionId, saved ? { update: snapshot } : { accept: snapshot }) .catch(() => null) const ok = next?.terminalSwitch?.generation === claim.generation Object.assign(claim, { saved: saved || ok, unsaved: !ok }) @@ -220,7 +238,9 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR return own?.observation ?? { state: 'unverifiable' } } // Every reachable owner answered and none is this one: only the execution can end it. - if (claim.boot && isLaterSwitchHostBoot(claim.boot, await readSwitchHostBoot())) { + // Only an OS boot id proves a later boot; a wall clock can step without one (Windows has none). + const boot = claim.boot ? await readBootIdentity() : undefined + if (boot && boot !== claim.boot) { return { state: 'ended' } } const recorded = claim.shell ? [claim.shell, ...claim.processes] : [] @@ -241,6 +261,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR (claim.shell || !bound.shellPid) && !claim.unsaved ) { + this.presentSwitchBinding(claim) return } const shell = bound.shellPid ? await readSwitchShellProcess(bound.shellPid) : null @@ -249,6 +270,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR claim.shell = shell } await this.saveSwitchClaim(claim) + this.presentSwitchBinding(claim) } protected async releaseSwitchClaim(claim: SwitchClaim): Promise { diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index c068e1f348e2..36e029c0133b 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -16,12 +16,17 @@ const mocks = vi.hoisted(() => { activateVerified: vi.fn(async () => 'activated'), switchCapability: vi.fn(async () => false), exitWatchers: new Map void>(), + rpc: vi.fn(async (..._args: unknown[]) => ({})), inspect: vi.fn(async (_ptyId: string): Promise<{ foregroundProcessEvidence?: unknown }> => ({})) } }) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ inspectRuntimeTerminalProcess: mocks.inspect })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + callRuntimeRpc: mocks.rpc +})) vi.mock('../terminal-pane/pty-dispatcher', () => ({ subscribeToPtyExit: (ptyId: string, watcher: () => void) => { mocks.exitWatchers.set(ptyId, watcher) @@ -322,31 +327,20 @@ describe('CLI identity resume through the existing launch owner', () => { expect(closed.mock.calls[0]?.[0].detail).toEqual({ tabId: 'tab-1', leafId }) expect(tabs()).toEqual([]) - // A paired source retires only while its host still reports the incarnation it started with. + // A paired source is closed by its host, fenced on the incarnation it started with, so a + // replacement under the same handle stays open; the renderer never closes it by tab. const remote = 'remote:env@@handle-1' mocks.source = { ...mocks.source, ptyId: remote } - useAppStore.setState({ - terminalLayoutsByTabId: { - 'tab-1': { - root: null, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: remote } - } - } - }) - const host = (...incarnations: string[]) => - incarnations.forEach((ptyIncarnationId) => - mocks.inspect.mockResolvedValueOnce({ foregroundProcessEvidence: { ptyIncarnationId } }) - ) + mocks.inspect.mockResolvedValueOnce({ foregroundProcessEvidence: { ptyIncarnationId: 'i1' } }) window.addEventListener('orca-close-terminal-pane', closed) - host('i1', 'i2') - await runCliChatResume(mocks.source) - expect(closed).toHaveBeenCalledOnce() - host('i1', 'i1') await runCliChatResume(mocks.source) window.removeEventListener('orca-close-terminal-pane', closed) - expect(closed).toHaveBeenCalledTimes(2) + expect(closed).toHaveBeenCalledOnce() + expect(mocks.rpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env' }, + 'terminal.close', + { terminal: 'handle-1', expectedIncarnationId: 'i1' } + ) }) it('keeps the eager History joiner failed and retryable after the hidden starter is refused', async () => { diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index f81b2b805921..a53fe8c90e5d 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -18,6 +18,8 @@ import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in import { panePtyId } from '@/lib/ai-vault-tab-title-requests' import { subscribeToPtyExit } from '../terminal-pane/pty-dispatcher' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' /** The incarnation the execution host reports for a PTY; null when it reports none. */ @@ -28,8 +30,9 @@ const hostPtyIncarnation = (ptyId: string): Promise => ) /** The chat took the conversation, so the terminal pane it came from closes (its tab with it): - * only while the host still reports the execution it came from, never a sibling or replacement. - * A local PTY whose host reports no incarnation is fenced by its exit, which precedes reuse. */ + * only that execution, never a sibling or replacement. A paired host closes it under its own + * incarnation fence; a local PTY, whose host may report none, is fenced by its exit, which + * precedes any reuse of its id. */ async function retireSwitchedTerminalPane( source: CliChatResumeSource, sourceExited: () => boolean, @@ -37,12 +40,18 @@ async function retireSwitchedTerminalPane( ): Promise { const pane = parsePaneKey(source.paneKey) const incarnation = await expected + const owner = source.ptyId ? getRemoteRuntimePtyOwner(source.ptyId) : null + if (owner || isRemoteRuntimePtyId(source.ptyId)) { + if (owner && incarnation) { + const params = { terminal: owner.terminal, expectedIncarnationId: incarnation } + await callRuntimeRpc(owner.target, 'terminal.close', params).catch(() => undefined) + } + return + } if ( pane && source.ptyId && - (incarnation - ? (await hostPtyIncarnation(source.ptyId)) === incarnation - : !isRemoteRuntimePtyId(source.ptyId)) && + (!incarnation || (await hostPtyIncarnation(source.ptyId)) === incarnation) && !sourceExited() && panePtyId(useAppStore.getState(), source.paneKey) === source.ptyId ) { From f8527918a36c24dfb1fa3dff01509cb9c0631fbe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:58:24 -0700 Subject: [PATCH 022/207] fix(native-chat): end an owner-lost switch on a clock-free later boot on every platform A saved switch records the host boot id (Linux boot_id, macOS boot session, or the Windows BootId through one read-only reg.exe query) and the uptime at acceptance. After owner loss, a different boot id or an uptime lower than recorded proves a reboot; wall-clock time never does, and macOS uptime, which Node derives from the wall clock, is not used. --- .../structured-session-switch-boot.ts | 43 +++++++++++++++++++ .../structured-session-switch-claims.test.ts | 34 +++++++++++++-- .../structured-session-switch-claims.ts | 9 ++-- 3 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 src/main/runtime/session-tabs/structured-session-switch-boot.ts diff --git a/src/main/runtime/session-tabs/structured-session-switch-boot.ts b/src/main/runtime/session-tabs/structured-session-switch-boot.ts new file mode 100644 index 000000000000..0c811adc2dc4 --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-switch-boot.ts @@ -0,0 +1,43 @@ +import { uptime } from 'node:os' +import { runProcess } from '@orca/process-host' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' +import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' + +const WINDOWS_BOOT_KEY = + 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\\PrefetchParameters' + +/** The OS's own boot counter or id; undefined when unreadable. Windows: one read-only reg.exe. */ +async function readOsBootId(): Promise { + if (process.platform !== 'win32') { + return readBootIdentity() + } + const result = await runProcess({ + program: windowsSystem32Binary('reg.exe'), + args: ['query', WINDOWS_BOOT_KEY, '/v', 'BootId'], + timeoutMs: 5_000 + }).catch(() => null) + const id = result?.code === 0 ? /BootId\s+REG_DWORD\s+(0x[0-9a-f]+)/i.exec(result.stdout) : null + return id ? `win:${id[1].toLowerCase()}` : undefined +} + +/** Read at acceptance and on owner-loss recovery only: the boot id, and uptime at reading. */ +export async function readSwitchHostBoot(): Promise { + return `${(await readOsBootId()) ?? ''}@${uptime()}` +} + +const parse = (boot: string): [string, number] => { + const at = boot.lastIndexOf('@') + return at === -1 ? ['', Number.NaN] : [boot.slice(0, at), Number(boot.slice(at + 1))] +} + +/** A later boot, from clock-free facts only: a different boot id, or uptime lower than it was + * (it only grows within a boot). macOS uptime is derived from the wall clock, so there only the + * id counts. Anything unreadable proves nothing. */ +export function isLaterSwitchHostBoot(saved: string, current: string): boolean { + const [savedId, savedUptime] = parse(saved) + const [id, currentUptime] = parse(current) + if (savedId && id) { + return savedId !== id + } + return process.platform !== 'darwin' && currentUptime < savedUptime +} diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts index 10f18d97986f..0927ac946a58 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts @@ -15,6 +15,7 @@ import { import { setAgentSessionRecordTerminalSwitch } from '../agent-session-record-terminal-switch' import { routedTerminalSwitchOwners } from '../../providers/terminal-switch-owners' import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' +import { isLaterSwitchHostBoot, readSwitchHostBoot } from './structured-session-switch-boot' import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' import type { SwitchClaim } from './structured-session-switch-claims' @@ -28,6 +29,13 @@ vi.mock('../../agent-hooks/managed-hook-owner-identity', () => ({ readBootIdentity: async () => boot })) vi.mock('../../ipc/pty/pane/resume-launch-identity', () => ({ seedResumeLaunchIdentity: vi.fn() })) +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal>()), + runProcess: vi.fn(async () => ({ + code: 0, + stdout: ` BootId REG_DWORD ${boot ?? ''}\r\n` + })) +})) vi.mock('./structured-session-switch-observation', () => ({ readSwitchPaneAgentProcesses: vi.fn(async () => []), readSwitchShellProcess: vi.fn(async (pid: number) => ({ @@ -47,7 +55,7 @@ let runtime: OrcaRuntimeService let host: TerminalHostSwitches let shellAlone: boolean let ownerReachable: boolean -let boot: string | undefined = 'boot-1' +let boot: string | undefined = '0x1' /** The chat records' saved switch descriptors, keyed by chat session id. */ let saved: Map @@ -65,7 +73,7 @@ const live = () => session({ isAlive: true, sessionId: 'pty-1', incarnationId: ' beforeEach(() => { shellAlone = true ownerReachable = true - boot = 'boot-1' + boot = '0x1' saved = new Map() host = new TerminalHostSwitches() runtime = new OrcaRuntimeService() @@ -189,6 +197,7 @@ describe('a chat → terminal switch start', () => { }) it('survives a restart: an unreachable owner holds; a lost one yields only to proof', async () => { + const sameBoot = await readSwitchHostBoot() const lost = (extra: Partial) => saved.set('chat-1', { generation: 'g-old', @@ -197,7 +206,7 @@ describe('a chat → terminal switch start', () => { owner: 'daemon:gone', paneKey: PANE, processes: [], - boot: 'boot-1', + boot: sameBoot, ...extra }) lost({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL, processes: [AGENT] }) @@ -216,11 +225,28 @@ describe('a chat → terminal switch start', () => { runtime['switchClaimsLoaded'] = null boot = undefined expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - boot = 'boot-2' + boot = '0x2' expect(await reason(chatStart())).toBe('admitted') expect(saved.has('chat-1')).toBe(false) }) + it('proves a later boot only from a boot id or an uptime drop, never the wall clock', async () => { + const onPlatform = (platform: string) => + Object.defineProperty(process, 'platform', { value: platform }) + const real = process.platform + expect(isLaterSwitchHostBoot('a@100', 'a@1')).toBe(false) + expect(isLaterSwitchHostBoot('a@1', 'b@100')).toBe(true) + onPlatform('win32') + boot = '0x1A' + expect(await readSwitchHostBoot()).toMatch(/^win:0x1a@/) + expect(isLaterSwitchHostBoot('@100', '@5')).toBe(true) + expect(isLaterSwitchHostBoot('@100', '@500')).toBe(false) + // macOS derives uptime from the wall clock, so only its boot id counts there. + onPlatform('darwin') + expect(isLaterSwitchHostBoot('@100', '@5')).toBe(false) + onPlatform(real) + }) + it('rebuilds a reattached switch terminal’s stamp from its owner, never onto a replacement', async () => { const claim = await acceptedSwitch() host.begin(claim.generation, host.owner) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts index 61ceecd713ca..b639ca771233 100644 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ b/src/main/runtime/session-tabs/structured-session-switch-claims.ts @@ -11,7 +11,7 @@ import { type TerminalSwitchObservation } from '../../../shared/terminal-switch' import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' -import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' +import { isLaterSwitchHostBoot, readSwitchHostBoot } from './structured-session-switch-boot' import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' import { adoptsCliConversations } from '../../../shared/structured-agent-cli-conversation-adoption' import { @@ -199,7 +199,7 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR /** Saved before dispatch, so a restart still knows the start may exist. A retired claim's * late evidence never writes, and the store only updates the generation it holds. */ async saveSwitchClaim(claim: SwitchClaim): Promise { - claim.boot ??= await readBootIdentity() + claim.boot ??= await readSwitchHostBoot() const { chatSessionId, at: _at, accepted: _a, saved, unsaved: _u, ...descriptor } = claim if (saved && this.switchClaims.get(claim.generation) !== claim) { return false @@ -238,9 +238,8 @@ export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaR return own?.observation ?? { state: 'unverifiable' } } // Every reachable owner answered and none is this one: only the execution can end it. - // Only an OS boot id proves a later boot; a wall clock can step without one (Windows has none). - const boot = claim.boot ? await readBootIdentity() : undefined - if (boot && boot !== claim.boot) { + // Only clock-free facts prove a later boot; a wall clock can step without one. + if (claim.boot && isLaterSwitchHostBoot(claim.boot, await readSwitchHostBoot())) { return { state: 'ended' } } const recorded = claim.shell ? [claim.shell, ...claim.processes] : [] From d65f9cf51f71a887d54716b0f7f05d02c31a57a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:58:28 -0700 Subject: [PATCH 023/207] fix: accept reattach payload policy sessions --- .../pty-connection/terminal-launch-result-policy.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts index 49400e22ba4e..3a62b60f9ac6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-launch-result-policy.ts @@ -4,13 +4,14 @@ import { } from '../../../../../shared/agent-session-resume' import { useAppStore } from '@/store' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import type { ReattachPayloadSession } from './reattach-payload-session' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' import type { PtyPaneStartup } from '../pty-connection-types' type SavedStartup = ColdRestoreAgentResumeStartup | PtyPaneStartup export function captureTerminalLaunchContext( - session: ConnectPanePtySession, + session: ConnectPanePtySession | ReattachPayloadSession, startup: SavedStartup ) { const state = useAppStore.getState() @@ -25,7 +26,7 @@ export function captureTerminalLaunchContext( export type TerminalLaunchContext = ReturnType export function applyTerminalLaunchResult( - session: ConnectPanePtySession, + session: ConnectPanePtySession | ReattachPayloadSession, result: TerminalLaunchResult & { isReattach?: boolean }, context: TerminalLaunchContext, kind: 'creator' | 'observer' | 'candidate' From 3568a5c435d9e580a9cacdfa14b8b080f7101484 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:30:19 -0700 Subject: [PATCH 024/207] fix: preserve terminal result validation contracts --- config/scripts/vitest-node-runtime-files.mjs | 1 + .../terminal-multiplex-escape-tail.test.ts | 2 ++ ...rminal-multiplex-pty-wait-capacity.test.ts | 20 ++++++++++++++----- .../terminal-multiplex-pty-wait-queue.test.ts | 11 ++++++++-- ...minal-multiplex-resync-replay-trim.test.ts | 2 ++ ...-requested-snapshot-unavailability.test.ts | 2 ++ .../terminal-pane/ipc-pty-connect-result.ts | 2 +- ...ty-connection-agent-session-resume.spec.ts | 13 ++++++++++++ .../host-terminal-runtime-stub.ts | 2 ++ ...untime-rejected-input-remount.unit.test.ts | 2 ++ 10 files changed, 49 insertions(+), 8 deletions(-) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 61e9c5314482..cf2ddb77ed0a 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -88,6 +88,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts', 'src/renderer/src/store/github/cache-persistence.test.ts', 'src/main/native-chat/agent-session-journal/**/*.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 38f5d9e5cd51..3a5c2cea9106 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -22,6 +22,8 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index 244c12557614..5bfc1c22cf41 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -108,14 +108,18 @@ describe('terminal multiplex RPC', () => { }) it("waits for a desktop multiplex subscriber's PTY before retiring the terminal", async () => { + let ptyId: string | null = null let resolvePty: (ptyId: string) => void = () => {} const runtime = stubRuntime({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId })), requestRendererTerminalTabMount: vi.fn(), waitForLeafPtyId: vi.fn( () => new Promise((resolve) => { - resolvePty = resolve + resolvePty = (resolvedPtyId) => { + ptyId = resolvedPtyId + resolve(resolvedPtyId) + } }) ) }) @@ -359,17 +363,23 @@ describe('terminal multiplex RPC', () => { const activeStreamCount = 44 const waitSignals: AbortSignal[] = [] const resolveWaits: ((ptyId: string) => void)[] = [] + const ptyIds = new Map() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn((terminal: string) => - terminal.startsWith('pending-') ? { ptyId: null } : { ptyId: `pty-${terminal}` } + terminal.startsWith('pending-') + ? { ptyId: ptyIds.get(terminal) ?? null } + : { ptyId: `pty-${terminal}` } ), waitForLeafPtyId: vi.fn( - (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => + (handle: string, _timeoutMs?: number, signal?: AbortSignal) => new Promise((resolve, reject) => { if (signal) { waitSignals.push(signal) } - resolveWaits.push(resolve) + resolveWaits.push((ptyId) => { + ptyIds.set(handle, ptyId) + resolve(ptyId) + }) signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { once: true }) diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts index a50c2dc32cda..f08d549c5e86 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-queue.test.ts @@ -15,15 +15,22 @@ import { startDesktopMultiplexSubscribe } from './terminal-multiplex-test-harnes function startPendingPtyHarness() { const waits = new Map void }>() + const ptyIds = new Map() const requestRendererTerminalTabMount = vi.fn() const harness = startDesktopMultiplexSubscribe({ - resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + resolveLiveLeafForHandle: vi.fn((handle: string) => ({ ptyId: ptyIds.get(handle) ?? null })), requestRendererTerminalTabMount, waitForLeafPtyId: vi.fn( (handle: string, _timeoutMs?: number, signal?: AbortSignal) => new Promise((resolve, reject) => { if (signal) { - waits.set(handle, { signal, resolve }) + waits.set(handle, { + signal, + resolve: (ptyId) => { + ptyIds.set(handle, ptyId) + resolve(ptyId) + } + }) } signal?.addEventListener('abort', () => reject(new Error('request_aborted')), { once: true diff --git a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts index 266630280f15..89125a0713e1 100644 --- a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts @@ -55,6 +55,8 @@ async function setupMultiplexStream(): Promise<{ const runtime = { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: serializeSnapshot, diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts index dacf408900cb..edbdf1f77eec 100644 --- a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -52,6 +52,8 @@ async function requestSnapshotReply(options: { }) const runtime = { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, registerRemoteTerminalViewSubscriber: () => () => {}, resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount: vi.fn().mockReturnValue(true), 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 ad2e995c3c3e..da9c93585fe0 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 @@ -7,8 +7,8 @@ type IpcPtySpawnResult = PtyConnectResult & { isReattach?: boolean } export function projectIpcPtyConnectResult( spawnResult: IpcPtySpawnResult ): string | PtyConnectResult { - const facts = terminalLaunchFacts(spawnResult) const launchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined + const facts = terminalLaunchFacts({ ...spawnResult, launchAgent }) if (spawnResult.isReattach || spawnResult.coldRestore || spawnResult.sessionExpired) { return { ...facts, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts index fa741e8afe00..50c215331433 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.spec.ts @@ -13,6 +13,8 @@ import { import { buildPaneConnectionDeps } from './pty-connection-test-deps' import { createInitialStoreState } from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' +import type { AgentStatusSlice } from '@/store/slices/agent-status-slice-contract' +import { normalizeLaunchConfigRegistrationMetadata } from '@/store/slices/agent-status-launch-config' import type { MockTransport } from './pty-connection-test-pane-fixtures' import { installTerminalTestGlobals, @@ -195,6 +197,17 @@ export function registerPtyConnectionAgentSessionResumeTests(): void { transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + const registerLaunchConfig: AgentStatusSlice['registerAgentLaunchConfig'] = ( + key, + config, + metadata + ) => { + mockStoreState.agentLaunchConfigByPaneKey[key] = { + launchConfig: config, + identity: normalizeLaunchConfigRegistrationMetadata(key, metadata) + } + } + mockStoreState.registerAgentLaunchConfig.mockImplementation(registerLaunchConfig) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts index 0f8625b60829..15f8c0202dac 100644 --- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts +++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts @@ -117,6 +117,8 @@ export function createHostTerminalRuntimeStub( const runtime: Record = { getRuntimeId: () => 'cross-version-host', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, resolveLiveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null), resolveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null), registerRemoteTerminalViewSubscriber: () => () => {}, diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index 0225f805c715..8820e405af0a 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -95,6 +95,8 @@ function startHost(): { const sendTerminal = vi.fn().mockResolvedValue({ accepted: false }) const runtime = { getRuntimeId: () => 'test-runtime', + getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), + getTerminalLaunchResult: () => undefined, registerRemoteTerminalViewSubscriber: () => () => {}, resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), requestRendererTerminalTabMount: vi.fn().mockReturnValue(true), From fb309d064a5ef37edd044e3b8cf8fa58123b4e3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:43:52 -0700 Subject: [PATCH 025/207] refactor: separate terminal create contract --- src/shared/runtime-terminal-contracts.ts | 20 +---------------- .../runtime-terminal-create-contract.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 src/shared/runtime-terminal-create-contract.ts diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 4da42bb236ae..a0091a6e97d0 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -272,25 +272,7 @@ export type RuntimeTerminalCreateRequestPayload = source: 'runtime-session' }) -export type RuntimeTerminalCreate = TerminalLaunchResult & { - coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } - handle: string - /** Host-owned PTY incarnation used to fence remote identity observations. */ - incarnationId?: string | null - tabId?: string - paneKey?: string | null - ptyId?: string | null - worktreeId: string - title: string | null - executionHostId?: ExecutionHostId - hostPlatform?: NodeJS.Platform - surface?: 'background' | 'visible' - warning?: string - agentSessionDisposition?: 'created' | 'adopted' - isReattach?: true - /** Spawn process identity for host-internal ownership proof. */ - processId?: number -} +export type { RuntimeTerminalCreate } from './runtime-terminal-create-contract' export type RuntimeTerminalSplit = { handle: string diff --git a/src/shared/runtime-terminal-create-contract.ts b/src/shared/runtime-terminal-create-contract.ts new file mode 100644 index 000000000000..4552a97cf8e6 --- /dev/null +++ b/src/shared/runtime-terminal-create-contract.ts @@ -0,0 +1,22 @@ +import type { TerminalLaunchResult } from './agent-session-resume' +import type { ExecutionHostId } from './execution-host' + +export type RuntimeTerminalCreate = TerminalLaunchResult & { + coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } + handle: string + /** Host-owned PTY incarnation used to fence remote identity observations. */ + incarnationId?: string | null + tabId?: string + paneKey?: string | null + ptyId?: string | null + worktreeId: string + title: string | null + executionHostId?: ExecutionHostId + hostPlatform?: NodeJS.Platform + surface?: 'background' | 'visible' + warning?: string + agentSessionDisposition?: 'created' | 'adopted' + isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number +} From 58d108a19eb054f5d6412870e29bb3a7d09af85a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:00:09 -0700 Subject: [PATCH 026/207] test: document partial terminal runtime fixtures --- src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts | 1 + .../runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts | 1 + .../rpc/terminal-requested-snapshot-unavailability.test.ts | 1 + tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 3a5c2cea9106..8f8c6ee695d9 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -20,6 +20,7 @@ import { // literally ("colors/garbage around what I type"). function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. return { getRuntimeId: () => 'test-runtime', getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), diff --git a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts index 89125a0713e1..649ffc02fc6b 100644 --- a/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-resync-replay-trim.test.ts @@ -53,6 +53,7 @@ async function setupMultiplexStream(): Promise<{ return { data: snapshot.data, cols: 80, rows: 24, seq: snapshot.seq } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. const runtime = { getRuntimeId: () => 'test-runtime', getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), diff --git a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts index edbdf1f77eec..f2ee6d022b69 100644 --- a/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts +++ b/src/main/runtime/rpc/terminal-requested-snapshot-unavailability.test.ts @@ -50,6 +50,7 @@ async function requestSnapshotReply(options: { options.duringSerialize?.(requestedAttempt, (data) => dataListenerRef.current?.(data)) return options.serializeRequested(requestedAttempt) }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. const runtime = { getRuntimeId: () => 'test-runtime', getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index 8820e405af0a..0b6715b22475 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -93,6 +93,7 @@ function startHost(): { const hostOpcodes: number[] = [] // The host's whole reason to emit the opcode: the PTY refused the bytes. const sendTerminal = vi.fn().mockResolvedValue({ accepted: false }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This partial runtime supplies the terminal RPC methods these tests invoke. const runtime = { getRuntimeId: () => 'test-runtime', getPtyExecutionHostMetadata: () => ({ incarnationId: 'test-incarnation' }), From 6eaa8684b581c0a0b06a5f23a457ab6156de1b23 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:30:31 -0700 Subject: [PATCH 027/207] test: align sleeping resume store fixture --- .../lib/sleeping-agent-session-launch-windows-quoting.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index 535946eb70a9..e6765b8b2b29 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -8,6 +8,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r const mockCreateTab = vi.fn() const store = { + agentStatusByPaneKey: {}, settings: { agentCmdOverrides: {}, agentDefaultArgs: {} as Record, From 977f31d70d8a4d2e03e5161d2d2f4d115a032a9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:41:58 -0700 Subject: [PATCH 028/207] Simplify same-conversation chat and CLI switching --- .../server-launch-resume-identity.test.ts | 50 ++- .../server/server-ingest-terminal.ts | 5 +- .../server/server-status-identity.ts | 1 - src/main/daemon/daemon-pty-adapter.ts | 25 -- .../daemon/daemon-pty-owner-query-protocol.ts | 7 - src/main/daemon/daemon-pty-router.ts | 17 +- src/main/daemon/daemon-pty-session-control.ts | 28 +- src/main/daemon/daemon-pty-spawn-request.ts | 3 - src/main/daemon/daemon-request-router.ts | 19 +- ...n-final-checkpoint-caller-deadline.test.ts | 17 + src/main/daemon/daemon-terminal-admission.ts | 3 - .../daemon/degraded-daemon-pty-provider.ts | 18 +- src/main/daemon/session.ts | 2 +- .../terminal-host-concurrent-create.test.ts | 39 -- .../daemon/terminal-host-create-contract.ts | 2 - .../daemon/terminal-host-session-create.ts | 46 +-- src/main/daemon/terminal-host-switches.ts | 63 --- src/main/daemon/terminal-host.ts | 14 +- src/main/daemon/types.ts | 4 - .../pty/host-env/resume-spawn-judgement.ts | 152 -------- .../pty/host-env/saved-resume-admission.ts | 133 +++++-- src/main/ipc/pty/ipc/renderer-kill.ts | 7 +- src/main/ipc/pty/ipc/spawn-commit.ts | 5 +- src/main/ipc/pty/ipc/spawn-execute.ts | 7 +- .../pty/pane/resume-launch-identity.test.ts | 7 - .../ipc/pty/pane/resume-launch-identity.ts | 11 +- src/main/ipc/pty/provider/shutdown-detect.ts | 9 +- src/main/ipc/pty/runtime/controller-deps.ts | 7 +- src/main/ipc/pty/runtime/controller.ts | 9 +- .../pty/runtime/kill-same-id-respawn.test.ts | 17 + src/main/ipc/pty/runtime/kill.ts | 11 +- src/main/ipc/pty/runtime/spawn-commit.ts | 5 +- src/main/ipc/pty/runtime/spawn-execute.ts | 7 +- src/main/ipc/pty/runtime/spawn-state.ts | 2 +- src/main/ipc/pty/session.ts | 7 +- .../structured-agent-session-acquisition.ts | 6 +- .../structured-agent-session-adapter.ts | 1 - ...red-agent-session-conversation-lifetime.ts | 7 +- .../structured-agent-session-failure-text.ts | 3 +- .../structured-agent-session-host.ts | 2 +- ...ructured-agent-session-switch-admission.ts | 7 +- ...nt-session-terminal-writer-acquire.test.ts | 50 ++- .../local-pty-provider-shutdown.test.ts | 11 + .../local-pty-provider-spawn-session.test.ts | 23 -- src/main/providers/local-pty-provider.ts | 10 +- .../providers/local-pty-session-activation.ts | 6 +- .../local-pty-shell-ready-startup-command.ts | 6 +- src/main/providers/local-pty-spawn.ts | 36 +- .../providers/local-pty-terminal-switch.ts | 101 ----- src/main/providers/local-pty-termination.ts | 6 +- src/main/providers/pty-provider-contract.ts | 7 - src/main/providers/terminal-switch-owners.ts | 20 - .../agent-session-record-terminal-switch.ts | 23 -- ...-runtime-terminal-close-continuity.test.ts | 23 ++ ...ctured-agent-session-switch-to-terminal.ts | 45 +-- .../runtime-mobile-session-projection.ts | 17 +- .../runtime-pty-controller-contract.ts | 5 +- .../explicit-terminal-close-outcome.ts | 86 +++++ .../explicit-terminal-close-stops.ts | 125 ++---- .../structured-session-cli-writer-host.ts | 112 ++---- .../structured-session-switch-boot.ts | 43 --- .../structured-session-switch-claims.test.ts | 359 ------------------ .../structured-session-switch-claims.ts | 339 ----------------- ...uctured-session-switch-observation.test.ts | 35 -- .../structured-session-switch-observation.ts | 96 ----- ...ured-session-terminal-launch-retirement.ts | 62 +++ .../launch/mobile-terminal-renderer-create.ts | 2 +- src/main/startup/main-window-agent-status.ts | 2 - .../NativeChatTopRightControls.tsx | 18 +- .../agent-session-failure-words-text.ts | 5 - .../AiVaultSessionActionMenuItems.tsx | 5 +- .../AiVaultSessionDetailsSwitchButtons.tsx | 6 +- .../right-sidebar/AiVaultSessionRow.tsx | 5 +- .../AiVaultSessionSurfaceSwitchMenuItems.tsx | 12 +- .../SessionRowTrailingActions.tsx | 5 +- .../ai-vault-session-in-place-switch.ts | 21 +- .../tab-bar/CliChatResumeMenuItems.tsx | 10 +- .../tab-bar/NativeChatCliForkMenuItems.tsx | 11 +- .../TabSessionSurfaceSwitchMenuItems.test.tsx | 6 +- .../tab-bar/TabSessionSwitchHoverAction.tsx | 15 +- .../tab-bar/cli-chat-resume-feedback.ts | 25 +- .../tab-bar/cli-chat-resume-run.test.ts | 62 ++- .../components/tab-bar/cli-chat-resume-run.ts | 130 +++++-- .../tab-bar/cli-chat-resume-source.ts | 12 + .../native-chat-cli-switch-run.test.ts | 13 +- .../tab-bar/native-chat-cli-switch-run.ts | 20 +- .../tab-session-history-switch.test.ts | 45 ++- .../TerminalPaneResumeInChatButton.test.tsx | 1 + .../TerminalPaneResumeInChatButton.tsx | 42 +- .../pty-connection/pty-exit-hibernate.ts | 1 - .../agent-status-partial-updates.ts | 15 +- .../src/i18n/en-runtime-required.json | 1 - src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/fr.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- .../src/lib/ai-vault-tab-title-requests.ts | 8 +- .../src/lib/in-place-switch-support.test.tsx | 42 ++ .../src/lib/in-place-switch-support.ts | 63 ++- .../src/lib/launch-ai-vault-session.ts | 2 +- .../agent-status-provider-session-actions.ts | 12 +- .../slices/agent-status-slice-contract.ts | 14 - src/renderer/src/store/slices/agent-status.ts | 1 - .../agent-hook-listener/listener-event.ts | 3 - src/shared/agent-session-failure-copy.ts | 2 - src/shared/agent-session-failure-words.ts | 2 - src/shared/agent-session-failure.ts | 1 - src/shared/agent-session-record.ts | 14 +- src/shared/agent-session-refusal-details.ts | 2 - .../agent-session-refusal-reason-words.ts | 1 - src/shared/agent-status-ipc-payload.ts | 3 - ...ctured-agent-session-dispatch-rejection.ts | 2 - src/shared/terminal-switch.ts | 74 ---- 115 files changed, 1076 insertions(+), 2121 deletions(-) delete mode 100644 src/main/daemon/terminal-host-switches.ts delete mode 100644 src/main/ipc/pty/host-env/resume-spawn-judgement.ts delete mode 100644 src/main/providers/local-pty-terminal-switch.ts delete mode 100644 src/main/providers/terminal-switch-owners.ts delete mode 100644 src/main/runtime/agent-session-record-terminal-switch.ts create mode 100644 src/main/runtime/session-tabs/explicit-terminal-close-outcome.ts delete mode 100644 src/main/runtime/session-tabs/structured-session-switch-boot.ts delete mode 100644 src/main/runtime/session-tabs/structured-session-switch-claims.test.ts delete mode 100644 src/main/runtime/session-tabs/structured-session-switch-claims.ts delete mode 100644 src/main/runtime/session-tabs/structured-session-switch-observation.test.ts delete mode 100644 src/main/runtime/session-tabs/structured-session-switch-observation.ts create mode 100644 src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts create mode 100644 src/renderer/src/lib/in-place-switch-support.test.tsx delete mode 100644 src/shared/terminal-switch.ts diff --git a/src/main/agent-hooks/server-launch-resume-identity.test.ts b/src/main/agent-hooks/server-launch-resume-identity.test.ts index 60b6fee29b09..9fa53d041043 100644 --- a/src/main/agent-hooks/server-launch-resume-identity.test.ts +++ b/src/main/agent-hooks/server-launch-resume-identity.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { assertCliConversationWritersExited } from '../runtime/structured-agent-cli-writers' import { AgentHookServer, _internals } from './server' import { buildBody, postHookEvent, PANE } from './server.test-fixtures' @@ -64,31 +65,6 @@ describe('a resume launch seeds the pane identity', () => { ]) }) - it('names a switch start’s exact execution, and a new incarnation there re-seeds it', async () => { - const server = await startServer() - const published = vi.fn() - server.setListener(published) - const seed = (incarnationId: string) => - server.recordLaunchResumeIdentity({ - paneKey: PANE, - agentType: 'codex', - providerSession: RESUMED, - launchedAt: Date.now(), - execution: { ptyId: 'pty-1', incarnationId } - }) - seed('i1') - seed('i1') - seed('i2') - expect(published.mock.calls.map(([row]) => row.launchExecution)).toEqual([ - { ptyId: 'pty-1', incarnationId: 'i1' }, - { ptyId: 'pty-1', incarnationId: 'i2' } - ]) - // A reloaded renderer reads the same stamp from the snapshot. - expect(server.getStatusSnapshotForPane(PANE)).toEqual([ - expect.objectContaining({ launchExecution: { ptyId: 'pty-1', incarnationId: 'i2' } }) - ]) - }) - it("yields to the agent's own report and is replaced by its first hook", async () => { const server = await startServer() const launchedAt = Date.now() - 1_000 @@ -155,7 +131,7 @@ describe('a resume launch seeds the pane identity', () => { }) }) - it('keeps the seeded identity across a host restart', async () => { + it('keeps the no-prompt launch identity across restart and refuses idle Codex until its shell is alone', async () => { const server = await startServer() server.recordLaunchResumeIdentity({ paneKey: PANE, @@ -175,6 +151,28 @@ describe('a resume launch seeds the pane identity', () => { providerSessionOnly: true }) ]) + let shellAlone = false + const writers = { + rows: () => restarted.getStatusSnapshotForPane(PANE), + saved: () => [], + observation: (row: Parameters[0]) => + restarted.readCliWriterObservation(row), + terminal: () => ({ + pane: { ptyId: 'shell', incarnationId: 'shell-1', identity: 'shell:shell-1' } + }), + links: () => [], + sleeping: () => false, + shellAlone: async () => shellAlone + } + await expect( + assertCliConversationWritersExited(writers, 'codex', RESUMED.id) + ).rejects.toMatchObject({ + refusal: { details: { reason: 'conversationHeldElsewhere' } } + }) + shellAlone = true + await expect( + assertCliConversationWritersExited(writers, 'codex', RESUMED.id) + ).resolves.toBeUndefined() }) it('ignores a pane key that is not a stable tab:leaf key', async () => { diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index fd64bcfc4ad0..ab621097860b 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -17,7 +17,6 @@ import { programStatusYieldsToHookRow } from './program-status/hook-precedence' import type { ProgramStatusFacet } from '../../../shared/program-status/facet' -import type { TerminalExecutionStamp } from '../../../shared/terminal-switch' const PROGRAM_STATUS_FACET_REPUBLISH_MS = 250 @@ -34,7 +33,6 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges providerSession: AgentProviderSessionMetadata /** When the pane's process launched; a row reported since then is the agent's own. */ launchedAt: number - execution?: TerminalExecutionStamp }): void { const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim()) const parsedPaneKey = parsePaneKey(paneKey) @@ -49,7 +47,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.agentType, previous.providerSession, event.providerSession - ) && previous.launchExecution?.incarnationId === event.execution?.incarnationId + ) : 'receivedAt' in previous && typeof previous.receivedAt === 'number' && previous.receivedAt >= event.launchedAt) @@ -64,7 +62,6 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId: null, providerSession: event.providerSession, providerSessionOnly: true, - ...(event.execution ? { launchExecution: event.execution } : {}), // Why: hydration keeps only marked identity rows; a host restart before the first turn must not lose it. retainedForLiveness: true, payload: { state: 'done', prompt: '', agentType: event.agentType } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index d3dd3464d8f2..0e798e742302 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -63,7 +63,6 @@ export function toAgentStatusIpcPayload( ...(entry.turnStartedAt !== undefined ? { turnStartedAt: entry.turnStartedAt } : {}), ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), - ...(entry.launchExecution ? { launchExecution: entry.launchExecution } : {}), ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(entry.observation ? { observation: entry.observation } : {}), diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c52792872dc1..3ebccf283115 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,6 +1,4 @@ import { emitPtyListeners, createPtyExitPayload } from './daemon-pty-listener-emission' -import { randomUUID } from 'node:crypto' -import type { TerminalSwitchOwnerAnswer, TerminalSwitchOwners } from '../../shared/terminal-switch' import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' import type { DaemonPtyAdapterOptions } from './daemon-pty-runtime-state' import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' @@ -18,29 +16,6 @@ import type { AutomaticWorkRequestType } from './daemon-automatic-work-protocol' import type { DaemonAsyncIoStatus } from '../../shared/daemon-async-io-health' export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { - /** Asked of this daemon only; a daemon without the request answers nothing, so it owns no switch. */ - readonly terminalSwitches: TerminalSwitchOwners = { - owner: async () => (await this.terminalSwitches.observe(randomUUID(), false))[0]?.owner ?? null, - observe: async (generation, fence) => { - if (!this.client.isConnected()) { - throw new Error('terminal_switch_owner_unreachable') - } - try { - const answer = await this.client.request( - 'observeTerminalSwitch', - { generation, fence }, - 5_000 - ) - return typeof answer?.owner === 'string' ? [answer] : [] - } catch (error) { - if (isUnknownRequestTypeError(error)) { - return [] - } - throw error - } - } - } - constructor(opts: DaemonPtyAdapterOptions) { super(opts) // Registered after the base listener, which marks the sessions awaiting recovery first. diff --git a/src/main/daemon/daemon-pty-owner-query-protocol.ts b/src/main/daemon/daemon-pty-owner-query-protocol.ts index 2242ff1c49c4..875f09c6c994 100644 --- a/src/main/daemon/daemon-pty-owner-query-protocol.ts +++ b/src/main/daemon/daemon-pty-owner-query-protocol.ts @@ -10,10 +10,3 @@ export type SetColorQueryReplyColorsRequest = { type: 'setColorQueryReplyColors' payload: { colors: unknown } } - -/** The daemon's own answer about one chat → terminal switch generation; an unseen one is fenced. */ -export type ObserveTerminalSwitchRequest = { - id: string - type: 'observeTerminalSwitch' - payload: { generation: string; fence?: boolean } -} diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 48bb1b06a07c..0009cb356bba 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,5 +1,4 @@ import { reconcileDaemonRouterSessions } from './daemon-router-session-reconciliation' -import { routedTerminalSwitchOwners } from '../providers/terminal-switch-owners' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout' import type { @@ -31,10 +30,6 @@ export class DaemonPtyRouter implements IPtyProvider { private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout private readonly legacyExitProof = new LegacyDaemonExitProof() readonly retirement = new DaemonRouterRetirement(() => this.allAdapters()) - readonly terminalSwitches = routedTerminalSwitchOwners( - () => this.current, - () => this.allAdapters() - ) constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current @@ -115,7 +110,9 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.supportsAgentSessionCreateOperations() } - attach = (id: string): ReturnType => this.adapterFor(id).attach(id) + async attach(id: string): ReturnType { + return await this.adapterFor(id).attach(id) + } hasPty(id: string): boolean { const routed = this.sessionAdapters.get(id) @@ -174,9 +171,13 @@ export class DaemonPtyRouter implements IPtyProvider { await this.adapterFor(id).sendSignal(id, signal) } - getCwd = (id: string): Promise => this.adapterFor(id).getCwd(id) + async getCwd(id: string): Promise { + return this.adapterFor(id).getCwd(id) + } - getInitialCwd = (id: string): Promise => this.adapterFor(id).getInitialCwd(id) + async getInitialCwd(id: string): Promise { + return this.adapterFor(id).getInitialCwd(id) + } async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { return (await this.adapterFor(id).getAppliedSize?.(id)) ?? null diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index f8cde245cb4d..5d5d94a078c9 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -99,7 +99,12 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { async shutdown( id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ): Promise { if (opts.keepHistory && this.disconnectOnlyPromise) { throw new Error('Cannot keep history after daemon disconnect has started') @@ -119,7 +124,12 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { protected async shutdownWithHistoryLock( id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ): Promise { // Why: shutdown can be the first lazy-client operation after restart; connect // before killing so a healthy daemon session is not orphaned (#7742). Connect, @@ -172,6 +182,20 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { this.historyManager?.suspendSession(id) } } + if (opts.expectedIncarnationId) { + // Older daemons cannot fence kill: compare their fresh inventory immediately before it. + const { sessions } = await this.client.request( + 'listSessions', + undefined, + remainingDaemonRequestTimeoutMs(opts.deadlineMs) + ) + if ( + sessions.find((session) => session.sessionId === id)?.incarnationId !== + opts.expectedIncarnationId + ) { + throw new Error('terminal_handle_stale') + } + } await this.client.request( 'kill', { sessionId: id, immediate: opts.immediate ?? false }, diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts index acdbd47e8b0d..6c5fb0e35cac 100644 --- a/src/main/daemon/daemon-pty-spawn-request.ts +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -143,9 +143,6 @@ export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { : {}), ...(!context.attachOnly && opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } - : {}), - ...(!context.attachOnly && opts.switchGeneration - ? { switchGeneration: opts.switchGeneration, switchOwner: opts.switchOwner } : {}) } return opts.signal diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts index f79a1379055c..73d7703592c2 100644 --- a/src/main/daemon/daemon-request-router.ts +++ b/src/main/daemon/daemon-request-router.ts @@ -62,16 +62,15 @@ export class DaemonRequestRouter { return {} case 'createOrAttach': return this.options.admission.createOrAttach(clientId, request) - case 'cancelCreateOrAttach': { - const { sessionId, requestId } = request.payload - const scope = typeof requestId === 'string' ? { clientId, requestId } : { clientId } - return { canceled: this.options.preparations.cancel(sessionId, scope) } - } - case 'observeTerminalSwitch': - return this.options.host.switches.observe( - request.payload.generation, - request.payload.fence === true - ) + case 'cancelCreateOrAttach': + return { + canceled: this.options.preparations.cancel(request.payload.sessionId, { + clientId, + ...(typeof request.payload.requestId === 'string' + ? { requestId: request.payload.requestId } + : {}) + }) + } case 'closeStartupQueryAuthority': return { appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts index f134a8c8f8cc..e563668b50f2 100644 --- a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -116,6 +116,23 @@ describe('STA-4228 keep-history stop bounds only the caller wait on the final ch rmSync(dir, { recursive: true, force: true }) }) + it('checks the execution-host inventory before an ordinary daemon kill', async () => { + const { id, incarnationId } = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'fenced-shell', + cwd: '/tmp' + }) + const child = subprocesses.at(-1)! + await expect( + adapter.shutdown(id, { immediate: true, expectedIncarnationId: 'earlier' }) + ).rejects.toThrow('terminal_handle_stale') + expect(child.forceKill).not.toHaveBeenCalled() + expect(child.kill).not.toHaveBeenCalled() + await adapter.shutdown(id, { immediate: true, expectedIncarnationId: incarnationId }) + expect((await adapter.listProcesses()).some((row) => row.id === id)).toBe(false) + }) + /** Wedges one session's checkpoint at the history-write layer until explicitly released. */ function stallCheckpointFor(stalledSessionId: string): { release: () => void } { const manager = adapter.getHistoryManager() diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts index 43a820b11ae0..04daad020155 100644 --- a/src/main/daemon/daemon-terminal-admission.ts +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -128,9 +128,6 @@ export class DaemonTerminalAdmission { ? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs } : {}), ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), - ...(typeof payload.switchGeneration === 'string' - ? { switchGeneration: payload.switchGeneration, switchOwner: payload.switchOwner } - : {}), spawnAdmission: { clientId, declaredIntent: isSpawnIntent(payload.spawnIntent) ? payload.spawnIntent : null, diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index ae82d3ae4f15..bf021bce8f83 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,5 +1,4 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import { routedTerminalSwitchOwners } from '../providers/terminal-switch-owners' import { combineUnsubscribes } from './combine-unsubscribes' import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' @@ -87,10 +86,6 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.freshSpawns.canProvideSnapshot(id) spawn = (opts: PtySpawnOptions): Promise => this.ownerRecovery.spawn(opts) - readonly terminalSwitches = routedTerminalSwitchOwners( - () => (this.freshSpawns.routesToFallback ? this.fallback : this.current), - () => this.allProviders() - ) // Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession. attach = (id: string): ReturnType => @@ -149,12 +144,17 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } } - sendSignal = (id: string, signal: string): Promise => - this.providerFor(id).sendSignal(id, signal) + async sendSignal(id: string, signal: string): Promise { + await this.providerFor(id).sendSignal(id, signal) + } - getCwd = (id: string): Promise => this.providerFor(id).getCwd(id) + async getCwd(id: string): Promise { + return this.providerFor(id).getCwd(id) + } - getInitialCwd = (id: string): Promise => this.providerFor(id).getInitialCwd(id) + async getInitialCwd(id: string): Promise { + return this.providerFor(id).getInitialCwd(id) + } async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { return (await this.providerFor(id).getAppliedSize?.(id)) ?? null diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index d23c96066f62..845608425caa 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -34,7 +34,7 @@ export class Session { private readonly onSessionExit?: (code: number) => void private readonly output: SessionOutputPlane private readonly producerPause: SessionProducerPause - readonly shellReady: SessionShellReadyBarrier + private readonly shellReady: SessionShellReadyBarrier private readonly termination: SessionTerminationController /** Public so the creating host can print its own notice as terminal output. */ readonly startupIngress: PtyStartupIngress diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index f144671f0feb..aa7b9fda956e 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -223,42 +223,3 @@ describe('concurrent createOrAttach across the async spawn', () => { expect(host.listSessions()).toEqual([]) }) }) - -describe('a switch start owned by the terminal host', () => { - it('holds its generation from before spawn until its session exits, and never reuses it', async () => { - let releaseSpawn: () => void = () => {} - const gate = new Promise((resolve) => { - releaseSpawn = resolve - }) - const proc = mockSubprocess() - const host = new TerminalHost({ - spawnSubprocess: vi.fn(async () => { - await gate - return proc - }) - }) - const { owner } = host.switches - const start = (sessionId: string, generation: string) => - host.createOrAttach({ - ...createOptions(sessionId), - switchGeneration: generation, - switchOwner: owner - }) - const seen = (generation: string, fence = false) => - host.switches.observe(generation, fence).observation - - const created = start('switch-1', 'g1') - await vi.waitFor(() => expect(seen('g1')).toEqual({ state: 'pending' })) - // A lost reply's retry names the same generation and never starts a second terminal. - await expect(start('switch-2', 'g1')).rejects.toThrow('terminal_switch_generation_refused') - releaseSpawn() - await created - expect(seen('g1', true)).toMatchObject({ state: 'bound', ptyId: 'switch-1', shellPid: 4242 }) - proc.emitExit(0) - await vi.waitFor(() => expect(seen('g1', true)).toEqual({ state: 'ended' })) - // An observation fences an unseen generation, so its late create refuses. - expect(seen('g2', true)).toEqual({ state: 'ended' }) - await expect(start('switch-3', 'g2')).rejects.toThrow('terminal_switch_generation_refused') - await host.dispose() - }) -}) diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 3a17a9c86106..9f6e6fbec1b1 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -35,8 +35,6 @@ export type CreateOrAttachOptions = { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } - switchGeneration?: string - switchOwner?: string streamClient: { onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void onExit: (code: number, incarnationId: PtyIncarnationId, cause?: TerminalExitCause) => void diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index e9f88329e475..ed5a06372c24 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,5 +1,4 @@ import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' -import type { TerminalHostSwitches } from './terminal-host-switches' import { discardStagedStartupCommand, stageStartupCommand, @@ -33,7 +32,6 @@ type TerminalHostSessionCreateDependencies = { killedTombstones: TerminalHostTombstones spawnSubprocess: TerminalHostOptions['spawnSubprocess'] spawnGate: TerminalSpawnGate | undefined - switches?: TerminalHostSwitches onDeadSessionRemoved: (sessionId: string) => void onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void onSessionExit: (sessionId: string, generation: string | undefined) => void @@ -153,31 +151,23 @@ async function spawnAndPublishSession( // Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path. const cwdReadableByDaemon = opts.cwd && !wslDistro ? await isCwdReadableByThisProcess(opts.cwd) : null - deps.switches?.begin(opts.switchGeneration, opts.switchOwner) - let subprocess: Awaited> - try { - subprocess = await deps.spawnSubprocess({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - shellOverride: opts.shellOverride, - terminalShellArgs: opts.terminalShellArgs, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, - isCanceled: opts.isCanceled, - ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) - }) - } catch (error) { - // A spawn that threw left no subprocess, so nothing can start under this generation. - deps.switches?.endUnstarted(opts.switchGeneration) - throw error - } + const subprocess = await deps.spawnSubprocess({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + cwd: opts.cwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + shellOverride: opts.shellOverride, + terminalShellArgs: opts.terminalShellArgs, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + isCanceled: opts.isCanceled, + ...(opts.cancelSignal ? { cancelSignal: opts.cancelSignal } : {}) + }) let staging: StartupCommandStaging | undefined // Why: a fallback shell does not emit the preferred shell's ready marker; @@ -214,8 +204,6 @@ async function spawnAndPublishSession( ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } : {}) }) - // From here only this session's own end ends the switch, canceled or not. - deps.switches?.bind(opts.switchGeneration, session) if (opts.isCanceled?.()) { // Retain cleanup ownership if the native child refuses to exit. diff --git a/src/main/daemon/terminal-host-switches.ts b/src/main/daemon/terminal-host-switches.ts deleted file mode 100644 index 0ea1edc94b4b..000000000000 --- a/src/main/daemon/terminal-host-switches.ts +++ /dev/null @@ -1,63 +0,0 @@ -// The daemon's ownership of chat → terminal switch starts. A generation is held from before its -// subprocess spawns, bound to the session the moment it exists, and only that session ends it. -import { randomUUID } from 'node:crypto' -import type { TerminalSwitchOwnerAnswer } from '../../shared/terminal-switch' -import type { Session } from './session' - -type HostSwitch = { state: 'pending' | 'ended' } | { state: 'bound'; session: Session } - -export class TerminalHostSwitches { - readonly owner = `daemon:${randomUUID()}` - private readonly entries = new Map() - - /** Before the subprocess: a foreign owner or an already used or fenced generation refuses. */ - begin(generation: string | undefined, owner: string | undefined): void { - if (generation === undefined) { - return - } - if (owner !== this.owner || this.entries.has(generation)) { - throw new Error('terminal_switch_generation_refused') - } - this.entries.set(generation, { state: 'pending' }) - } - - bind(generation: string | undefined, session: Session): void { - if (generation !== undefined) { - this.entries.set(generation, { state: 'bound', session }) - } - } - - /** The subprocess never spawned, so nothing can start or deliver under this generation. */ - endUnstarted(generation: string | undefined): void { - if (generation !== undefined && this.entries.get(generation)?.state === 'pending') { - this.entries.set(generation, { state: 'ended' }) - } - } - - /** An unseen generation is fenced, so a late create carrying it refuses and absence means no start. */ - observe(generation: string, fence: boolean): TerminalSwitchOwnerAnswer { - const entry: HostSwitch = this.entries.get(generation) ?? { - state: fence ? 'ended' : 'pending' - } - if (fence) { - this.entries.set(generation, entry) - } - if (entry.state !== 'bound') { - return { owner: this.owner, observation: { state: entry.state } } - } - const { session } = entry - return { - owner: this.owner, - observation: session.isAlive - ? { - state: 'bound', - ptyId: session.sessionId, - incarnationId: session.incarnationId, - shellPid: session.pid > 0 ? session.pid : null, - // The startup command is queued behind the shell-ready barrier until it flushes. - startupSettled: !session.shellReady.isGatingWrites - } - : { state: 'ended' } - } - } -} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 6588ff785b61..9a6f5c21c9a8 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,5 +1,4 @@ import type { Session } from './session' -import { TerminalHostSwitches } from './terminal-host-switches' import type { PtyProcessInspectionOptions } from '../providers/pty-process-inspection' import { SessionNotFoundError } from './types' import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' @@ -52,7 +51,6 @@ export class TerminalHost { private disposePromise: Promise | null = null private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations() - readonly switches = new TerminalHostSwitches() private readonly authorityGeneration = randomUUID() private observationEpoch = 0 private readonly retiredIncarnations = new Map< @@ -83,8 +81,13 @@ export class TerminalHost { } this.assertCreateOrAttachAllowed(opts) - const creation = Promise.withResolvers() - this.pendingCreations.set(opts.sessionId, creation.promise) + let settleCreation: () => void = () => {} + this.pendingCreations.set( + opts.sessionId, + new Promise((resolve) => { + settleCreation = resolve + }) + ) try { return await createOrAttachClaimedAgentSession({ options: opts, @@ -106,7 +109,6 @@ export class TerminalHost { killedTombstones: this.killedTombstones, spawnSubprocess: this.spawnOptions.spawnSubprocess, spawnGate: this.spawnOptions.spawnGate, - switches: this.switches, onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), onSessionCreated: (sessionId, generation, isAlive) => this.agentSessionGenerations.remember(sessionId, generation, isAlive), @@ -119,7 +121,7 @@ export class TerminalHost { }) } finally { this.pendingCreations.delete(opts.sessionId) - creation.resolve() + settleCreation() } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 05cee6524c64..080c474ab033 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -107,9 +107,6 @@ export type CreateOrAttachRequest = { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } - /** A switch start; sent only to a daemon that answered `observeTerminalSwitch`. */ - switchGeneration?: string - switchOwner?: string } } @@ -327,7 +324,6 @@ export type DaemonRequest = | TakePendingOutputRequest | PtyOwnerQueryProtocol.CloseStartupQueryAuthorityRequest | PtyOwnerQueryProtocol.SetColorQueryReplyColorsRequest - | PtyOwnerQueryProtocol.ObserveTerminalSwitchRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── diff --git a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts b/src/main/ipc/pty/host-env/resume-spawn-judgement.ts deleted file mode 100644 index 1d9ccc79c36d..000000000000 --- a/src/main/ipc/pty/host-env/resume-spawn-judgement.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { - AgentProviderSessionMetadata, - SleepingAgentLaunchConfig, - SleepingAgentSessionRecord -} from '../../../../shared/agent-session-resume' -import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' -import type { TuiAgent } from '../../../../shared/tui-agent' -import type { - AgentStatusIpcPayload, - RefusedPaneResume -} from '../../../../shared/agent-status-types' -import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' -import type { SwitchClaim } from '../../../runtime/session-tabs/structured-session-switch-claims' -import { TERMINAL_SWITCH_GENERATION_ENV } from '../../../../shared/terminal-switch' -import { - agentSessionLeasePossiblyWriting, - listStructuredProviderSessionOwnership -} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' -import { - keysMayMatch, - terminalConversationKey -} from '../../../native-chat/structured-conversation-key' - -export type ResumeSpawnArgs = { - connectionId?: string | null - command?: string - launchAgent?: TuiAgent - launchConfig?: SleepingAgentLaunchConfig - launchToken?: unknown - resumeProviderSession?: AgentProviderSessionMetadata - /** `saved`: built from a pane's saved facts; `switch`: the terminal half of a chat's switch. */ - resumeOrigin?: unknown - env?: Record -} - -export type AdmissionRuntime = { - getAgentProviderSessionRows?(): AgentStatusIpcPayload[] - readSavedResumeSource?( - worktreeId: string, - paneKey: string - ): Promise - openAgentSessionRecordStore(): Promise - acceptSwitchStart?(generation: string, paneKey: string): SwitchClaim | null - saveSwitchClaim?(claim: SwitchClaim): Promise - dispatchSwitchStart?(claim: SwitchClaim): { switchGeneration: string; switchOwner: string } -} - -/** The spawn options a switch admission reads its generation from and hands its owner fields to. */ -export type SwitchSpawnOptions = { - env?: Record - switchGeneration?: string - switchOwner?: string -} - -/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: - * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ -export type ResumeSpawnRefusal = { - sourceRecord?: SleepingAgentSessionRecord - verdict: 'chat-home' | 'chat-writing' | 'unverifiable' - refused: RefusedPaneResume -} - -export type ResumeSpawnAdmission = - | { - verdict: 'admitted' - refused: RefusedPaneResume - sourceRecord?: SleepingAgentSessionRecord - } - | ResumeSpawnRefusal - -export async function judgeResumes( - runtime: AdmissionRuntime, - resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]], - /** A switch's own start: its chat tab is still open on purpose, so only a writing chat refuses. */ - switchStart = false -): Promise { - const keysOf = ({ agent, providerSession }: RefusedPaneResume) => - [providerSession.id, providerSession.transcriptPath].flatMap((id) => - id?.trim() ? [terminalConversationKey(agent, id)] : [] - ) - const admitted = (): ResumeSpawnAdmission => ({ - verdict: 'admitted', - refused: resumes[0] - }) - let store: AgentSessionRecordStore - try { - store = await runtime.openAgentSessionRecordStore() - } catch (error) { - console.warn( - "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", - error - ) - return { verdict: 'unverifiable', refused: resumes[0] } - } - const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( - (holding) => holding.role === 'head' - ) - const tabs = store.getVisibleSessionTabIndex() - for (const refused of resumes) { - const holders = heads.filter((holding) => - keysOf(refused).some((key) => keysMayMatch(holding.key, key)) - ) - if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { - return { verdict: 'chat-writing', refused } - } - if (switchStart) { - continue - } - if (holders.length > 0 && (!tabs || !tabs.present)) { - return { verdict: 'unverifiable', refused } - } - if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { - return { verdict: 'chat-home', refused } - } - } - return admitted() -} - -/** The terminal half of a chat's switch. Its generation takes this pane and is saved on the chat - * before the owner gets it; a refusal sends nothing, and only the owner then says it never started. */ -export async function admitSwitchResumeSpawn( - runtime: AdmissionRuntime | undefined, - args: ResumeSpawnArgs, - wslDistro: string | null, - spawn?: { paneKey: string | null; options: SwitchSpawnOptions } -): Promise { - const generation = args.env?.[TERMINAL_SWITCH_GENERATION_ENV] - delete args.env?.[TERMINAL_SWITCH_GENERATION_ENV] - delete spawn?.options.env?.[TERMINAL_SWITCH_GENERATION_ENV] - const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) - const agent = args.launchAgent - if (!providerSession || !agent) { - throw new Error('switch_start_missing_conversation') - } - const refused = { agent, providerSession } - if (args.connectionId || wslDistro !== null || !generation || !spawn?.paneKey) { - return { verdict: 'unverifiable', refused } - } - // Before any await: a chat start whose census ends after this point sees the claim. - const claim = runtime?.acceptSwitchStart?.(generation, spawn.paneKey) - if (!claim || claim.providerSessionId !== providerSession.id) { - return { verdict: 'chat-writing', refused } - } - if (!(await runtime?.saveSwitchClaim?.(claim))) { - return { verdict: 'unverifiable', refused } - } - const admission = await judgeResumes(runtime, [refused], true) - if (admission.verdict === 'admitted' && runtime?.dispatchSwitchStart) { - Object.assign(spawn.options, runtime.dispatchSwitchStart(claim)) - } - return admission -} diff --git a/src/main/ipc/pty/host-env/saved-resume-admission.ts b/src/main/ipc/pty/host-env/saved-resume-admission.ts index 59fcc9b8c7f3..1f96ce843478 100644 --- a/src/main/ipc/pty/host-env/saved-resume-admission.ts +++ b/src/main/ipc/pty/host-env/saved-resume-admission.ts @@ -1,43 +1,72 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' import { normalizeAgentProviderSession, agentProviderSessionsEqual, + type AgentProviderSessionMetadata, + type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import type { AgentSessionRecordStore } from '../../../runtime/agent-session-record-store' +import { + agentSessionLeasePossiblyWriting, + listStructuredProviderSessionOwnership +} from '../../../native-chat/agent-session-wire/structured-provider-session-ownership' import { agentHookServer } from '../../../agent-hooks/server' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { - admitSwitchResumeSpawn, - judgeResumes, - type AdmissionRuntime, - type ResumeSpawnAdmission, - type ResumeSpawnArgs, - type ResumeSpawnRefusal, - type SwitchSpawnOptions -} from './resume-spawn-judgement' + keysMayMatch, + terminalConversationKey +} from '../../../native-chat/structured-conversation-key' + +export type ResumeSpawnArgs = { + connectionId?: string | null + command?: string + launchAgent?: TuiAgent + launchConfig?: SleepingAgentLaunchConfig + launchToken?: unknown + resumeProviderSession?: AgentProviderSessionMetadata + /** Saved pane restore or explicit same-id switch from a native chat. */ + resumeOrigin?: unknown + env?: Record +} + +type AdmissionRuntime = { + getAgentProviderSessionRows?(): AgentStatusIpcPayload[] + readSavedResumeSource?( + worktreeId: string, + paneKey: string + ): Promise + openAgentSessionRecordStore(): Promise +} + +/** `chat-home`: an automatic restore of a conversation an open chat continues; `chat-writing`: + * any start of one a chat may be writing; `unverifiable`: an automatic restore with no records. */ +export type ResumeSpawnRefusal = { + sourceRecord?: SleepingAgentSessionRecord + verdict: 'chat-home' | 'chat-writing' | 'unverifiable' + refused: RefusedPaneResume +} -export type { - ResumeSpawnAdmission, - ResumeSpawnArgs, - ResumeSpawnRefusal -} from './resume-spawn-judgement' +export type ResumeSpawnAdmission = + | { verdict: 'admitted'; refused: RefusedPaneResume; sourceRecord?: SleepingAgentSessionRecord } + | ResumeSpawnRefusal -/** Saved-home policy and a switch's own start; other terminal starts remain hook-observed. */ +/** Saved-home policy only; ordinary terminal starts remain hook-observed. */ export async function admitResumeSpawn( runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, - wslDistro: string | null, - spawn?: { paneKey: string | null; options: SwitchSpawnOptions } + wslDistro: string | null ): Promise { - if (args.resumeOrigin === 'switch') { - return admitSwitchResumeSpawn(runtime, args, wslDistro, spawn) - } const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) const agent = args.launchAgent // Native chats run only natively, so a remote or WSL conversation is never a chat's. if ( - args.resumeOrigin !== 'saved' || + (args.resumeOrigin !== 'saved' && args.resumeOrigin !== 'switch') || !providerSession || !agent || args.connectionId || @@ -48,7 +77,7 @@ export async function admitResumeSpawn( if (!runtime) { return { verdict: 'unverifiable', refused: { agent, providerSession } } } - return judgeResumes(runtime, [{ agent, providerSession }]) + return judgeResumes(runtime, [{ agent, providerSession }], args.resumeOrigin === 'switch') } /** A reattach that found its session gone returns a fresh shell its window then resumes from the @@ -86,6 +115,49 @@ export async function admitColdRestoreReattach( return first ? judgeResumes(runtime, [first]) : null } +async function judgeResumes( + runtime: AdmissionRuntime, + resumes: readonly [RefusedPaneResume, ...RefusedPaneResume[]], + switchStart = false +): Promise { + const keysOf = ({ agent, providerSession }: RefusedPaneResume) => + [providerSession.id, providerSession.transcriptPath].flatMap((id) => + id?.trim() ? [terminalConversationKey(agent, id)] : [] + ) + let store: AgentSessionRecordStore + try { + store = await runtime.openAgentSessionRecordStore() + } catch (error) { + console.warn( + "[pty] native chat records unreadable; cannot tell whether a resume is a chat's", + error + ) + return { verdict: 'unverifiable', refused: resumes[0] } + } + const heads = listStructuredProviderSessionOwnership(store.listRecords()).filter( + (holding) => holding.role === 'head' + ) + const tabs = store.getVisibleSessionTabIndex() + for (const refused of resumes) { + const holders = heads.filter((holding) => + keysOf(refused).some((key) => keysMayMatch(holding.key, key)) + ) + if (holders.some((holding) => agentSessionLeasePossiblyWriting(holding.lease))) { + return { verdict: 'chat-writing', refused } + } + if (switchStart) { + continue + } + if (holders.length > 0 && (!tabs || !tabs.present)) { + return { verdict: 'unverifiable', refused } + } + if (tabs && holders.some((holding) => tabs.sessionIds.includes(holding.sessionId))) { + return { verdict: 'chat-home', refused } + } + } + return { verdict: 'admitted', refused: resumes[0] } +} + /** A refused resume becomes 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 { @@ -153,21 +225,14 @@ export function savedResumeRecord( /** Runs only in the fresh branch, after stable/claimed owner adoption has settled. */ export async function chooseSavedResumeFreshLaunch( - spawn: { - deps: { runtime?: AdmissionRuntime } - expectedWslDistro: string | null - spawnOptions: ResumeSpawnArgs & - SwitchSpawnOptions & { startupCommandDelivery?: unknown; startupIngress?: unknown } - }, + runtime: AdmissionRuntime | undefined, args: ResumeSpawnArgs, - paneKey: string | null + wslDistro: string | null, + options: ResumeSpawnArgs & { startupCommandDelivery?: unknown; startupIngress?: unknown } ): Promise { - const admission = await admitResumeSpawn(spawn.deps.runtime, args, spawn.expectedWslDistro, { - paneKey, - options: spawn.spawnOptions - }) + const admission = await admitResumeSpawn(runtime, args, wslDistro) if (resumeSpawnRefusal(admission)) { - suppressSavedResumeLaunch(args, spawn.spawnOptions) + suppressSavedResumeLaunch(args, options) } return admission } diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index a69b92b2f007..a5c7b9a0d137 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -24,7 +24,12 @@ export type PtyKillIpcDeps = { shutdownProviderAndDetectExit: ( provider: IPtyProvider, id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ) => Promise rememberSyntheticKillExit: (id: string, incarnationId?: string) => void sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 789d1047484c..207c8c06c468 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -1,3 +1,4 @@ +import { retireChatForAdmittedTerminalLaunch } from '../../../runtime/session-tabs/structured-session-terminal-launch-retirement' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { agentHookServer } from '../../../agent-hooks/server' import { registerPty } from '../../../memory/pty-registry' @@ -177,8 +178,7 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const chooseFresh = async (): Promise => { ctx.spawnOptions.env = { ...ctx.spawnOptions.env } args.launchConfig = ctx.effectiveLaunchConfig - ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(ctx, args, ctx.stablePaneKey) + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + ) if (captured && ctx.resumeAdmission) { ctx.resumeAdmission.refused = captured.refused ctx.resumeAdmission.sourceRecord = captured.sourceRecord diff --git a/src/main/ipc/pty/pane/resume-launch-identity.test.ts b/src/main/ipc/pty/pane/resume-launch-identity.test.ts index f6cd27f3612b..782118dfc5a0 100644 --- a/src/main/ipc/pty/pane/resume-launch-identity.test.ts +++ b/src/main/ipc/pty/pane/resume-launch-identity.test.ts @@ -42,13 +42,6 @@ describe('seedResumeLaunchIdentityFromSpawn', () => { }) }) - it('names the exact execution only for a switch start', () => { - seed({ switchGeneration: 'g1', result: { id: 'pty-1', incarnationId: 'i1' } }) - expect(recordLaunchResumeIdentity).toHaveBeenCalledWith( - expect.objectContaining({ execution: { ptyId: 'pty-1', incarnationId: 'i1' } }) - ) - }) - it.each([ ['a reattached process', { result: { id: 'pty-1', isReattach: true } }], ['a Codex resume that fell back to a fresh session', { resumeArgvDropped: true }], diff --git a/src/main/ipc/pty/pane/resume-launch-identity.ts b/src/main/ipc/pty/pane/resume-launch-identity.ts index 0757be7c7ca1..ab0945d0fdbb 100644 --- a/src/main/ipc/pty/pane/resume-launch-identity.ts +++ b/src/main/ipc/pty/pane/resume-launch-identity.ts @@ -4,7 +4,6 @@ import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' import type { PtySpawnResult } from '../../../providers/pty-provider-contract' -import type { TerminalExecutionStamp } from '../../../../shared/terminal-switch' /** * Record a fresh resume launch's provider session in the hook store, so every reader (phone tab @@ -21,8 +20,6 @@ export function seedResumeLaunchIdentityFromSpawn(args: { /** The Codex resume fell back to a fresh session, so the pane does not hold this one. */ resumeArgvDropped: boolean result: PtySpawnResult - /** Set on a chat → terminal switch start: its row then names the exact execution. */ - switchGeneration?: string }): void { // Why: a reattached process already reported its own identity; an SSH pane's rows belong to // its relay, which owns that host's status. @@ -35,12 +32,10 @@ export function seedResumeLaunchIdentityFromSpawn(args: { ) { return } - const { id: ptyId, incarnationId } = args.result seedResumeLaunchIdentity({ ...args, paneKey: args.paneKey, - launchedAt: args.launchedAt.getTime(), - ...(args.switchGeneration && incarnationId ? { execution: { ptyId, incarnationId } } : {}) + launchedAt: args.launchedAt.getTime() }) } @@ -54,7 +49,6 @@ export function seedResumeLaunchIdentity(args: { resumeProviderSession: unknown worktreeId: unknown launchedAt: number - execution?: TerminalExecutionStamp }): void { const agent = args.launchAgent const providerSession = normalizeAgentProviderSession(args.resumeProviderSession) @@ -70,7 +64,6 @@ export function seedResumeLaunchIdentity(args: { ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), agentType: agent, providerSession, - launchedAt: args.launchedAt, - ...(args.execution ? { execution: args.execution } : {}) + launchedAt: args.launchedAt }) } diff --git a/src/main/ipc/pty/provider/shutdown-detect.ts b/src/main/ipc/pty/provider/shutdown-detect.ts index b43928355aca..70828ff49c84 100644 --- a/src/main/ipc/pty/provider/shutdown-detect.ts +++ b/src/main/ipc/pty/provider/shutdown-detect.ts @@ -4,10 +4,15 @@ import { ptyIncarnationById } from './ownership-state' export async function shutdownProviderAndDetectExit( provider: IPtyProvider, id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ): Promise { let providerExitObserved = false - const expectedIncarnationId = ptyIncarnationById.get(id) + const expectedIncarnationId = opts.expectedIncarnationId ?? ptyIncarnationById.get(id) const unsubscribe = provider.onExit((payload) => { if ( payload.id === id && diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 667900bf474c..44f39f37ab22 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -45,7 +45,12 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & { shutdownProviderAndDetectExit: ( provider: IPtyProvider, id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ) => Promise rememberSyntheticKillExit: (id: string, incarnationId?: string) => void rememberRetiredRejectedPty: (id: string) => void diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 52bb65941796..f80e78897bf9 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -1,6 +1,4 @@ -import { getProvider, localProvider } from '../provider/registry' -import { routedTerminalSwitchOwners } from '../../../providers/terminal-switch-owners' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { localProvider } from '../provider/registry' import { getLocalAutomaticWorkStatus, resumeLocalAutomaticWork @@ -76,11 +74,6 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), resetInputModes: (ptyId) => resetInputModesFromRuntimeController(deps, ptyId), hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), - // Resolved per call: the local route can move between the daemon and its in-process fallback. - terminalSwitches: routedTerminalSwitchOwners( - () => getProvider(LOCAL_EXECUTION_HOST_ID), - () => [getProvider(LOCAL_EXECUTION_HOST_ID)] - ), listProcesses: (hostId, opts) => listProcessesFromRuntimeController(deps, hostId, opts), listProcessesWithHostScope: (opts) => listProcessesWithHostScopeFromRuntimeController(deps, opts), diff --git a/src/main/ipc/pty/runtime/kill-same-id-respawn.test.ts b/src/main/ipc/pty/runtime/kill-same-id-respawn.test.ts index 42f92d03329d..499fef71b3fa 100644 --- a/src/main/ipc/pty/runtime/kill-same-id-respawn.test.ts +++ b/src/main/ipc/pty/runtime/kill-same-id-respawn.test.ts @@ -76,6 +76,23 @@ describe('a stop whose shutdown lands after a same-id respawn', () => { expect(ports.sendPtyExitToRenderer).not.toHaveBeenCalled() } + it('refuses a captured stop after provider startup replaced the same id', async () => { + const h = start() + const shutdown = vi.fn(async () => false) + const ports = { + ...h.ports, + shutdownProviderAndDetectExit: shutdown, + getLocalPtyProviderStartupPromise: () => Promise.resolve().then(respawn) + } + await expect( + stopAndWaitPtyFromRuntimeController(asControllerDeps(ports), PTY_ID, { + expectedIncarnationId: 'inc-stopped' + }) + ).resolves.toBe(false) + expect(shutdown).not.toHaveBeenCalled() + expectRespawnUntouched(h) + }) + it('leaves the respawn alone after a runtime kill', async () => { const h = start() killPtyFromRuntimeController(asControllerDeps(h.ports), PTY_ID) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index cc18dfa7002e..5f33778fa161 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -195,7 +195,7 @@ export function retireRejectedPtyFromRuntimeController( export async function stopAndWaitPtyFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: { keepHistory?: boolean; deadlineMs?: number; expectedIncarnationId?: string } ): Promise { const { runtime, @@ -206,6 +206,9 @@ export async function stopAndWaitPtyFromRuntimeController( } = deps runtime?.markPtyStopRequested?.(ptyId) const connectionId = getPtySshConnectionId(ptyId) + if (opts?.expectedIncarnationId && ptyIncarnationById.get(ptyId) !== opts.expectedIncarnationId) { + return false + } if (connectionId && isLegacyRelayBridgedPty(ptyId)) { // A reversible stop must not leave the bridge's durable stop behind, so the pane stays live. if (opts?.keepHistory) { @@ -252,6 +255,9 @@ export async function stopAndWaitPtyFromRuntimeController( await startupPromise } } + if (opts?.expectedIncarnationId && ptyIncarnationById.get(ptyId) !== opts.expectedIncarnationId) { + return false + } let provider: IPtyProvider try { provider = getProviderForPty(ptyId) @@ -271,7 +277,8 @@ export async function stopAndWaitPtyFromRuntimeController( providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { immediate: true, keepHistory: opts?.keepHistory ?? false, - deadlineMs + deadlineMs, + ...(opts?.expectedIncarnationId ? { expectedIncarnationId: opts.expectedIncarnationId } : {}) }) } catch (err) { if (!isPtyAlreadyGoneError(err)) { diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 12f4cf4cd903..8f0d43f4f1f9 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,3 +1,4 @@ +import { retireChatForAdmittedTerminalLaunch } from '../../../runtime/session-tabs/structured-session-terminal-launch-retirement' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { ptySizes } from '../delivery/visibility-state' import { commitRuntimePtySize } from './spawn-commit-pty-size' @@ -244,8 +245,7 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { worktreeId: args.worktreeId, launchedAt: ctx.codexHomeLaunchStartedAt, resumeArgvDropped: ctx.codexResumeArgvDropped, - result: ctx.result, - switchGeneration: ctx.spawnOptions.switchGeneration + result: ctx.result }) // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) @@ -288,5 +288,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { : { snapshotKittyKeyboardFlags: undefined }), isReattach: true }) + await retireChatForAdmittedTerminalLaunch(ctx.deps.runtime, args, response) return response } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 89d32f016fdc..64133237fc54 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -45,7 +45,12 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise } const chooseFresh = async (): Promise => { ctx.spawnOptions.env = { ...ctx.spawnOptions.env } - ctx.resumeAdmission = await chooseSavedResumeFreshLaunch(ctx, args, ctx.spawnIdentityPaneKey) + ctx.resumeAdmission = await chooseSavedResumeFreshLaunch( + ctx.deps.runtime, + args, + ctx.expectedWslDistro, + ctx.spawnOptions + ) if (captured && ctx.resumeAdmission) { ctx.resumeAdmission.refused = captured.refused ctx.resumeAdmission.sourceRecord = captured.sourceRecord diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 5ca761ba57ab..90c5a3d6a18d 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -115,7 +115,7 @@ export type RuntimePtySpawnArgs = { envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' | 'switch' + resumeOrigin?: 'saved' connectionId?: string | null worktreeId?: string preAllocatedHandle?: string diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index 98a759b898e4..287c72f599c7 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -147,7 +147,12 @@ export type PtyIpcSession = { shutdownProviderAndDetectExit: ( provider: IPtyProvider, id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number + expectedIncarnationId?: string + } ) => Promise rememberSyntheticKillExit: (id: string, incarnationId?: string) => void rememberRetiredRejectedPty: (id: string) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 09ba39159c36..5a2028e82fc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,6 +1,5 @@ import { isDeepStrictEqual } from 'node:util' import { abortSignalReason } from '../../../shared/abort-signal-reason' -import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionProcessIdentity, AgentSessionRecord @@ -49,12 +48,9 @@ export async function acquireOwner( try { await input.assertNoTerminalWriter?.(record) } catch (error) { - const moving = - isAgentSessionRefusalError(error) && - error.refusal.details?.reason === 'conversationMovingToTerminal' // Not a code: a code message would travel as the census's own conflict, not this reason. throw new AgentSessionPreSpawnError(error, { - reason: moving ? 'conversationMovingToTerminal' : 'conversationOpenInTerminal', + reason: 'conversationOpenInTerminal', message: 'a terminal may still be writing this conversation' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index d970f207c7ba..7fd0a9513a2f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -135,7 +135,6 @@ export type AgentSessionPreSpawnReason = Extract< | 'agentCommandNotRunnable' | 'runtimeNotInstalled' | 'conversationOpenInTerminal' - | 'conversationMovingToTerminal' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 906e29f89042..d272f5422c89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -331,12 +331,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) }, - /** Close's body behind an idle-only admission with no effect of its own; `admit` registers the - * switch before the stop, in the same step. */ - switchToTerminal: (id: string, admit: () => T): Promise => + /** Idle admission and proven-exit stop share the session queue. */ + switchToTerminal: (id: string): Promise => serialize(id, () => switchAdmission.stopIdleStructuredAgentSessionForSwitch( - { session: sessions.get(id), delivering: host.deliveryActive(id), idleSweep, admit }, + { session: sessions.get(id), delivering: host.deliveryActive(id), idleSweep }, () => holdStopClose(id, () => stopAgent(id, { cause: 'user-close' })), () => deps().store.getRecord(id) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index c77dbe72321f..5f6a22005656 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -41,8 +41,7 @@ const TYPED_START_REFUSALS = [ 'agentCommandNotRunnable', 'runtimeNotInstalled', 'conversationOpenInTerminal', - 'conversationOpenElsewhere', - 'conversationMovingToTerminal' + 'conversationOpenElsewhere' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index a9fd067f10d4..2cd5f9cacdeb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -212,7 +212,7 @@ export class StructuredAgentSessionHost { * `user-close` makes a turn it cuts short the user's cancellation; an `evict` leaves it news. */ close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) - switchToTerminal = (id: string, admit: () => T) => this.lifetime.switchToTerminal(id, admit) + switchToTerminal = (id: string) => this.lifetime.switchToTerminal(id) /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts index 2e1d44763137..46d075a0f749 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts @@ -10,18 +10,17 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi /** In the chat's ordered step: refuses unless nothing is running, starting, queued or owed, then * admits, stops through close's body, and proves the agent gone before anything starts elsewhere. */ -export async function stopIdleStructuredAgentSessionForSwitch( +export async function stopIdleStructuredAgentSessionForSwitch( idle: { session: StructuredAgentSessionHostSession | undefined delivering: boolean idleSweep: { owesWork: (sessionId: string, session: StructuredAgentSessionHostSession) => boolean } - admit: () => T }, stop: () => Promise, record: () => AgentSessionRecord | null -): Promise { +): Promise { const { session } = idle if ( idle.delivering || @@ -33,11 +32,9 @@ export async function stopIdleStructuredAgentSessionForSwitch( ) { throw agentSessionRefusalError('agent_session_operation_invalid', { reason: 'turnActive' }) } - const admitted = idle.admit() await stop() const lease = record()?.lease if (!lease || agentSessionLeaseOwnerVerdict(lease) !== 'exited') { throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }) } - return admitted } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index 7c7b9654cdae..e8414016524e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -1,3 +1,5 @@ +import { retireChatForAdmittedTerminalLaunch } from '../../runtime/session-tabs/structured-session-terminal-launch-retirement' +import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -88,6 +90,7 @@ beforeEach(async () => { }) afterEach(async () => { + setStructuredAgentSessionHost(null) await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) @@ -145,7 +148,7 @@ it('refuses the chat agent start while a terminal writes its conversation, once }) }) -// The switch to a terminal: admitted only when idle, registers before it stops, proves the stop. +// The switch is idle-only and proves the stop; subsequent sends use ordinary G-acquire. it('switches an idle chat to its terminal and a racing send starts no second writer', async () => { const order: string[] = [] const closeSession = vi.mocked(host.deps.adapter.closeSession) @@ -153,11 +156,9 @@ it('switches an idle chat to its terminal and a racing send starts no second wri order.push('stop') return true }) - await host.switchToTerminal(SESSION, () => { - order.push('claim') - terminalWriting = true - }) - expect(order).toEqual(['claim', 'stop']) + await host.switchToTerminal(SESSION) + expect(order).toEqual(['stop']) + terminalWriting = true expect(agentSessionLeaseOwnerVerdict(store.getRecord(SESSION)!.lease)).toBe('exited') const spawnsBefore = acquire.mock.calls.length const raced = await rejection(await send('from another window')) @@ -179,11 +180,9 @@ it('refuses a switch while the chat is starting, without aborting that start', a }) await send('starts the agent') await vi.waitFor(() => expect(signal).toBeDefined()) - const admit = vi.fn() - await expect(host.switchToTerminal(SESSION, admit)).rejects.toMatchObject({ + await expect(host.switchToTerminal(SESSION)).rejects.toMatchObject({ refusal: { details: { reason: 'turnActive' } } }) - expect(admit).not.toHaveBeenCalled() expect(signal?.aborted).toBe(false) release() }) @@ -215,3 +214,36 @@ it('starts the chat that holds the conversation for a switch from its terminal', expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') expect(vi.mocked(host.deps.adapter.dispatch)).not.toHaveBeenCalled() }) + +// A delayed admitted launch retires presentation only, even if a later send already restarted the chat. +it('retires only after an explicit admitted launch without stopping a later native child', async () => { + setStructuredAgentSessionHost(host) + await host.setSessionTabVisibility(SESSION, true) + await host.switchToTerminal(SESSION) + const runtime = { retireStructuredAgentSessionTabFromSnapshot: vi.fn(() => true) } + const args = { + resumeOrigin: 'switch', + worktreeId: store.getRecord(SESSION)?.location.workspaceId + } + const decision = { + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: THREAD }, + verdict: 'admitted' as const + } + for (const result of [ + {}, + { savedResumeDecision: { ...decision, verdict: 'unverifiable' as const } }, + { savedResumeDecision: decision, isReattach: true } + ]) { + await retireChatForAdmittedTerminalLaunch(runtime, args, result) + expect(store.getVisibleSessionTabIndex()?.sessionIds).toContain(SESSION) + } + expect(await host.switchToChat(SESSION)).toEqual({ ok: true }) + const laterChild = host.collaboratorsForTests().sessions.get(SESSION)?.child + const closes = vi.mocked(host.deps.adapter.closeSession).mock.calls.length + await retireChatForAdmittedTerminalLaunch(runtime, args, { savedResumeDecision: decision }) + expect(store.getVisibleSessionTabIndex()?.sessionIds).not.toContain(SESSION) + expect(runtime.retireStructuredAgentSessionTabFromSnapshot).toHaveBeenCalledWith(SESSION) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child).toBe(laterChild) + expect(host.deps.adapter.closeSession).toHaveBeenCalledTimes(closes) +}) diff --git a/src/main/providers/local-pty-provider-shutdown.test.ts b/src/main/providers/local-pty-provider-shutdown.test.ts index a3b4021411b0..24a86a9997a9 100644 --- a/src/main/providers/local-pty-provider-shutdown.test.ts +++ b/src/main/providers/local-pty-provider-shutdown.test.ts @@ -163,6 +163,17 @@ describe('LocalPtyProvider', () => { }) describe('shutdown', () => { + it('fences the actual local shutdown before cancelling or signalling a replacement', async () => { + const kill = mockProc.kill + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + await expect( + provider.shutdown(id, { immediate: true, expectedIncarnationId: 'earlier' }) + ).rejects.toThrow('terminal_handle_stale') + expect(kill).not.toHaveBeenCalled() + await provider.shutdown(id, { immediate: true, expectedIncarnationId: incarnationId }) + expect(kill).toHaveBeenCalled() + }) + it('kills the PTY process', async () => { // Why: capture the spy reference before shutdown triggers onExit → // POSIX kill neutralization. After neutralization, mockProc.kill is diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index 6795733230ea..a6eb775f461f 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -610,28 +610,5 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledOnce() expect(committed).toHaveBeenCalledOnce() }) - - it('stops the process of a switch start whose commit callback fails, and ends on its exit', async () => { - spawnMock.mockClear() - const { kill } = mockProc - const switchOwner = (await provider.terminalSwitches.owner()) ?? '' - const seen = async () => (await provider.terminalSwitches.observe('g-local', true))[0] - const start = () => - provider.spawn({ - cols: 80, - rows: 24, - switchGeneration: 'g-local', - switchOwner, - onPtySpawnCommitted: () => { - throw new Error('commit callback failed') - } - }) - await expect(start()).rejects.toThrow('commit callback failed') - // The failure stops that exact process, and its own exit (not the throw) ends the switch. - expect(kill).toHaveBeenCalledWith('SIGKILL') - expect((await seen())?.observation).toEqual({ state: 'ended' }) - await expect(start()).rejects.toThrow('terminal_switch_generation_refused') - expect(spawnMock).toHaveBeenCalledOnce() - }) }) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 6241d0aff92f..5e7cccd8140e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -53,7 +53,6 @@ import { } from './local-pty-session-operations' import { spawnLocalPty } from './local-pty-spawn' import { cancelAllPendingLocalPtySpawns } from './local-pty-spawn-state' -import { localTerminalSwitchOwner, observeLocalTerminalSwitch } from './local-pty-terminal-switch' import { killAllLocalPtys, killOrphanedLocalPtys, shutdownLocalPty } from './local-pty-termination' export type { LocalPtyProviderOptions } from './local-pty-provider-types' @@ -83,10 +82,6 @@ export class LocalPtyProvider implements IPtyProvider { spawn(args: PtySpawnOptions): Promise { return spawnLocalPty(args, () => this.opts) } - readonly terminalSwitches = { - owner: async () => localTerminalSwitchOwner(), - observe: observeLocalTerminalSwitch - } // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. async attach(_id: string): Promise {} @@ -117,7 +112,10 @@ export class LocalPtyProvider implements IPtyProvider { return getLocalPtyAppliedSize(id) } - shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { + shutdown( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; expectedIncarnationId?: string } + ): Promise { return shutdownLocalPty(id, opts) } diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index cdea1a8f881b..e7782b34bb8d 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -1,5 +1,4 @@ import type * as pty from 'node-pty' -import { settleLocalTerminalSwitchStartup } from './local-pty-terminal-switch' import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission' import { discardStagedStartupCommand, @@ -207,12 +206,9 @@ export function activateLocalPtySession(args: { readiness.setStartupCommandCleanup(cleanup) }, { - bracketedPasteSafe, - onSettled: () => settleLocalTerminalSwitchStartup(id) + bracketedPasteSafe } ) - } else { - settleLocalTerminalSwitchStartup(id) } // Why: publish the OS pid for the memory collector; proc.pid can be briefly 0/undefined before node-pty sees the child. diff --git a/src/main/providers/local-pty-shell-ready-startup-command.ts b/src/main/providers/local-pty-shell-ready-startup-command.ts index 71b3b38c34cd..5fefb5db5fac 100644 --- a/src/main/providers/local-pty-shell-ready-startup-command.ts +++ b/src/main/providers/local-pty-shell-ready-startup-command.ts @@ -18,16 +18,13 @@ export function writeStartupCommandWhenShellReady( startupCommand: string, onExit: (cleanup: () => void) => void, // Why: only shells with bracketed-paste active (see isBracketedPasteSafeShell) accept the wrapper; others use the raw path so ESC[200~ isn't echoed. - options: { bracketedPasteSafe?: boolean; onSettled?: () => void } = {} + options: { bracketedPasteSafe?: boolean } = {} ): void { let sent = false let postReadyTimer: ReturnType | null = null let postReadyDataDisposable: { dispose: () => void } | null = null const cleanup = (): void => { - if (!sent) { - options.onSettled?.() - } sent = true if (postReadyTimer !== null) { clearTimeout(postReadyTimer) @@ -55,7 +52,6 @@ export function writeStartupCommandWhenShellReady( bracketedPasteSafe: options.bracketedPasteSafe === true }) ) - options.onSettled?.() } const schedulePostReadyFlush = (): void => { diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 7e2e903a60d9..50e01861b8a2 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -1,9 +1,4 @@ import { randomUUID } from 'node:crypto' -import { - beginLocalTerminalSwitch, - commitLocalTerminalSwitch, - endUnstartedLocalTerminalSwitch -} from './local-pty-terminal-switch' import { win32 as pathWin32 } from 'node:path' import { mkdir } from 'node:fs/promises' import { JCODE_RUNTIME_DIR_ENV_KEY } from '../../shared/jcode-runtime-dir' @@ -61,9 +56,7 @@ export async function spawnLocalPty( } } const id = allocatePtyId(reattachId ?? undefined) - beginLocalTerminalSwitch(args.switchGeneration, args.switchOwner) - let committed = false - const spawned = runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { + return runCancelableLocalPtySpawn(id, async (throwIfCanceled, cancellation) => { const incarnationId = randomUUID() let plan = createLocalPtyLaunchPlan(args, getOptions) if (plan instanceof DeferredLocalPtyLaunchPlan) { @@ -133,13 +126,6 @@ export async function spawnLocalPty( windowsFallbackAttempts: plan.windowsFallbackAttempts }) const spawnResult = pendingSpawn instanceof Promise ? await pendingSpawn : pendingSpawn - // The process exists: from here only its own exit ends the switch, whatever throws next. - committed = true - commitLocalTerminalSwitch(args.switchGeneration, { - id, - incarnationId, - proc: spawnResult.process - }) try { checkCanceled() } catch (error) { @@ -150,19 +136,7 @@ export async function spawnLocalPty( } throw error } - try { - args.onPtySpawnCommitted?.() - } catch (error) { - // A switch keeps its claim until this exact process exits; stop it so that exit comes. - if (args.switchGeneration !== undefined) { - try { - spawnResult.process.kill('SIGKILL') - } finally { - destroyPtyProcess(spawnResult.process) - } - } - throw error - } + args.onPtySpawnCommitted?.() plan.shellPath = spawnResult.shellPath // Why: a Windows fallback embeds its startup command in argv; honor the winning shell's delivery flag to avoid a double write. if (spawnResult.startupCommandDeliveredInShellArgs !== undefined) { @@ -198,10 +172,4 @@ export async function spawnLocalPty( }) }) }) - // A spawn that never committed a process (thrown, canceled, or joined a winner) started nothing. - return spawned.finally(() => { - if (!committed) { - endUnstartedLocalTerminalSwitch(args.switchGeneration) - } - }) } diff --git a/src/main/providers/local-pty-terminal-switch.ts b/src/main/providers/local-pty-terminal-switch.ts deleted file mode 100644 index 2981f8ad66bd..000000000000 --- a/src/main/providers/local-pty-terminal-switch.ts +++ /dev/null @@ -1,101 +0,0 @@ -// The in-process owner of a chat → terminal switch start. A generation is bound before any physical -// work and to the process the moment it exists, so a publication that throws later cannot hide it. -import { randomUUID } from 'node:crypto' -import type { IPty } from 'node-pty' -import type { TerminalSwitchOwnerAnswer } from '../../shared/terminal-switch' - -const OWNER = `local:${randomUUID()}` - -type LocalSwitch = - | { state: 'pending' | 'ended' } - | { - state: 'bound' - id: string - incarnationId: string - pid: number - exited: boolean - startupSettled: boolean - } - -const switches = new Map() - -export function localTerminalSwitchOwner(): string { - return OWNER -} - -/** Before any physical work: a foreign owner or an already used or fenced generation refuses. */ -export function beginLocalTerminalSwitch(generation: string | undefined, owner?: string): void { - if (generation === undefined) { - return - } - if (owner !== OWNER || switches.has(generation)) { - throw new Error('terminal_switch_generation_refused') - } - switches.set(generation, { state: 'pending' }) -} - -/** The process exists: from here only its own exit ends the switch. */ -export function commitLocalTerminalSwitch( - generation: string | undefined, - execution: { id: string; incarnationId: string; proc: IPty } -): void { - if (generation === undefined) { - return - } - const bound: LocalSwitch = { - state: 'bound', - id: execution.id, - incarnationId: execution.incarnationId, - pid: execution.proc.pid, - exited: false, - startupSettled: false - } - switches.set(generation, bound) - execution.proc.onExit(() => { - bound.exited = true - }) -} - -/** A start that threw before its process existed started nothing. */ -export function endUnstartedLocalTerminalSwitch(generation: string | undefined): void { - if (generation !== undefined && switches.get(generation)?.state === 'pending') { - switches.set(generation, { state: 'ended' }) - } -} - -/** The startup command was written, revoked, or never owed: nothing can still deliver it. */ -export function settleLocalTerminalSwitchStartup(id: string): void { - for (const entry of switches.values()) { - if (entry.state === 'bound' && entry.id === id) { - entry.startupSettled = true - } - } -} - -/** An unseen generation is fenced, so a late start carrying it refuses and absence means no start. */ -export async function observeLocalTerminalSwitch( - generation: string, - fence: boolean -): Promise { - const entry: LocalSwitch = switches.get(generation) ?? { state: fence ? 'ended' : 'pending' } - if (fence) { - switches.set(generation, entry) - } - const observation = - entry.state !== 'bound' - ? { state: entry.state } - : entry.exited - ? { state: 'ended' as const } - : { - state: 'bound' as const, - ptyId: entry.id, - incarnationId: entry.incarnationId, - shellPid: entry.pid > 0 ? entry.pid : null, - startupSettled: entry.startupSettled - } - return [{ owner: OWNER, observation }] -} - -export function resetLocalTerminalSwitchesForTest(): void { - switches.clear() -} diff --git a/src/main/providers/local-pty-termination.ts b/src/main/providers/local-pty-termination.ts index 426a7aad346e..af8c046514ed 100644 --- a/src/main/providers/local-pty-termination.ts +++ b/src/main/providers/local-pty-termination.ts @@ -11,6 +11,7 @@ import { ptyAgentSessionIds, ptyForceKillTimers, ptyPhysicalExits, + ptyIncarnations, ptyProcesses, ptyShutdownOperations, ptyTerminationMode, @@ -189,8 +190,11 @@ async function shutdownTrackedPty( export async function shutdownLocalPty( id: string, - opts: { immediate?: boolean; keepHistory?: boolean } + opts: { immediate?: boolean; keepHistory?: boolean; expectedIncarnationId?: string } ): Promise { + if (opts.expectedIncarnationId && ptyIncarnations.get(id) !== opts.expectedIncarnationId) { + throw new Error('terminal_handle_stale') + } cancelPendingLocalPtySpawns(id) const pending = ptyShutdownOperations.get(id) if (pending) { diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 53522f753b4c..cd68eb5df862 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -1,5 +1,4 @@ import type { SpawnIntent } from '../../shared/host-spawn-admission' -import type { TerminalSwitchOwners } from '../../shared/terminal-switch' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentWorkspaceTrustSpawnRequest } from '../../shared/agent-workspace-trust-spawn-request' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' @@ -119,10 +118,6 @@ export type PtySpawnOptions = { onPtySpawnCommitted?: () => void /** Cancels only before physical dispatch; operation identity fences later ambiguity. */ signal?: AbortSignal - /** A chat → terminal switch's start: the owner binds this generation to this one fresh spawn and - * refuses it when `switchOwner` names another owner or the generation was already used. */ - switchGeneration?: string - switchOwner?: string } export type { PtyProcessInfo, PtySpawnResult } @@ -142,8 +137,6 @@ export type IPtyProvider = { /** Re-probes a degraded durable host before main commits to fallback spawn semantics. */ recoverFreshSpawnRouting?: () => Promise spawn(opts: PtySpawnOptions): Promise - /** Present only where an owner can hold and observe a chat → terminal switch start. */ - terminalSwitches?: TerminalSwitchOwners /** Process-owner cleanup for history stored outside the workspace tree. */ deleteWorktreeHistory?: (worktreeId: string) => Promise /** Whether this spawn target can append the Git guard after its final env merge. */ diff --git a/src/main/providers/terminal-switch-owners.ts b/src/main/providers/terminal-switch-owners.ts deleted file mode 100644 index ca600b02e2ea..000000000000 --- a/src/main/providers/terminal-switch-owners.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { TerminalSwitchOwners } from '../../shared/terminal-switch' - -type Reach = { terminalSwitches?: TerminalSwitchOwners } - -/** A routing provider's owners: fresh starts go to one; any of them may hold an older start. */ -export function routedTerminalSwitchOwners( - fresh: () => Reach, - all: () => readonly Reach[] -): TerminalSwitchOwners { - return { - owner: async () => (await fresh().terminalSwitches?.owner()) ?? null, - // One owner that cannot answer fails the whole read: it may be the one that holds the start. - observe: async (generation, fence) => - ( - await Promise.all( - all().map((owner) => owner.terminalSwitches?.observe(generation, fence) ?? []) - ) - ).flat() - } -} diff --git a/src/main/runtime/agent-session-record-terminal-switch.ts b/src/main/runtime/agent-session-record-terminal-switch.ts deleted file mode 100644 index 77c53f801bd5..000000000000 --- a/src/main/runtime/agent-session-record-terminal-switch.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { TerminalSwitchWrite } from '../../shared/terminal-switch' - -/** Unfenced like the name: the descriptor is the switch owner's obligation, not the writer lease. - * Compare-and-set on generation, so a stale collector never resurrects or replaces another. */ -export function setAgentSessionRecordTerminalSwitch( - record: AgentSessionRecord, - write: TerminalSwitchWrite, - now: number -): AgentSessionRecord { - const current = record.terminalSwitch?.generation - if ('clear' in write) { - if (current !== write.clear) { - return record - } - const next = { ...record, updatedAt: now } - delete next.terminalSwitch - return next - } - const descriptor = 'accept' in write ? write.accept : write.update - const applies = current === descriptor.generation || ('accept' in write && current === undefined) - return applies ? { ...record, terminalSwitch: descriptor, updatedAt: now } : record -} diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index e3bd90aef740..35c2388b5d5f 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -284,6 +284,29 @@ describe('terminal close and handle incarnation continuity', () => { expect(canary.ptyId).toBe(survivor.ptyId) }) + it('forwards the captured incarnation to the stop and never falls back after a refused stop', async () => { + const harness = createHarness({ registerPtyBacked: true }) + const terminal = (await harness.runtime.listTerminals()).terminals.find( + (row) => row.ptyId === PTY_ID + )! + harness.setVerifiedStopResult(false) + await expect(harness.runtime.closeTerminal(terminal.handle, INCARNATION_ID)).rejects.toThrow( + 'terminal_close_unverifiable' + ) + expect(harness.stopAndWait).toHaveBeenCalledWith( + PTY_ID, + expect.objectContaining({ expectedIncarnationId: INCARNATION_ID }) + ) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + expect(harness.closeTerminalPane).not.toHaveBeenCalled() + harness.setVerifiedStopResult(true) + harness.acknowledged.resolve() + await expect( + harness.runtime.closeTerminal(terminal.handle, INCARNATION_ID) + ).resolves.toMatchObject({ ptyKilled: true }) + }) + it('closes nothing when its handle now runs a later incarnation than the caller expected', async () => { const harness = createHarness() const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals diff --git a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts index 5c6680d970c2..d734436c472f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-switch-to-terminal.ts @@ -1,17 +1,10 @@ -// `agentSession.switchToTerminal` — the chat's half of moving its conversation into a terminal on -// the same id. Before any stop it checks the route can hold the start and prepares the recipe; then, -// in the chat's ordered step, it refuses unless idle, registers one switch generation and stops the -// agent with proven exit. Only that generation's terminal owner can later end the switch. -// Gated per agent by `agent-session.structured.switch.v1:`; a client never falls back to close. - -import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +// Prepare the ordinary same-id resume recipe before the ordered idle-only, proven-exit stop. import { inPlaceSwitchEnabled } from '../../../../shared/structured-agent-cli-conversation-adoption' import type { AiVaultChatForkSource } from '../../../../shared/ai-vault-resume-preparation' import { prepareStructuredChatCliTranscript, resolveStructuredChatCliSource } from '../../../ai-vault/structured-chat-cli-fork' -import type { SwitchClaim } from '../../session-tabs/structured-session-switch-claims' import { defineMethod } from '../core' import { ensureStructuredHostInstalled, @@ -25,10 +18,7 @@ export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ name: 'agentSession.switchToTerminal', permission: 'workspace', params: OptionsParams, - handler: async ( - params, - ctx - ): Promise<{ source: AiVaultChatForkSource; generation: string }> => { + handler: async (params, ctx): Promise<{ source: AiVaultChatForkSource }> => { requireStructuredCapability(ctx) await ensureStructuredHostInstalled(ctx) const host = requireStructuredSessionHost(ctx, params.sessionId) @@ -41,12 +31,6 @@ export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ { sessionId: record.sessionId, workspaceId: record.location.workspaceId }, false ) - const owner = await ctx.runtime.terminalSwitchOwner() - if (!owner) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) - } const prepared = await prepareStructuredChatCliTranscript( source, (args) => ctx.runtime.prepareAiVaultSessionResume(args), @@ -55,29 +39,8 @@ export const STRUCTURED_AGENT_SESSION_SWITCH_TO_TERMINAL_METHODS = [ if (!prepared.cliForkSource) { throw new Error('This chat has no saved conversation available on this host.') } - // A switch whose owner proved its start over no longer holds the conversation. - await ctx.runtime.releaseEndedSwitchStarts(source.agent, source.providerSessionId) - let claim: SwitchClaim | undefined - try { - await host.switchToTerminal(params.sessionId, () => { - claim = ctx.runtime.registerSwitchClaim( - source.agent, - source.providerSessionId, - record.sessionId, - owner - ) - }) - if (!claim) { - throw new Error('This chat could not switch to a terminal.') - } - return { source: prepared.cliForkSource, generation: claim.generation } - } catch (error) { - // No recipe left this host, so nothing can start under the claim. - if (claim) { - ctx.runtime.dropSwitchClaim(claim) - } - throw error - } + await host.switchToTerminal(params.sessionId) + return { source: prepared.cliForkSource } } }) ] diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index ece53a621ef6..f31994b57b29 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -35,21 +35,18 @@ export function projectRuntimeMobileSessionTabs( // and embedders that have not adopted the narrow getter. let hookRowsByPaneKey: Map | null = null const hookRowsForPane = new Map() - // A switch's launch identity names one exact execution; a replacement there never wears it. - const current = ({ launchExecution: stamp }: AgentStatusIpcPayload) => - !stamp || host.ptysById.get(stamp.ptyId)?.incarnationId === stamp.incarnationId const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { const cached = hookRowsForPane.get(paneKey) if (cached) { return cached } - const rows = ( - host.getProviderSessionRows(paneKey) ?? - (hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot())).get( - paneKey - ) ?? - [] - ).filter(current) + const direct = host.getProviderSessionRows(paneKey) + if (direct) { + hookRowsForPane.set(paneKey, direct) + return direct + } + hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey(host.getProviderSessionSnapshot()) + const rows = hookRowsByPaneKey.get(paneKey) ?? [] hookRowsForPane.set(paneKey, rows) return rows } diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 0f047c9b68dd..dca2af369c51 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -1,5 +1,4 @@ import type { AgentLaunchRuntime } from '../../shared/agent-launch-runtime' -import type { TerminalSwitchOwners } from '../../shared/terminal-switch' import type { AutomaticWorkResumeOutcome, AutomaticWorkStatus, @@ -134,7 +133,7 @@ export type RuntimePtyController = { retireRejectedPty?(ptyId: string, stopConfirmed: boolean, incarnationId: string | undefined): void stopAndWait?( ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: { keepHistory?: boolean; deadlineMs?: number; expectedIncarnationId?: string } ): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise @@ -150,8 +149,6 @@ export type RuntimePtyController = { resize?(ptyId: string, cols: number, rows: number): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null - /** This machine's terminal owners, for a chat → terminal switch start. */ - terminalSwitches?: TerminalSwitchOwners /** Omitting the host lists every registered host. */ listProcesses?( hostId?: ExecutionHostId, diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-outcome.ts b/src/main/runtime/session-tabs/explicit-terminal-close-outcome.ts new file mode 100644 index 000000000000..38447ccf2ca8 --- /dev/null +++ b/src/main/runtime/session-tabs/explicit-terminal-close-outcome.ts @@ -0,0 +1,86 @@ +import { OrcaRuntimeWithFocusTerminal } from '../terminal/focus/focus-terminal' +import { EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS } from '../orca-runtime-core' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../shared/pty-liveness-verdict' +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' +import { errorMessage } from '../../../shared/error-message' + +/** How an explicit close's stop of its addressed PTY ended. */ +type ExplicitCloseStop = { stopped: boolean } + +const NO_STOP: ExplicitCloseStop = { stopped: false } + +export abstract class OrcaRuntimeWithExplicitTerminalCloseOutcome extends OrcaRuntimeWithFocusTerminal { + protected async stopExplicitlyClosedTabPtys( + ptyIds: readonly string[], + addressedPtyId: string, + capturedIncarnationId?: string + ): Promise { + let addressedPtyStop = NO_STOP + const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS + for (const ptyId of ptyIds) { + this.markPtyStopRequested(ptyId) + const expectedIncarnationId = capturedIncarnationId ?? this.ptysById.get(ptyId)?.incarnationId + let stopped = false + if (this.ptyController?.stopAndWait) { + try { + stopped = await this.ptyController.stopAndWait(ptyId, { + deadlineMs, + ...(capturedIncarnationId ? { expectedIncarnationId: capturedIncarnationId } : {}) + }) + } catch (error) { + this.markPtyLivenessUnverifiable(ptyId, errorMessage(error)) + } + // Preserve an observed exit when a broader inventory check could not finish. + if ( + !stopped && + expectedIncarnationId && + this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + ) { + stopped = true + } + if (!stopped && !capturedIncarnationId) { + const verdict = this.getPtyLivenessVerdict(ptyId) + const providerAlreadyRetiredPty = + verdict?.status === 'unverifiable' && + verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON + if (!providerAlreadyRetiredPty) { + this.ptyController.kill(ptyId) + if (!verdict || verdict.status === 'live') { + this.markPtyLivenessUnverifiable( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + } + } + } + } else if (!capturedIncarnationId) { + stopped = this.ptyController?.kill(ptyId) ?? false + } + if (ptyId === addressedPtyId) { + addressedPtyStop = { stopped } + } + } + return addressedPtyStop + } + + protected describeTerminalClose( + handle: string, + tabId: string, + ptyId: string | null, + stop: ExplicitCloseStop + ): RuntimeTerminalClose { + const close: RuntimeTerminalClose = { handle, tabId, ptyKilled: stop.stopped } + if (stop.stopped || !ptyId) { + return close + } + const verdict = this.getPtyLivenessVerdict(ptyId) + if (verdict?.status === 'unverifiable') { + return { ...close, ptyStopVerdict: 'unverifiable', ptyStopReason: verdict.reason } + } + if (verdict?.status === 'live') { + return { ...close, ptyStopVerdict: 'live' } + } + return close + } +} diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts index abffb82f48e8..0ad6ce3860ee 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts @@ -1,23 +1,17 @@ import type { WorktreePtyHostFence } from '../worktree-pty-host-fence' -import { OrcaRuntimeWithFocusTerminal } from '../terminal/focus/focus-terminal' -import { EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS } from '../orca-runtime-core' -import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../shared/pty-liveness-verdict' +import { OrcaRuntimeWithExplicitTerminalCloseOutcome } from './explicit-terminal-close-outcome' import type { RuntimeTerminalClose } from '../../../shared/runtime-types' import type { RuntimePtyTabCloseAuthority } from '../runtime-terminal-state-records' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { errorMessage } from '../../../shared/error-message' import type { ResolvedWorktree } from '../runtime-worktree-path-identity' import { closePairedHostLeafFirst, isTerminalTabPinnedInSession } from './paired-host-terminal-close' -/** How an explicit close's stop of its addressed PTY ended. */ -type ExplicitCloseStop = { stopped: boolean } +const NO_STOP = { stopped: false } -const NO_STOP: ExplicitCloseStop = { stopped: false } - -export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithFocusTerminal { +export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithExplicitTerminalCloseOutcome { protected abstract getResolvedWorktreeMap(): Promise> protected abstract refreshPtyWorktreeRecordsFromController( resolvedWorktrees: ResolvedWorktree[], @@ -43,76 +37,6 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun terminalHandlesByPtyId: Readonly> }): void - protected async stopExplicitlyClosedTabPtys( - ptyIds: readonly string[], - addressedPtyId: string - ): Promise { - let addressedPtyStop = NO_STOP - const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS - for (const ptyId of ptyIds) { - this.markPtyStopRequested(ptyId) - const expectedIncarnationId = this.ptysById.get(ptyId)?.incarnationId - let stopped = false - if (this.ptyController?.stopAndWait) { - try { - stopped = await this.ptyController.stopAndWait(ptyId, { deadlineMs }) - } catch (error) { - this.markPtyLivenessUnverifiable(ptyId, errorMessage(error)) - } - // Preserve an observed exit when a broader inventory check could not finish. - if ( - !stopped && - expectedIncarnationId && - this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId && - this.getPtyLivenessVerdict(ptyId)?.status === 'exited' - ) { - stopped = true - } - if (!stopped) { - const verdict = this.getPtyLivenessVerdict(ptyId) - const providerAlreadyRetiredPty = - verdict?.status === 'unverifiable' && - verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON - if (!providerAlreadyRetiredPty) { - this.ptyController.kill(ptyId) - if (!verdict || verdict.status === 'live') { - this.markPtyLivenessUnverifiable( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) - } - } - } - } else { - stopped = this.ptyController?.kill(ptyId) ?? false - } - if (ptyId === addressedPtyId) { - addressedPtyStop = { stopped } - } - } - return addressedPtyStop - } - - protected describeTerminalClose( - handle: string, - tabId: string, - ptyId: string | null, - stop: ExplicitCloseStop - ): RuntimeTerminalClose { - const close: RuntimeTerminalClose = { handle, tabId, ptyKilled: stop.stopped } - if (stop.stopped || !ptyId) { - return close - } - const verdict = this.getPtyLivenessVerdict(ptyId) - if (verdict?.status === 'unverifiable') { - return { ...close, ptyStopVerdict: 'unverifiable', ptyStopReason: verdict.reason } - } - if (verdict?.status === 'live') { - return { ...close, ptyStopVerdict: 'live' } - } - return close - } - /** null when this machine owns the PTY; see `closePairedHostLeafFirst` otherwise. */ private closeOnOwningPairedHost( handle: string, @@ -134,10 +58,37 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun async closeTerminal(handle: string, incarnation?: string): Promise { const pty = this.getLivePtyForHandle(handle) + const addressedPty = + pty?.pty ?? + (incarnation + ? this.ptysById.get(this.getLiveLeafForHandle(handle).leaf.ptyId ?? '') + : undefined) // The handle's fence: a replacement execution under the same handle is never closed. - if (incarnation && pty?.pty.incarnationId !== incarnation) { + if (incarnation && addressedPty?.incarnationId !== incarnation) { throw new Error('terminal_handle_stale') } + // A fenced switch stops its captured execution before any presentation await. + const capturedStop = + incarnation && addressedPty + ? await this.stopExplicitlyClosedTabPtys( + [addressedPty.ptyId], + addressedPty.ptyId, + incarnation + ) + : null + if ( + incarnation && + (!capturedStop?.stopped || + (addressedPty && + this.ptysById.has(addressedPty.ptyId) && + this.ptysById.get(addressedPty.ptyId)?.incarnationId !== incarnation)) + ) { + throw new Error('terminal_close_unverifiable') + } + const stopCapturedOrCurrent = (ids: readonly string[], addressed: string) => + capturedStop + ? Promise.resolve(capturedStop) + : this.stopExplicitlyClosedTabPtys(ids, addressed) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { const closeAuthority: RuntimePtyTabCloseAuthority = { @@ -175,7 +126,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } this.notifyRendererOfHeadlessTerminalClose(tabId) } - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + const stop = await stopCapturedOrCurrent(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } if (closesTab && surface && ptyCloseAuthority && !this.tabs.has(surface.tab.parentTabId)) { @@ -189,11 +140,11 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCapturedOrCurrent([pty.pty.ptyId], pty.pty.ptyId) this.notifyRendererOfHeadlessTerminalClose(tabId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCapturedOrCurrent([pty.pty.ptyId], pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } // Why the tabs guard: a headless host has no renderer tab to close through the notifier. @@ -216,11 +167,11 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun tabClosed = false } if (tabClosed) { - const stop = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId) + const stop = await stopCapturedOrCurrent(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } } - const stop = await this.stopExplicitlyClosedTabPtys([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCapturedOrCurrent([pty.pty.ptyId], pty.pty.ptyId) if (!closesTab) { // Why: the pane's removal is this close's own commit, not a side effect of its exit. An // unconfirmed stop is unverifiable, never a reason to close the live siblings with it. @@ -273,9 +224,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } return { handle, tabId: leaf.tabId, ...hostClose.close } } - const stop = leaf.ptyId - ? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId) - : NO_STOP + const stop = leaf.ptyId ? await stopCapturedOrCurrent(ptyIdsToKill, leaf.ptyId) : NO_STOP if (!closesTab) { await this.closeTerminalPane(leaf.worktreeId, paneTarget) } else if (!this.notifier?.closeTerminalTab) { diff --git a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts index 150f080bcdbf..5fe9dd7b78df 100644 --- a/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts +++ b/src/main/runtime/session-tabs/structured-session-cli-writer-host.ts @@ -5,12 +5,7 @@ import { } from '../../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle' -import { switchStartProcessVerdict } from './structured-session-switch-observation' -import { - OrcaRuntimeWithStructuredSessionSwitchClaims, - switchHeldRefusal, - switchMovingRefusal -} from './structured-session-switch-claims' +import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' import { getProviderForPty } from '../../ipc/pty/provider/registry' import { LocalPtyProvider } from '../../providers/local-pty-provider' import { inspectPtyProviderProcess } from '../../providers/pty-process-inspection' @@ -30,7 +25,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../shared/structured-agent-cli-conversation-adoption' import { recordConversationKey } from '../../native-chat/structured-conversation-key' -export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithStructuredSessionSwitchClaims { +export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends OrcaRuntimeWithGetWorktreePs { rememberSavedTerminalLaunchResult(ptyId: string, result: TerminalLaunchResult): void { const pty = this.ptysById.get(ptyId) if (pty && result.savedResumeDecision) { @@ -99,7 +94,6 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca } if (event.agentPresence?.process) { delete pty.savedTerminalLaunchResult - this.noteSwitchStartProcess(pty.ptyId, { agent, id: binding.id }, event.agentPresence.process) } if (pty.cliConversations?.incarnationId !== pty.incarnationId) { pty.cliConversations = { incarnationId: pty.incarnationId, byKey: new Map() } @@ -136,9 +130,6 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca providerSessionId: string, source?: { workspaceId: string; paneKey: string } ): Promise { - await this.loadSwitchClaims() - this.assertNoPendingSwitch(agent, providerSessionId) - await this.assertSwitchStartsEnded(agent, providerSessionId) await assertCliConversationWritersExited( { rows: () => this.getAgentProviderSessionRows(), @@ -172,81 +163,38 @@ export abstract class OrcaRuntimeWithStructuredSessionCliWriterHost extends Orca this.terminalSleepStateByHostWorktree.has( hostSleepKey(LOCAL_EXECUTION_HOST_ID, workspaceId) ), - shellAlone: (pane) => this.cliWriterShellAlone(pane) + shellAlone: async (pane) => { + if (process.platform === 'win32') { + const provider = getProviderForPty(pane.ptyId) + const inspection = await inspectPtyProviderProcess(provider, pane.ptyId, { + expectedIncarnationId: pane.incarnationId, + scanChildProcesses: true, + ignoreDetached: true + }) + const evidence = inspection.foregroundProcessEvidence + return ( + inspectionShowsShellAlone(inspection) && + (provider instanceof LocalPtyProvider || + (evidence?.ptyId === pane.ptyId && + evidence.ptyIncarnationId === pane.incarnationId && + evidence.verdict === 'unverifiable' && + evidence.reason === 'windows_ssh_foreground_unavailable')) + ) + } + const processes = await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) + const entry = processes?.find((candidate) => candidate.id === pane.ptyId) + const pty = this.ptysById.get(pane.ptyId) + return ( + !!entry?.rootProcessId && + !!entry.incarnationId && + entry.incarnationId === pty?.incarnationId && + (await posixCliWriterShellAlone(entry.rootProcessId)) + ) + } }, agent, providerSessionId, source && { paneKey: source.paneKey, worktreeId: source.workspaceId } ) - // A switch spawn admitted during the census either registered first or read this chat's reservation. - this.assertNoPendingSwitch(agent, providerSessionId, true) - } - - async releaseEndedSwitchStarts(agent: string, id: string): Promise { - await this.loadSwitchClaims() - await this.assertSwitchStartsEnded(agent, id).catch(() => undefined) - } - - /** A switch's start holds its conversation until every agent recorded there is proved exited and - * either its owner proves the execution ended, or its startup settled with that exact shell alone. */ - private async assertSwitchStartsEnded(agent: string, id: string): Promise { - for (const claim of this.switchClaimsFor(agent, id)) { - if (!claim.accepted) { - throw switchMovingRefusal() - } - const seen = await this.observeSwitch(claim, true) - if (seen.state === 'bound') { - await this.noteSwitchBound(claim, seen) - } - const pane = seen.state === 'bound' ? this.cliWriterPtyPane(seen.ptyId) : null - // A root exit is not the agents' exit: a detached one outlives its shell. - const agentsExited = ( - await Promise.all(claim.processes.map(switchStartProcessVerdict)) - ).every((verdict) => verdict === 'exited') - const over = - agentsExited && - (seen.state === 'ended' || - (seen.state === 'bound' && - seen.startupSettled && - claim.processes.length > 0 && - pane?.incarnationId === seen.incarnationId && - (await this.cliWriterShellAlone(pane)))) - if (over) { - await this.releaseSwitchClaim(claim) - continue - } - throw seen.state === 'pending' || (seen.state === 'bound' && !seen.startupSettled) - ? switchMovingRefusal() - : switchHeldRefusal() - } - } - - private async cliWriterShellAlone(pane: CliWriterPane): Promise { - if (process.platform === 'win32') { - const provider = getProviderForPty(pane.ptyId) - const inspection = await inspectPtyProviderProcess(provider, pane.ptyId, { - expectedIncarnationId: pane.incarnationId, - scanChildProcesses: true, - ignoreDetached: true - }) - const evidence = inspection.foregroundProcessEvidence - return ( - inspectionShowsShellAlone(inspection) && - (provider instanceof LocalPtyProvider || - (evidence?.ptyId === pane.ptyId && - evidence.ptyIncarnationId === pane.incarnationId && - evidence.verdict === 'unverifiable' && - evidence.reason === 'windows_ssh_foreground_unavailable')) - ) - } - const processes = await this.ptyController?.listProcesses?.(LOCAL_EXECUTION_HOST_ID) - const entry = processes?.find((candidate) => candidate.id === pane.ptyId) - const pty = this.ptysById.get(pane.ptyId) - return ( - !!entry?.rootProcessId && - !!entry.incarnationId && - entry.incarnationId === pty?.incarnationId && - (await posixCliWriterShellAlone(entry.rootProcessId)) - ) } } diff --git a/src/main/runtime/session-tabs/structured-session-switch-boot.ts b/src/main/runtime/session-tabs/structured-session-switch-boot.ts deleted file mode 100644 index 0c811adc2dc4..000000000000 --- a/src/main/runtime/session-tabs/structured-session-switch-boot.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { uptime } from 'node:os' -import { runProcess } from '@orca/process-host' -import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' -import { readBootIdentity } from '../../agent-hooks/managed-hook-owner-identity' - -const WINDOWS_BOOT_KEY = - 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\\PrefetchParameters' - -/** The OS's own boot counter or id; undefined when unreadable. Windows: one read-only reg.exe. */ -async function readOsBootId(): Promise { - if (process.platform !== 'win32') { - return readBootIdentity() - } - const result = await runProcess({ - program: windowsSystem32Binary('reg.exe'), - args: ['query', WINDOWS_BOOT_KEY, '/v', 'BootId'], - timeoutMs: 5_000 - }).catch(() => null) - const id = result?.code === 0 ? /BootId\s+REG_DWORD\s+(0x[0-9a-f]+)/i.exec(result.stdout) : null - return id ? `win:${id[1].toLowerCase()}` : undefined -} - -/** Read at acceptance and on owner-loss recovery only: the boot id, and uptime at reading. */ -export async function readSwitchHostBoot(): Promise { - return `${(await readOsBootId()) ?? ''}@${uptime()}` -} - -const parse = (boot: string): [string, number] => { - const at = boot.lastIndexOf('@') - return at === -1 ? ['', Number.NaN] : [boot.slice(0, at), Number(boot.slice(at + 1))] -} - -/** A later boot, from clock-free facts only: a different boot id, or uptime lower than it was - * (it only grows within a boot). macOS uptime is derived from the wall clock, so there only the - * id counts. Anything unreadable proves nothing. */ -export function isLaterSwitchHostBoot(saved: string, current: string): boolean { - const [savedId, savedUptime] = parse(saved) - const [id, currentUptime] = parse(current) - if (savedId && id) { - return savedId !== id - } - return process.platform !== 'darwin' && currentUptime < savedUptime -} diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts deleted file mode 100644 index 4c737b181c07..000000000000 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import '../rpc/unused-default-rpc-methods.test-fixture' -import { OrcaRuntimeService } from '../orca-runtime' -import { admitSwitchResumeSpawn } from '../../ipc/pty/host-env/resume-spawn-judgement' -import type { AdmissionRuntime } from '../../ipc/pty/host-env/resume-spawn-judgement' -import { TerminalHostSwitches } from '../../daemon/terminal-host-switches' -import type { Session } from '../../daemon/session' -import { switchStartProcessVerdict } from './structured-session-switch-observation' -import { codexChatHolding } from '../../ai-vault/structured-session-ownership.test-support' -import { - TERMINAL_SWITCH_GENERATION_ENV, - type TerminalSwitchDescriptor -} from '../../../shared/terminal-switch' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { routedTerminalSwitchOwners } from '../../providers/terminal-switch-owners' -import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' -import { isLaterSwitchHostBoot, readSwitchHostBoot } from './structured-session-switch-boot' -import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' -import type { SwitchClaim } from './structured-session-switch-claims' - -vi.mock('electron', () => ({ - BrowserWindow: { fromId: vi.fn(() => null) }, - webContents: { fromId: vi.fn(() => null) }, - ipcMain: { on: vi.fn(), removeListener: vi.fn() }, - app: { getPath: vi.fn(() => '/tmp') } -})) -vi.mock('../../agent-hooks/managed-hook-owner-identity', () => ({ - readBootIdentity: async () => boot -})) -vi.mock('../../ipc/pty/pane/resume-launch-identity', () => ({ seedResumeLaunchIdentity: vi.fn() })) -vi.mock('@orca/process-host', async (importOriginal) => ({ - ...(await importOriginal>()), - runProcess: vi.fn(async () => ({ - code: 0, - stdout: ` BootId REG_DWORD ${boot ?? ''}\r\n` - })) -})) -vi.mock('./structured-session-switch-observation', () => ({ - readSwitchPaneAgentProcesses: vi.fn(async () => []), - readSwitchShellProcess: vi.fn(async (pid: number) => ({ - pid, - platform: 'linux', - startTime: 's' - })), - switchStartProcessVerdict: vi.fn(async (): Promise => 'live') -})) - -const C = 'thread-c' -const LEAF = '11111111-1111-4111-8111-111111111111' -const PANE = `tab-1:${LEAF}` -const SHELL = { pid: 4242, platform: 'linux', startTime: 's' } as const -const AGENT = { pid: 999_991, platform: 'linux', startTime: 'started' } as const -let runtime: OrcaRuntimeService -let host: TerminalHostSwitches -let shellAlone: boolean -let ownerReachable: boolean -let boot: string | undefined = '0x1' -/** The chat records' saved switch descriptors, keyed by chat session id. */ -let saved: Map - -const chatStart = () => runtime.assertStructuredAdoptionTerminalWritersExited('codex', C) -const reason = (promise: Promise) => - promise.then( - () => 'admitted', - (error: { refusal?: { details?: { reason?: string } } }) => error.refusal?.details?.reason - ) -const session = (facts: Partial) => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner reads only these session facts. - ({ shellReady: { isGatingWrites: false }, ...facts }) as Session -const live = () => session({ isAlive: true, sessionId: 'pty-1', incarnationId: 'i1', pid: 4242 }) - -beforeEach(() => { - shellAlone = true - ownerReachable = true - boot = '0x1' - saved = new Map() - host = new TerminalHostSwitches() - runtime = new OrcaRuntimeService() - runtime.registerPty('pty-1', 'wt-1', null, { tabId: 'tab-1', leafId: LEAF, incarnationId: 'i1' }) - const reach = { - terminalSwitches: { - owner: async () => host.owner, - observe: async (generation: string, fence: boolean) => { - if (!ownerReachable) { - throw new Error('terminal_switch_owner_unreachable') - } - return [host.observe(generation, fence)] - } - } - } - // The production routing: an unreachable owner must stay unreachable through it. - const terminalSwitches = routedTerminalSwitchOwners( - () => reach, - () => [reach, {}] - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads only its owner contract. - runtime.setPtyController({ terminalSwitches } as never) - vi.spyOn(runtime, 'openAgentSessionRecordStore').mockResolvedValue( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the switch reads records and writes descriptors only. - { - listRecords: () => - [...saved].map(([sessionId, terminalSwitch]) => ({ sessionId, terminalSwitch })), - transitionHandoff: async ( - sessionId: string, - transition: (record: AgentSessionRecord) => AgentSessionRecord - ) => { - const current = saved.get(sessionId) - const record = { - ...codexChatHolding(sessionId, [C]), - ...(current ? { terminalSwitch: current } : {}) - } - // The journal store freezes what it commits. - const next = Object.freeze(transition(record)) - Object.freeze(next.terminalSwitch?.processes) - if (next.terminalSwitch) { - saved.set(sessionId, next.terminalSwitch) - } else { - saved.delete(sessionId) - } - return next - } - } as never - ) - vi.spyOn( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census's one process-table read, replaced by the pane's modeled state. - runtime as never as { cliWriterShellAlone: () => Promise }, - 'cliWriterShellAlone' - ).mockImplementation(async () => shellAlone) - vi.mocked(switchStartProcessVerdict).mockResolvedValue('live') -}) - -async function acceptedSwitch(): Promise { - const claim = runtime.registerSwitchClaim('codex', C, 'chat-1', host.owner) - runtime.acceptSwitchStart(claim.generation, PANE) - await runtime.saveSwitchClaim(claim) - return claim -} - -describe('a chat → terminal switch start', () => { - it('is held from the recipe until its owner proves the exact execution over', async () => { - const claim = runtime.registerSwitchClaim('codex', C, 'chat-1', host.owner) - expect(await reason(chatStart())).toBe('conversationMovingToTerminal') - expect(() => runtime.registerSwitchClaim('codex', C, 'chat-1', host.owner)).toThrow( - 'agent_session_conflict' - ) - expect(runtime.acceptSwitchStart(claim.generation, PANE)).toBe(claim) - // One pane takes a generation; a retry there joins it. - expect(runtime.acceptSwitchStart(claim.generation, 'tab-2:other')).toBeNull() - expect(runtime.acceptSwitchStart(claim.generation, PANE)).toBe(claim) - await runtime.saveSwitchClaim(claim) - host.begin(claim.generation, host.owner) - expect(await reason(chatStart())).toBe('conversationMovingToTerminal') - expect(() => host.begin(claim.generation, host.owner)).toThrow( - 'terminal_switch_generation_refused' - ) - - const shell = live() - host.bind(claim.generation, shell) - // Bound with no agent recorded: only the owner's end releases it. - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - expect(saved.get('chat-1')).toMatchObject({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL }) - runtime['noteSwitchStartProcess']('pty-1', { agent: 'codex', id: C }, AGENT) - // The shell exiting is not its detached agent exiting. - Object.assign(shell, { isAlive: false }) - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') - expect(await reason(chatStart())).toBe('admitted') - expect(saved.has('chat-1')).toBe(false) - }) - - it('releases on a proved agent exit with that exact shell alone, keeping the shell', async () => { - const claim = await acceptedSwitch() - host.begin(claim.generation, host.owner) - host.bind(claim.generation, live()) - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - runtime['noteSwitchStartProcess']('pty-1', { agent: 'codex', id: C }, AGENT) - vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') - shellAlone = false - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - shellAlone = true - expect(await reason(chatStart())).toBe('admitted') - }) - - it('never lets a retired switch’s late evidence replace the next one', async () => { - const a = await acceptedSwitch() - expect(await reason(chatStart())).toBe('admitted') - const b = await acceptedSwitch() - a.processes.push(AGENT) - expect(await runtime.saveSwitchClaim(a)).toBe(false) - expect(saved.get('chat-1')?.generation).toBe(b.generation) - }) - - it('fences a saved start no terminal took, so it can never start later', async () => { - const claim = await acceptedSwitch() - expect(await reason(chatStart())).toBe('admitted') - expect(() => host.begin(claim.generation, host.owner)).toThrow( - 'terminal_switch_generation_refused' - ) - }) - - it('survives a restart: an unreachable owner holds; a lost one yields only to proof', async () => { - const sameBoot = await readSwitchHostBoot() - const lost = (extra: Partial) => - saved.set('chat-1', { - generation: 'g-old', - agent: 'codex', - providerSessionId: C, - owner: 'daemon:gone', - paneKey: PANE, - processes: [], - boot: sameBoot, - ...extra - }) - lost({ ptyId: 'pty-1', incarnationId: 'i1', shell: SHELL, processes: [AGENT] }) - ownerReachable = false - vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - ownerReachable = true - vi.mocked(switchStartProcessVerdict).mockResolvedValue('live') - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - vi.mocked(switchStartProcessVerdict).mockResolvedValue('exited') - expect(await reason(chatStart())).toBe('admitted') - - // A crash before any identity was learned stays held until an OS boot id changes; no id - // (Windows) never proves a reboot, however far the clock moves. - lost({}) - runtime['switchClaimsLoaded'] = null - boot = undefined - expect(await reason(chatStart())).toBe('conversationHeldElsewhere') - boot = '0x2' - expect(await reason(chatStart())).toBe('admitted') - expect(saved.has('chat-1')).toBe(false) - }) - - it('proves a later boot only from a boot id or an uptime drop, never the wall clock', async () => { - const onPlatform = (platform: string) => - Object.defineProperty(process, 'platform', { value: platform }) - const real = process.platform - expect(isLaterSwitchHostBoot('a@100', 'a@1')).toBe(false) - expect(isLaterSwitchHostBoot('a@1', 'b@100')).toBe(true) - onPlatform('win32') - boot = '0x1A' - expect(await readSwitchHostBoot()).toMatch(/^win:0x1a@/) - expect(isLaterSwitchHostBoot('@100', '@5')).toBe(true) - expect(isLaterSwitchHostBoot('@100', '@500')).toBe(false) - // macOS derives uptime from the wall clock, so only its boot id counts there. - onPlatform('darwin') - expect(isLaterSwitchHostBoot('@100', '@5')).toBe(false) - onPlatform(real) - }) - - it('rebuilds a reattached switch terminal’s stamp from its owner, never onto a replacement', async () => { - const claim = await acceptedSwitch() - host.begin(claim.generation, host.owner) - host.bind(claim.generation, live()) - vi.mocked(seedResumeLaunchIdentity).mockClear() - const reattach = (incarnationId: string) => - runtime.registerPty('pty-1', 'wt-1', null, { - tabId: 'tab-1', - leafId: LEAF, - incarnationId, - providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' } - }) - // Replaced before the owner's answer lands: nothing is stamped onto i2. - reattach('i1') - runtime.registerPty('pty-1', 'wt-1', null, { - tabId: 'tab-1', - leafId: LEAF, - incarnationId: 'i2' - }) - await vi.waitFor(() => expect(saved.get('chat-1')?.ptyId).toBe('pty-1')) - expect(seedResumeLaunchIdentity).not.toHaveBeenCalled() - reattach('i1') - await vi.waitFor(() => expect(seedResumeLaunchIdentity).toHaveBeenCalledOnce()) - expect(seedResumeLaunchIdentity).toHaveBeenCalledWith( - expect.objectContaining({ - paneKey: PANE, - resumeProviderSession: { key: 'session_id', id: C }, - execution: { ptyId: 'pty-1', incarnationId: 'i1' } - }) - ) - }) -}) - -describe('a switch spawn', () => { - const args = (generation = 'g1') => ({ - launchAgent: 'codex' as const, - resumeProviderSession: { key: 'session_id' as const, id: C }, - resumeOrigin: 'switch', - env: { [TERMINAL_SWITCH_GENERATION_ENV]: generation, KEEP: '1' } - }) - const claim: SwitchClaim = { - generation: 'g1', - agent: 'codex', - providerSessionId: C, - owner: 'daemon:o', - paneKey: PANE, - processes: [], - chatSessionId: 'chat-1', - at: 0, - accepted: true - } - const admission = (writing: boolean, savedOk = true) => - ({ - acceptSwitchStart: vi.fn(() => claim), - saveSwitchClaim: vi.fn(async () => savedOk), - dispatchSwitchStart: vi.fn(() => ({ switchGeneration: 'g1', switchOwner: 'daemon:o' })), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a switch spawn reads only the store's records and tab index. - openAgentSessionRecordStore: vi.fn( - async () => - ({ - listRecords: () => (writing ? [codexChatHolding('chat-1', [C])] : []), - getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) - }) as never - ) - }) satisfies AdmissionRuntime - - it('hands the owner its generation only once the start is saved and the chat is quiet', async () => { - const quiet = admission(false) - const spawnArgs = args() - const options: { env?: Record; switchGeneration?: string } = { - env: { ...spawnArgs.env } - } - expect( - await admitSwitchResumeSpawn(quiet, spawnArgs, null, { paneKey: PANE, options }) - ).toMatchObject({ verdict: 'admitted' }) - expect(quiet.acceptSwitchStart).toHaveBeenCalledWith('g1', PANE) - expect(options).toEqual({ env: { KEEP: '1' }, switchGeneration: 'g1', switchOwner: 'daemon:o' }) - expect(spawnArgs.env).toEqual({ KEEP: '1' }) - - const writing = admission(true) - const refused = admitSwitchResumeSpawn(writing, args(), null, { paneKey: PANE, options: {} }) - // Accepted before the first await, so a chat start's census sees it. - expect(writing.acceptSwitchStart).toHaveBeenCalledOnce() - expect(await refused).toMatchObject({ verdict: 'chat-writing' }) - expect(writing.dispatchSwitchStart).not.toHaveBeenCalled() - - const unsaved = admission(false, false) - expect( - await admitSwitchResumeSpawn(unsaved, args(), null, { paneKey: PANE, options: {} }) - ).toMatchObject({ verdict: 'unverifiable' }) - expect(unsaved.dispatchSwitchStart).not.toHaveBeenCalled() - }) - - it('refuses where it cannot judge the pane, and a switch without its conversation', async () => { - const quiet = admission(false) - const spawn = { paneKey: PANE, options: {} } - expect( - await admitSwitchResumeSpawn(quiet, { ...args(), connectionId: 'ssh' }, null, spawn) - ).toMatchObject({ verdict: 'unverifiable' }) - expect(await admitSwitchResumeSpawn(quiet, args(), 'Ubuntu', spawn)).toMatchObject({ - verdict: 'unverifiable' - }) - await expect( - admitSwitchResumeSpawn(quiet, { ...args(), resumeProviderSession: undefined }, null, spawn) - ).rejects.toThrow('switch_start_missing_conversation') - expect(quiet.acceptSwitchStart).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/session-tabs/structured-session-switch-claims.ts b/src/main/runtime/session-tabs/structured-session-switch-claims.ts deleted file mode 100644 index 757b8dcc38b4..000000000000 --- a/src/main/runtime/session-tabs/structured-session-switch-claims.ts +++ /dev/null @@ -1,339 +0,0 @@ -// The conversation index of chat → terminal switch starts. It projects the owner's own answer and -// never settles a start from a caller's success, exception or a session list's absence. -import { randomUUID } from 'node:crypto' -import type { AgentProcessIdentity } from '../../../shared/agent-process-presence' -import { isSameAgentProcess } from '../../../shared/agent-process-presence' -import { chatAgentsShareConversations } from '../../../shared/structured-agent-cli-conversation-adoption' -import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { - isTerminalSwitchDescriptor, - type TerminalSwitchDescriptor, - type TerminalSwitchObservation, - type TerminalSwitchWrite -} from '../../../shared/terminal-switch' -import type { AgentSessionRecordStore } from '../agent-session-record-store' -import { setAgentSessionRecordTerminalSwitch } from '../agent-session-record-terminal-switch' -import { OrcaRuntimeWithGetWorktreePs } from '../workspaces/workspace-process-listing' -import { isLaterSwitchHostBoot, readSwitchHostBoot } from './structured-session-switch-boot' -import { seedResumeLaunchIdentity } from '../../ipc/pty/pane/resume-launch-identity' -import { adoptsCliConversations } from '../../../shared/structured-agent-cli-conversation-adoption' -import { - readSwitchPaneAgentProcesses, - readSwitchShellProcess, - switchStartProcessVerdict -} from './structured-session-switch-observation' - -/** Unfenced: the descriptor is the switch owner's obligation, not the writer lease. */ -const writeTerminalSwitch = ( - store: Pick, - sessionId: string, - write: TerminalSwitchWrite -) => - store.transitionHandoff(sessionId, (record) => - setAgentSessionRecordTerminalSwitch(record, write, Date.now()) - ) - -/** A recipe no terminal took may lapse; once taken, only its owner ends it. */ -const UNACCEPTED_SWITCH_CLAIM_MS = 60_000 -const SWITCH_START_WATCH_POLLS = 120 -const SWITCH_START_WATCH_INTERVAL_MS = 1_000 - -/** One switch, from the chat's stop. Accepted, it is saved on the chat it left. */ -export type SwitchClaim = TerminalSwitchDescriptor & { - chatSessionId: string - at: number - accepted: boolean - /** The descriptor holds this generation; later evidence only updates it. */ - saved: boolean - /** Evidence learned but not yet saved; the next observation retries it. */ - unsaved?: boolean -} - -export const switchMovingRefusal = () => - agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'conversationMovingToTerminal' - }) -export const switchHeldRefusal = () => - agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere' }) - -export abstract class OrcaRuntimeWithStructuredSessionSwitchClaims extends OrcaRuntimeWithGetWorktreePs { - private readonly switchClaims = new Map() - private switchClaimsLoaded: Promise | null = null - - protected switchClaimsFor(agent: string, id: string): SwitchClaim[] { - return [...this.switchClaims.values()].filter((claim) => { - if (!claim.accepted && Date.now() - claim.at >= UNACCEPTED_SWITCH_CLAIM_MS) { - this.switchClaims.delete(claim.generation) - return false - } - return claim.providerSessionId === id && chatAgentsShareConversations(claim.agent, agent) - }) - } - - /** Accepted switches saved before a restart stay owed until their owner proves them over. */ - protected loadSwitchClaims(): Promise { - this.switchClaimsLoaded ??= this.openAgentSessionRecordStore().then( - (store) => { - for (const { sessionId, terminalSwitch } of store.listRecords()) { - if (isTerminalSwitchDescriptor(terminalSwitch)) { - const claim = { - ...terminalSwitch, - processes: [...terminalSwitch.processes], - chatSessionId: sessionId, - at: 0, - accepted: true, - saved: true - } - this.switchClaims.set( - terminalSwitch.generation, - this.switchClaims.get(claim.generation) ?? claim - ) - } - } - }, - (error: unknown) => { - this.switchClaimsLoaded = null - throw error - } - ) - return this.switchClaimsLoaded - } - - /** After a restart, a reattached switch terminal asks the original owner for its binding; - * the stamp is rebuilt from the saved switch and that answer, never persisted itself. */ - override registerPty(...args: Parameters): void { - super.registerPty(...args) - const reattached = args[3]?.providerReattachLaunchIdentity - if (!reattached || !adoptsCliConversations(reattached.launchAgent)) { - return - } - void this.loadSwitchClaims() - .then(async () => { - for (const claim of this.switchClaims.values()) { - const seen = claim.accepted ? await this.observeSwitch(claim, false) : null - if (seen?.state === 'bound' && seen.ptyId === args[0]) { - await this.noteSwitchBound(claim, seen) - } - } - }) - .catch(() => undefined) - } - - /** Presents a switch's conversation on its pane, only while that pane runs its exact execution. */ - private presentSwitchBinding(claim: SwitchClaim): void { - const pty = claim.ptyId ? this.ptysById.get(claim.ptyId) : undefined - if ( - !pty?.incarnationId || - pty.incarnationId !== claim.incarnationId || - pty.paneKey !== claim.paneKey || - this.switchClaims.get(claim.generation) !== claim - ) { - return - } - const row = this.getAgentProviderSessionRows().find( - (candidate) => - candidate.paneKey === claim.paneKey && - candidate.providerSession?.id === claim.providerSessionId - ) - seedResumeLaunchIdentity({ - paneKey: claim.paneKey, - launchAgent: claim.agent, - resumeProviderSession: row?.providerSession ?? { - key: 'session_id', - id: claim.providerSessionId - }, - worktreeId: pty.worktreeId, - launchedAt: 0, - execution: { ptyId: pty.ptyId, incarnationId: pty.incarnationId } - }) - } - - /** Throws while a switch of this conversation exists: one no terminal took yet, or with `all`, any. */ - protected assertNoPendingSwitch(agent: string, id: string, all = false): void { - if (this.switchClaimsFor(agent, id).some((claim) => all || !claim.accepted)) { - throw switchMovingRefusal() - } - } - - /** The owner a fresh switch start here would go to; null when this route cannot hold one. */ - async terminalSwitchOwner(): Promise { - return (await this.ptyController?.terminalSwitches?.owner().catch(() => null)) ?? null - } - - /** In the chat's ordered step before its stop: one switch of a conversation at a time. */ - registerSwitchClaim( - agent: string, - id: string, - chatSessionId: string, - owner: string - ): SwitchClaim { - if (this.switchClaimsFor(agent, id).length > 0) { - throw switchHeldRefusal() - } - const claim: SwitchClaim = { - generation: randomUUID(), - agent, - providerSessionId: id, - owner, - paneKey: '', - processes: [], - chatSessionId, - at: Date.now(), - accepted: false, - saved: false - } - this.switchClaims.set(claim.generation, claim) - return claim - } - - /** Only a recipe no terminal took can be withdrawn. */ - dropSwitchClaim(claim: SwitchClaim): void { - if (!claim.accepted) { - this.switchClaims.delete(claim.generation) - } - } - - /** Synchronous, before the switch spawn's first await. A generation takes one pane; a retry there - * joins it, and an expired or unknown recipe is refused, never reminted. */ - acceptSwitchStart(generation: string, paneKey: string): SwitchClaim | null { - const claim = this.switchClaims.get(generation) - if ( - !claim || - (claim.accepted - ? claim.paneKey !== paneKey - : Date.now() - claim.at >= UNACCEPTED_SWITCH_CLAIM_MS) - ) { - return null - } - Object.assign(claim, { accepted: true, paneKey }) - return claim - } - - /** Saved before dispatch, so a restart still knows the start may exist. A retired claim's - * late evidence never writes, and the store only updates the generation it holds. */ - async saveSwitchClaim(claim: SwitchClaim): Promise { - claim.boot ??= await readSwitchHostBoot() - const { chatSessionId, at: _at, accepted: _a, saved, unsaved: _u, ...descriptor } = claim - if (saved && this.switchClaims.get(claim.generation) !== claim) { - return false - } - const store = await this.openAgentSessionRecordStore() - // A copy: the store freezes what it commits, and this claim keeps collecting evidence. - const snapshot = { ...descriptor, processes: [...descriptor.processes] } - const next = await writeTerminalSwitch( - store, - chatSessionId, - saved ? { update: snapshot } : { accept: snapshot } - ).catch(() => null) - const ok = next?.terminalSwitch?.generation === claim.generation - Object.assign(claim, { saved: saved || ok, unsaved: !ok }) - return ok - } - - /** The owner's fields for the dispatch; evidence collection starts with it and decides nothing. */ - dispatchSwitchStart(claim: SwitchClaim): { switchGeneration: string; switchOwner: string } { - void this.watchSwitchStart(claim).catch((error: unknown) => - console.warn('[agent-session] could not watch a switched terminal start', error) - ) - return { switchGeneration: claim.generation, switchOwner: claim.owner } - } - - /** The owner's word; when it is gone, only the execution it recorded can prove the start over. - * `fence` makes an unseen generation unusable, so its absence is a proved non-start. */ - protected async observeSwitch( - claim: SwitchClaim, - fence: boolean - ): Promise { - const answers = await this.ptyController?.terminalSwitches - ?.observe(claim.generation, fence) - .catch(() => null) - const own = answers?.find((answer) => answer.owner === claim.owner) - // An owner that could not answer is not a lost one. - if (own || !answers) { - return own?.observation ?? { state: 'unverifiable' } - } - // Every reachable owner answered and none is this one: only the execution can end it. - // Only clock-free facts prove a later boot; a wall clock can step without one. - if (claim.boot && isLaterSwitchHostBoot(claim.boot, await readSwitchHostBoot())) { - return { state: 'ended' } - } - const recorded = claim.shell ? [claim.shell, ...claim.processes] : [] - const verdicts = await Promise.all(recorded.map(switchStartProcessVerdict)) - return recorded.length > 0 && verdicts.every((verdict) => verdict === 'exited') - ? { state: 'ended' } - : { state: 'unverifiable' } - } - - /** The exact execution the owner bound, saved with its shell's identity. */ - protected async noteSwitchBound( - claim: SwitchClaim, - bound: Extract - ): Promise { - if ( - claim.ptyId === bound.ptyId && - claim.incarnationId === bound.incarnationId && - (claim.shell || !bound.shellPid) && - !claim.unsaved - ) { - this.presentSwitchBinding(claim) - return - } - const shell = bound.shellPid ? await readSwitchShellProcess(bound.shellPid) : null - Object.assign(claim, { ptyId: bound.ptyId, incarnationId: bound.incarnationId }) - if (shell) { - claim.shell = shell - } - await this.saveSwitchClaim(claim) - this.presentSwitchBinding(claim) - } - - protected async releaseSwitchClaim(claim: SwitchClaim): Promise { - if (this.switchClaims.get(claim.generation) !== claim) { - return - } - this.switchClaims.delete(claim.generation) - const store = await this.openAgentSessionRecordStore() - await writeTerminalSwitch(store, claim.chatSessionId, { clear: claim.generation }).catch( - (error: unknown) => console.warn('[agent-session] could not clear a finished switch', error) - ) - } - - /** An authentic hook's process joins the evidence; identity-only rows carry none. */ - protected noteSwitchStartProcess( - ptyId: string, - conversation: { agent: string; id: string }, - process: AgentProcessIdentity - ): void { - for (const claim of this.switchClaimsFor(conversation.agent, conversation.id)) { - if ( - claim.ptyId === ptyId && - !claim.processes.some((known) => isSameAgentProcess(known, process)) - ) { - claim.processes.push(process) - void this.saveSwitchClaim(claim) - } - } - } - - /** Bounded evidence collection only: it records identities and never fences or releases. */ - private async watchSwitchStart(claim: SwitchClaim): Promise { - for (let poll = 0; poll < SWITCH_START_WATCH_POLLS && claim.processes.length === 0; poll++) { - await new Promise((resolve) => setTimeout(resolve, SWITCH_START_WATCH_INTERVAL_MS).unref?.()) - if (this.switchClaims.get(claim.generation) !== claim) { - return - } - const seen = await this.observeSwitch(claim, false) - if (seen.state === 'ended') { - return - } - if (seen.state === 'bound' && seen.shellPid) { - await this.noteSwitchBound(claim, seen) - const found = await readSwitchPaneAgentProcesses(seen.shellPid, claim.providerSessionId) - if (found.length > 0 && claim.processes.length === 0) { - claim.processes.push(...found) - await this.saveSwitchClaim(claim) - } - } - } - } -} diff --git a/src/main/runtime/session-tabs/structured-session-switch-observation.test.ts b/src/main/runtime/session-tabs/structured-session-switch-observation.test.ts deleted file mode 100644 index 21579eb6c2ae..000000000000 --- a/src/main/runtime/session-tabs/structured-session-switch-observation.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { switchStartProcessVerdict } from './structured-session-switch-observation' - -const table = vi.hoisted(() => { - const state: { - created: number | null - rows: { pid: number; ppid: number; name: string; creationTimeMs?: number }[] - } = { created: null, rows: [] } - return state -}) -vi.mock('../../windows/windows-process-table', () => ({ - isWindowsProcessStartTimeAvailable: () => true, - readWindowsProcessCreationTime: () => table.created, - readWindowsProcessIdentityTableFresh: async () => table.rows, - readWindowsProcessTableFresh: async () => [] -})) - -const AGENT = { pid: 4242, platform: 'win32', startTime: '1000' } as const -const platform = process.platform -beforeEach(() => Object.defineProperty(process, 'platform', { value: 'win32' })) -afterEach(() => Object.defineProperty(process, 'platform', { value: platform })) - -it('reads an unreadable Windows identity as unverifiable, and only absence or reuse as exited', async () => { - table.created = null - table.rows = [{ pid: 4242, ppid: 1, name: 'codex.exe' }] - expect(await switchStartProcessVerdict(AGENT)).toBe('unverifiable') - table.rows = [] - expect(await switchStartProcessVerdict(AGENT)).toBe('unverifiable') - table.rows = [{ pid: 7, ppid: 1, name: 'other.exe' }] - expect(await switchStartProcessVerdict(AGENT)).toBe('exited') - table.rows = [{ pid: 4242, ppid: 1, name: 'codex.exe', creationTimeMs: 2000 }] - expect(await switchStartProcessVerdict(AGENT)).toBe('exited') - table.created = 1000 - expect(await switchStartProcessVerdict(AGENT)).toBe('live') -}) diff --git a/src/main/runtime/session-tabs/structured-session-switch-observation.ts b/src/main/runtime/session-tabs/structured-session-switch-observation.ts deleted file mode 100644 index b2968739d7bf..000000000000 --- a/src/main/runtime/session-tabs/structured-session-switch-observation.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { - AgentProcessIdentity, - AgentProcessVerdict -} from '../../../shared/agent-process-presence' -import { - probeAgentProcessPresence, - readAgentProcess -} from '../../../shared/agent-process-presence-probe' -import { - buildProcessTableIndex, - collectDescendantsFromIndex -} from '../../../shared/process-table-index' -import { getFreshShellForegroundSnapshot } from '../../../shared/process-table-snapshot-reader' -import { - isWindowsProcessStartTimeAvailable, - readWindowsProcessCreationTime, - readWindowsProcessIdentityTableFresh, - readWindowsProcessTableFresh -} from '../../windows/windows-process-table' - -/** The processes below a pane's shell whose command line names the conversation: what the switch - * typed there. Session leaders left the terminal (a prompt's worker), so they are not its job. */ -export async function readSwitchPaneAgentProcesses( - rootPid: number, - conversationId: string -): Promise { - if (process.platform === 'win32') { - if (!isWindowsProcessStartTimeAvailable()) { - return [] - } - const index = buildProcessTableIndex(await readWindowsProcessTableFresh()) - return collectDescendantsFromIndex(index, rootPid).flatMap((row) => - row.command.includes(conversationId) && row.creationTimeMs - ? [{ pid: row.pid, platform: 'win32' as const, startTime: String(row.creationTimeMs) }] - : [] - ) - } - const platform = process.platform - if (platform !== 'darwin' && platform !== 'linux') { - return [] - } - const index = buildProcessTableIndex(await getFreshShellForegroundSnapshot()) - const named = collectDescendantsFromIndex(index, rootPid, (row) => row.stat.includes('s')).filter( - (row) => row.command.includes(conversationId) - ) - const identities = await Promise.all( - named.map(async (row) => { - const observed = await readAgentProcess(row.pid) - return observed.verdict === 'live' && !observed.zombie - ? [{ pid: row.pid, platform, startTime: observed.startTime }] - : [] - }) - ) - return identities.flat() -} - -/** The shell's own identity, so a later owner loss can still be judged by its process. */ -export async function readSwitchShellProcess(pid: number): Promise { - if (process.platform === 'win32') { - const created = isWindowsProcessStartTimeAvailable() - ? readWindowsProcessCreationTime(pid) - : null - return created === null ? null : { pid, platform: 'win32', startTime: String(created) } - } - const platform = process.platform - if (platform !== 'darwin' && platform !== 'linux') { - return null - } - const observed = await readAgentProcess(pid) - return observed.verdict === 'live' ? { pid, platform, startTime: observed.startTime } : null -} - -/** `exited` only on proof: the pid is gone or now names a different process. */ -export async function switchStartProcessVerdict( - identity: AgentProcessIdentity -): Promise { - if (identity.platform !== 'win32') { - return probeAgentProcessPresence(identity) - } - if (process.platform !== 'win32' || !isWindowsProcessStartTimeAvailable()) { - return 'unverifiable' - } - const created = readWindowsProcessCreationTime(identity.pid) - if (created !== null) { - return String(created) === identity.startTime ? 'live' : 'exited' - } - // An unreadable pid is not an absent one: only a fresh table's own listing proves it is gone. - const rows = await readWindowsProcessIdentityTableFresh().catch(() => []) - const row = rows.find((candidate) => candidate.pid === identity.pid) - if (rows.length > 0 && !row) { - return 'exited' - } - return row?.creationTimeMs && String(row.creationTimeMs) !== identity.startTime - ? 'exited' - : 'unverifiable' -} diff --git a/src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts b/src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts new file mode 100644 index 000000000000..4d133cc7265f --- /dev/null +++ b/src/main/runtime/session-tabs/structured-session-terminal-launch-retirement.ts @@ -0,0 +1,62 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { TerminalLaunchResult } from '../../../shared/agent-session-resume' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { listStructuredProviderSessionOwnership } from '../../native-chat/agent-session-wire/structured-provider-session-ownership' +import { + keysMayMatch, + terminalConversationKey +} from '../../native-chat/structured-conversation-key' + +/** Presentation only: a delayed successful spawn must never close a newer native child. */ +export async function retireChatForAdmittedTerminalLaunch( + runtime: { retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean } | undefined, + args: { resumeOrigin?: unknown; worktreeId?: unknown }, + result: TerminalLaunchResult & { isReattach?: boolean } +): Promise { + const decision = result.savedResumeDecision + if ( + args.resumeOrigin !== 'switch' || + result.isReattach || + decision?.verdict !== 'admitted' || + result.agentResumeRefused || + result.agentResumeUnavailable + ) { + return + } + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + const key = terminalConversationKey(decision.agent, decision.providerSession.id) + const holders = listStructuredProviderSessionOwnership(host.deps.store.listRecords()).filter( + (holding) => holding.role === 'head' && keysMayMatch(holding.key, key) + ) + for (const holding of holders) { + const record = host.deps.store.getRecord(holding.sessionId) + if ( + record?.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.workspaceId !== args.worktreeId + ) { + continue + } + try { + await host.setSessionTabVisibility(holding.sessionId, false) + } catch (error) { + // The terminal is admitted regardless of a failed visibility write; never stop another child. + host.deps.logger.warn('recording a switched chat tab retirement failed', { + scope: 'switch-tab-retirement', + sessionId: holding.sessionId, + error + }) + } + try { + runtime?.retireStructuredAgentSessionTabFromSnapshot(holding.sessionId) + } catch (error) { + host.deps.logger.warn('publishing a switched chat tab retirement failed', { + scope: 'switch-tab-retirement', + sessionId: holding.sessionId, + error + }) + } + } +} diff --git a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts index c6ad23fd1e90..8bd9b5a60b8c 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -37,7 +37,7 @@ export type MobileTerminalCreateOptions = { agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent - /** A switch's start: its spawn admission judges and claims the conversation. */ + /** A switch's start: its ordinary spawn admission checks the conversation. */ resumeProviderSession?: AgentProviderSessionMetadata resumeOrigin?: 'switch' viewMode?: 'terminal' | 'chat' diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts index 749652e70918..fb4e94e2461b 100644 --- a/src/main/startup/main-window-agent-status.ts +++ b/src/main/startup/main-window-agent-status.ts @@ -25,7 +25,6 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt launchToken, providerSession, providerSessionOnly, - launchExecution, promptInteractionKey, restoredUnconfirmed, observation, @@ -57,7 +56,6 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt stateStartedAt, ...(providerSession ? { providerSession } : {}), ...(observation ? { observation } : {}), - ...(launchExecution ? { launchExecution } : {}), providerSessionOnly: true }) return diff --git a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx index 1d544b074566..d37d5477a87c 100644 --- a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { useInPlaceSwitchSupported } from '@/lib/in-place-switch-support' +import { useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' import { SwitchToCliTooltipText } from '../right-sidebar/ai-vault-session-cli-fork-copy' import { @@ -38,12 +38,12 @@ export function NativeChatTopRightControls({ return subject?.kind === 'chat' ? subject.request.executionHostScope : null }) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === session.agent) - const inPlace = useInPlaceSwitchSupported(hostId, agent) + const inPlace = useInPlaceSwitchCapability(hostId, agent) const pending = useNativeChatCliSwitchPending(session.sessionId) if (find.isOpen) { return } - if (!worktreeId || !hostId || !agent || !inPlace) { + if (!worktreeId || !hostId || !agent || (inPlace === false && agent !== 'omp')) { return null } const label = translate('components.tab.bar.switch.toCli', 'Switch to CLI') @@ -58,9 +58,9 @@ export function NativeChatTopRightControls({ aria-label={label} // Not `disabled` while a reply runs: the tooltip has to say why. aria-disabled={working || undefined} - disabled={pending} + disabled={pending || inPlace !== true} onClick={() => { - if (!working) { + if (!working && inPlace === true) { void runNativeChatCliSwitch({ sessionId: session.sessionId, workspaceId: worktreeId, @@ -75,7 +75,13 @@ export function NativeChatTopRightControls({ - {working ? ( + {inPlace === false ? ( + translate( + 'components.tab.bar.switch.unavailable', + 'Switching {{agent}} is not available yet.', + { agent: session.agent } + ) + ) : working ? ( translate( 'components.tab.bar.switch.waitForReply', 'Wait for the reply to finish or stop it, then switch.' diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 6763d6fbdb6a..cc28ce185d28 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -155,11 +155,6 @@ const PIECES: Record - translate( - 'components.native-chat.failureWords.conversationMovingToTerminal', - COPY.conversationMovingToTerminal - ), conversationOpenInTerminal: (values) => translate( 'components.native-chat.failureWords.conversationOpenInTerminal', diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index d963dcb4ab6b..4642d7ca8611 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -37,7 +37,8 @@ export function SessionActionMenuItems({ onOpenCwd, deleteBlockedReason, onDelete, - inPlaceAgent + inPlaceAgent, + switchLoading }: { menuKind?: 'dropdown' | 'context' resumeDisabled: boolean @@ -63,6 +64,7 @@ export function SessionActionMenuItems({ // Null when Delete is offered; otherwise the tooltip explaining why it isn't. deleteBlockedReason: string | null onDelete?: () => void + switchLoading?: boolean inPlaceAgent?: AiVaultAgent }) { const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem @@ -130,6 +132,7 @@ export function SessionActionMenuItems({ onResumeInNewChat={onResumeInNewChat} onResumeInNewCli={onResumeInNewCli} inPlaceAgent={inPlaceAgent} + switchLoading={switchLoading} /> {onCopyResume ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx index 51bc820f6b18..a871edbd9546 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx @@ -2,7 +2,7 @@ import { MessagesSquare, SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import { useAiVaultInPlaceAgent } from '@/lib/in-place-switch-support' +import { useAiVaultInPlaceAgent, useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { ResumeInNewCliTooltipText, SwitchToCliTooltipText } from './ai-vault-session-cli-fork-copy' @@ -17,6 +17,8 @@ export function AiVaultSessionDetailsSwitchButtons({ onResumeInNewCli?: () => void }): React.JSX.Element { const inPlaceAgent = useAiVaultInPlaceAgent(session) + const switchLoading = + useInPlaceSwitchCapability(session.executionHostId, session.agent) === undefined return ( <> {onResumeInNewChat ? ( @@ -25,6 +27,7 @@ export function AiVaultSessionDetailsSwitchButtons({ variant="secondary" size="xs" draggable={false} + disabled={switchLoading} onClick={(event) => { event.stopPropagation() onResumeInNewChat() @@ -48,6 +51,7 @@ export function AiVaultSessionDetailsSwitchButtons({ variant="secondary" size="xs" draggable={false} + disabled={switchLoading} onClick={(event) => { event.stopPropagation() onResumeInNewCli() diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 18aa403119ce..83f0ed1f34be 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -1,4 +1,4 @@ -import { useAiVaultInPlaceAgent } from '@/lib/in-place-switch-support' +import { useAiVaultInPlaceAgent, useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin' import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents' import { useCallback } from 'react' @@ -103,6 +103,8 @@ export function VaultSessionRow({ const title = useAiVaultSessionDisplayTitle(session) const detailsId = getSessionDetailsId(session.id) const inPlaceAgent = useAiVaultInPlaceAgent(session) + const switchLoading = + useInPlaceSwitchCapability(session.executionHostId, session.agent) === undefined const latestTurn = latestSessionConversationTurn(session) // Computed once so the dropdown menu and the context menu never disagree. const deleteBlockedReason = onRequestDelete @@ -289,6 +291,7 @@ export function VaultSessionRow({ onResumeInNewChat={onResumeInNewChat} onResumeInNewCli={onResumeInNewCli} inPlaceAgent={inPlaceAgent} + switchLoading={switchLoading} onCopyResume={onCopyResume} onCopyId={onCopyId} onCopyPath={onCopyPath} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx index 1fa66f84b6b4..cc098ca995dd 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSurfaceSwitchMenuItems.tsx @@ -17,7 +17,8 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ onResumeInNewChat, onResumeInNewCli, cliPending = false, - inPlaceAgent + inPlaceAgent, + switchLoading = false }: { action: 'resume' | 'switch' menuKind: 'dropdown' | 'context' @@ -27,6 +28,7 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ onResumeInNewCli?: (event: Event) => void cliPending?: boolean /** The agent whose conversation switches in place on its host; unset keeps the new-tab entries. */ + switchLoading?: boolean inPlaceAgent?: AiVaultAgent }): React.JSX.Element { const inPlace = inPlaceAgent !== undefined @@ -36,7 +38,7 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ {onResumeInNewChat ? ( @@ -51,7 +53,11 @@ export function AiVaultSessionSurfaceSwitchMenuItems({ {onResumeInNewCli ? ( - + {cliPending ? ( ) : ( diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 002ce322dc59..917607d34c7e 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -1,4 +1,4 @@ -import { useAiVaultInPlaceAgent } from '@/lib/in-place-switch-support' +import { useAiVaultInPlaceAgent, useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import type React from 'react' import { ChevronDown, @@ -97,6 +97,8 @@ export function SessionRowTrailingActions({ actionsHidden?: boolean }) { const inPlaceAgent = useAiVaultInPlaceAgent(session) + const switchLoading = + useInPlaceSwitchCapability(session.executionHostId, session.agent) === undefined const jumpToWorktreeTooltip = aiVaultWorktreeJumpTooltip(worktreeInfo) return ( @@ -277,6 +279,7 @@ export function SessionRowTrailingActions({ onResumeInNewChat={onResumeInNewChat} onResumeInNewCli={onResumeInNewCli} inPlaceAgent={inPlaceAgent} + switchLoading={switchLoading} onJumpToOriginalPane={onJumpToOriginalPane} showJumpToWorktree={showJumpToWorktree} jumpWorkspaceId={worktreeInfo?.worktreeId} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts index 969c8253a9bb..446245eaaf09 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts @@ -1,3 +1,6 @@ +import { switchIntoOwner } from '../tab-bar/cli-chat-resume-feedback' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { cliConversationRowsShareAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' import { useAppStore } from '@/store' import { collectAgentPaneConversations } from '@/lib/ai-vault-tab-title-requests' import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' @@ -32,13 +35,18 @@ export function switchAiVaultSessionToCli(session: AiVaultSession): boolean { /** History's "Switch to native chat": in place when a pane in this window last reported the * conversation. False otherwise, so the row opens or reveals its chat in a new tab. */ export function switchAiVaultSessionToNativeChat(session: AiVaultSession): boolean { - if (!inPlaceSwitchSupportedNow(session.executionHostId, session.agent)) { + if ( + !adoptsCliConversations(session.agent) || + !inPlaceSwitchSupportedNow(session.executionHostId, session.agent) + ) { return false } + const agent = session.agent const state = useAppStore.getState() const pane = collectAgentPaneConversations(state, adoptsCliConversations).find( (candidate) => candidate.providerSession.id === session.sessionId && + cliConversationRowsShareAgent(candidate.agent, agent) && candidate.executionHostId === session.executionHostId ) const source = pane @@ -48,7 +56,16 @@ export function switchAiVaultSessionToNativeChat(session: AiVaultSession): boole }) : null if (!source) { - return false + const target = structuredAgentSessionTargetForHost(session.executionHostId) + if (!target || !session.structuredSession || !adoptsCliConversations(session.agent)) { + return false + } + void switchIntoOwner( + { agent: session.agent, tabId: '', workspaceId: session.structuredSession.workspaceId }, + session.structuredSession, + target + ) + return true } void runCliChatResume(source) return true diff --git a/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx b/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx index aef7714957c0..168e6c7659d2 100644 --- a/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { AiVaultSessionSurfaceSwitchMenuItems } from '../right-sidebar/AiVaultSessionSurfaceSwitchMenuItems' -import { resolveCliChatResumeSource } from './cli-chat-resume-source' +import { resolveCliChatResumeSource, useCliChatResumeCapability } from './cli-chat-resume-source' import { runCliChatResume, useCliChatResumePending } from './cli-chat-resume-run' export function CliChatResumeMenuItems({ @@ -15,7 +15,8 @@ export function CliChatResumeMenuItems({ leadingSeparator: boolean onComplete?: () => void }): React.JSX.Element | null { - const [source] = useState(() => resolveCliChatResumeSource(useAppStore.getState(), { tab })) + const source = useAppStore(useShallow((state) => resolveCliChatResumeSource(state, { tab }))) + const capability = useCliChatResumeCapability(tab.worktreeId, source?.agent) const pending = useCliChatResumePending(source) if (!source) { return null @@ -24,7 +25,8 @@ export function CliChatResumeMenuItems({ <> {leadingSeparator ? : null} state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) - const inPlace = useInPlaceSwitchSupported(chat?.request.executionHostScope, agent) + const inPlace = useInPlaceSwitchCapability(chat?.request.executionHostScope, agent) if (!chat || !agent) { return null } @@ -42,11 +42,12 @@ export function NativeChatCliForkMenuItems({ action="switch" menuKind="dropdown" tooltipSide="right" - cliPending={inPlace ? switchPending : forkPending} - {...(inPlace ? { inPlaceAgent: agent } : {})} + cliPending={inPlace !== false ? switchPending : forkPending} + switchLoading={inPlace === undefined} + {...(inPlace !== false ? { inPlaceAgent: agent } : {})} onResumeInNewCli={(event) => { event.preventDefault() - void (inPlace + void (inPlace === true ? runNativeChatCliSwitch({ sessionId: chat.sessionId, workspaceId: chat.workspaceId, diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx index 7164bac2963a..436ab2399fa4 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx @@ -1,3 +1,4 @@ +vi.mock('@/lib/in-place-switch-support', () => ({ useInPlaceSwitchCapability: () => false })) // @vitest-environment happy-dom import { useSyncExternalStore } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -49,7 +50,10 @@ vi.mock('./tab-session-history-switch', async (importOriginal) => ({ resolveTabSessionHistorySubject: () => mocks.subject })) -vi.mock('./cli-chat-resume-source', () => ({ resolveCliChatResumeSource: () => mocks.cliSource })) +vi.mock('./cli-chat-resume-source', () => ({ + useCliChatResumeCapability: () => true, + resolveCliChatResumeSource: () => mocks.cliSource +})) vi.mock('./cli-chat-resume-run', () => ({ runCliChatResume: mocks.runCliChatResume, useCliChatResumePending: () => false diff --git a/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx index b3c43402b831..47c2fb5b8310 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx @@ -4,9 +4,9 @@ import { MessagesSquare, SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { useInPlaceSwitchSupported } from '@/lib/in-place-switch-support' +import { useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' -import { resolveCliChatResumeSource } from './cli-chat-resume-source' +import { resolveCliChatResumeSource, useCliChatResumeCapability } from './cli-chat-resume-source' import { runCliChatResume, useCliChatResumePending } from './cli-chat-resume-run' import { runNativeChatCliSwitch, useNativeChatCliSwitchPending } from './native-chat-cli-switch-run' import { @@ -41,16 +41,16 @@ function ChatSwitchAction({ ) const chat = subject?.kind === 'chat' ? subject : null const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) - const inPlace = useInPlaceSwitchSupported(chat?.request.executionHostScope, agent) + const inPlace = useInPlaceSwitchCapability(chat?.request.executionHostScope, agent) const pending = useNativeChatCliSwitchPending(chat?.sessionId) - if (!chat || !agent || !inPlace) { + if (!chat || !agent || inPlace === false) { return null } return ( +} +afterEach(cleanup) + +it('keeps the switch disabled while loading and re-reads capability changes without a pane update', async () => { + useAppStore.setState(useAppStore.getInitialState(), true) + setLocalRuntimeCapabilitiesForTests(null) + let answer = (_supported: boolean): void => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + render() + expect(screen.getByRole('button', { name: 'Switch to CLI' }).hasAttribute('disabled')).toBe(true) + await act(async () => { + answer(true) + }) + await waitFor(() => expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)) + probe.mockResolvedValue(false) + Object.assign(window, { api: { runtime: { getStatus: async () => ({ capabilities: [] }) } } }) + await act(async () => { + await refreshLocalRuntimeCapabilities() + }) + await waitFor(() => expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)) +}) diff --git a/src/renderer/src/lib/in-place-switch-support.ts b/src/renderer/src/lib/in-place-switch-support.ts index 58febd83ed08..8e4f708db5bf 100644 --- a/src/renderer/src/lib/in-place-switch-support.ts +++ b/src/renderer/src/lib/in-place-switch-support.ts @@ -1,4 +1,6 @@ import { useSyncExternalStore } from 'react' +import { useAppStore } from '@/store' +import { subscribeLocalRuntimeCapabilityObservation } from '@/runtime/local-runtime-capabilities' import { supportsInPlaceSwitch } from '@/runtime/structured-agent-session-client' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { @@ -10,21 +12,41 @@ import type { AiVaultAgent } from '../../../shared/ai-vault-types' // The answer per host and agent; a reconnect or retirement asks again. const known = new Map() -const asking = new Set() +const asking = new Map>() const listeners = new Set<() => void>() const changed = () => listeners.forEach((listener) => listener()) const forget = () => { known.clear() + asking.clear() changed() } onRuntimeEnvironmentRevisionsChanged(forget) onRuntimeEnvironmentsRetired(forget) +subscribeLocalRuntimeCapabilityObservation(forget) +let stopStatusSubscription: (() => void) | undefined +const subscribe = (listener: () => void) => { + if (listeners.size === 0) { + stopStatusSubscription = useAppStore.subscribe((state, previous) => { + if (state.runtimeStatusByEnvironmentId !== previous.runtimeStatusByEnvironmentId) { + forget() + } + }) + } + listeners.add(listener) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + stopStatusSubscription?.() + stopStatusSubscription = undefined + } + } +} /** The cached answer, asking in the background when there is none yet. */ -export function inPlaceSwitchSupportedNow( +export function inPlaceSwitchCapabilityNow( executionHostId: string | null | undefined, agent: string -): boolean { +): boolean | undefined { if (!inPlaceSwitchEnabled(agent)) { return false } @@ -32,37 +54,50 @@ export function inPlaceSwitchSupportedNow( const answer = known.get(key) const target = structuredAgentSessionTargetForHost(executionHostId) if (answer === undefined && target && !asking.has(key)) { - asking.add(key) - void supportsInPlaceSwitch(target, agent).then((supported) => { + const attempt = supportsInPlaceSwitch(target, agent) + asking.set(key, attempt) + void attempt.then((supported) => { + if (asking.get(key) !== attempt) { + return + } asking.delete(key) known.set(key, supported) changed() }) } - return answer === true + return answer +} + +export function inPlaceSwitchSupportedNow(host: string | null | undefined, agent: string): boolean { + return inPlaceSwitchCapabilityNow(host, agent) === true } /** Whether the host running this conversation switches it in place; false until it answered yes. */ -export function useInPlaceSwitchSupported( +export function useInPlaceSwitchCapability( executionHostId: string | null | undefined, agent: string | null | undefined -): boolean { +): boolean | undefined { return useSyncExternalStore( - (listener) => { - listeners.add(listener) - return () => listeners.delete(listener) - }, - () => (agent ? inPlaceSwitchSupportedNow(executionHostId, agent) : false), + subscribe, + () => (agent ? inPlaceSwitchCapabilityNow(executionHostId, agent) : false), () => false ) } +export function useInPlaceSwitchSupported( + host: string | null | undefined, + agent: string | null | undefined +): boolean { + return useInPlaceSwitchCapability(host, agent) === true +} + /** A History row's agent when its host switches it in place: the row offers the switch, not a copy. */ export function useAiVaultInPlaceAgent(session: { agent: AiVaultAgent executionHostId?: string | null }): AiVaultAgent | undefined { - return useInPlaceSwitchSupported(session.executionHostId, session.agent) + return useInPlaceSwitchCapability(session.executionHostId, session.agent) !== false && + inPlaceSwitchEnabled(session.agent) ? session.agent : undefined } diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 4646748c8793..ab279af68d19 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -29,7 +29,7 @@ export function launchAiVaultSessionInNewTab(args: { splitDirection?: TabSplitDirection /** Opens in this tab's slot: the next tab in its group. */ afterTabId?: string - /** A chat's switch, whose spawn claims the conversation; set only for a capable host. */ + /** A chat's switch, whose ordinary spawn admits the resume; set only for a capable host. */ resumeOrigin?: 'switch' }): LaunchAiVaultSessionInNewTabResult { const store = useAppStore.getState() diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 91b82a6afb8f..5a08e59b8edf 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -26,19 +26,9 @@ import { settleAttentionRemovedBy } from './workspace-attention/attention-remova export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime -): Pick { +): Pick { const { get, set, requestFreshness } = runtime return { - setLaunchConversation: (paneKey, conversation) => { - if (conversation || paneKey in get().launchConversationByPaneKey) { - set((s) => { - const { [paneKey]: _old, ...rest } = s.launchConversationByPaneKey - return { - launchConversationByPaneKey: conversation ? { ...rest, [paneKey]: conversation } : rest - } - }) - } - }, recordAgentProviderSession: ( paneKey: string, agent: ResumableTuiAgent, diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 346f1e2e5b1e..bc6575c8e3eb 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -29,15 +29,6 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { ProgramStatusFacet } from '../../../../shared/program-status/facet' -import type { TerminalExecutionStamp } from '../../../../shared/terminal-switch' -import type { CliConversationRowAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' - -/** A switched terminal's conversation, before its agent reports one. Presentation only. */ -export type LaunchConversation = { - agent: CliConversationRowAgent - providerSession: AgentProviderSessionMetadata - execution: TerminalExecutionStamp -} export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ @@ -60,11 +51,6 @@ export type AgentStatusSlice = { /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ sleepingAgentSessionsByPaneKey: Record - /** The host's switched-terminal identity rows; never a sleeping record or a status. */ - launchConversationByPaneKey: Record - /** Null when the pane's PTY exits: a later incarnation, even under the same id, is not it. */ - setLaunchConversation: (paneKey: string, conversation: LaunchConversation | null) => void - /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ agentLaunchConfigByPaneKey: Record diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index d05a94f5851c..b08ed80b5ea2 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -108,7 +108,6 @@ export const createAgentStatusSlice: StateCreator say('conversationOpenElsewhere'), - conversationMovingToTerminal: (_context, _fact, _surface, say) => - say('conversationMovingToTerminal'), managedAccountUnsupported: (context, _fact, _surface, say) => joinSentences([ say('managedAccountUnsupported'), diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index fd464a3ecb21..89736eb8eec5 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -48,7 +48,6 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'runtimeNotInstalled', 'conversationOpenInTerminal', 'conversationOpenElsewhere', - 'conversationMovingToTerminal', 'providerExited', 'restartFailed', 'providerRejected', diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 23ee63f1852a..554630ee2412 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -1,5 +1,4 @@ import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind' -import { isTerminalSwitchDescriptor, type TerminalSwitchDescriptor } from './terminal-switch' import { isAgentSessionLaunchArgs } from './agent-session-launch-args' import { isAgentSessionConversationName } from './agent-session-conversation-name' import { @@ -165,8 +164,6 @@ export type AgentSessionRecord = { /** The name Orca gave this conversation, so a later acquisition need not name it again. */ conversationName?: string launchArgs?: AgentSessionLaunchArgs - /** A switch of this chat's conversation into a terminal whose start its owner has not proved over. */ - terminalSwitch?: TerminalSwitchDescriptor lease: AgentSessionLease createdAt: number updatedAt: number @@ -187,8 +184,9 @@ const MAX_LAUNCH_ENV_ENTRIES = 256 const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536 const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/ -const isBoundedString = (value: unknown, max: number): value is string => - typeof value === 'string' && value.length > 0 && value.length <= max +function isBoundedString(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max +} export function isAgentSessionId(value: unknown): value is string { return typeof value === 'string' && SESSION_ID_PATTERN.test(value) @@ -202,8 +200,9 @@ const SCOPE_KEY_SEPARATOR = '\u0000' * different sessions; collapsing them would let one host adjudicate another host's lease. */ export function agentSessionScopeKey(location: AgentSessionExecutionLocation): string { - const { executionHostId, wslDistro, workspaceId } = location - return [executionHostId, wslDistro ?? '', workspaceId].join(SCOPE_KEY_SEPARATOR) + return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join( + SCOPE_KEY_SEPARATOR + ) } export function agentSessionExecutionLocationsEqual( @@ -374,7 +373,6 @@ export function isPersistedAgentSessionRecord( (record.conversationName === undefined || isAgentSessionConversationName(record.conversationName)) && (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && - (record.terminalSwitch === undefined || isTerminalSwitchDescriptor(record.terminalSwitch)) && !Object.hasOwn(record, 'launchEnv') && isPersistedAgentSessionLease(record.lease) && record.lease.sessionId === record.sessionId && diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 7ebef57c0f31..f7b6d9dd6d2f 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -90,8 +90,6 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** A terminal may still be writing the chat's conversation, so its agent did not start. */ 'conversationOpenInTerminal', 'conversationOpenElsewhere', - /** The conversation is being switched into a terminal that hasn't started yet. */ - 'conversationMovingToTerminal', /** The agent started, then Orca could not open the chat's conversation for it. */ 'attachFailed' ], diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 7161892db037..3d32e4e857b5 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -122,7 +122,6 @@ const REASON_WORDS = { runtimeNotInstalled: { fact: 'runtimeNotInstalled', action: 'actFirst' }, conversationOpenInTerminal: { fact: 'conversationOpenInTerminal', action: 'actFirst' }, conversationOpenElsewhere: { fact: 'conversationOpenElsewhere', action: 'actFirst' }, - conversationMovingToTerminal: { fact: 'conversationMovingToTerminal', action: 'actFirst' }, attachFailed: codeWords('retry') }, agent_session_ownership_unknown: { diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 5d7ab2c227ef..0efbc6d7f7a9 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -5,7 +5,6 @@ import type { StructuredHostStatus } from './agent-hook-listener/listener-event' import type { ProgramStatusFacet } from './program-status/facet' -import type { TerminalExecutionStamp } from './terminal-switch' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { WithAgentStatusObservation } from './agent-status-observation' import type { @@ -65,8 +64,6 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { providerSession?: AgentProviderSessionMetadata /** Resume identity update only; the status-shaped fields are transport placeholders. */ providerSessionOnly?: boolean - /** The switched terminal execution an identity-only row describes. */ - launchExecution?: TerminalExecutionStamp /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string /** See AgentStatusEntry.programStatus. Live-only; older peers ignore it. */ diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index dd41caebff4f..8eac0a500805 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -99,7 +99,6 @@ const KIND_CATEGORY = { runtimeNotInstalled: 'startFailed', conversationOpenInTerminal: 'startFailed', conversationOpenElsewhere: 'startFailed', - conversationMovingToTerminal: 'startFailed', restartFailed: 'startFailed', providerRejected: 'content', attachmentInvalid: 'content', @@ -135,7 +134,6 @@ const KIND_VERDICT = { runtimeNotInstalled: 'failure', conversationOpenInTerminal: 'failure', conversationOpenElsewhere: 'failure', - conversationMovingToTerminal: 'failure', restartFailed: 'failure', providerRejected: 'failure', attachmentInvalid: 'failure', diff --git a/src/shared/terminal-switch.ts b/src/shared/terminal-switch.ts deleted file mode 100644 index 76596d545a5f..000000000000 --- a/src/shared/terminal-switch.ts +++ /dev/null @@ -1,74 +0,0 @@ -// A chat → terminal switch's start, owned by the execution host that creates the terminal: the -// recipe names one generation and that owner, and only the owner can say the start is over. -import { readAgentProcessIdentity, type AgentProcessIdentity } from './agent-process-presence' - -/** Carries the generation from the recipe to the spawn that honours it; the spawn strips it. */ -export const TERMINAL_SWITCH_GENERATION_ENV = 'ORCA_TERMINAL_SWITCH_GENERATION' - -/** What the owner knows of one generation. `bound` is the exact execution it created. */ -export type TerminalSwitchObservation = - | { state: 'pending' } - | { - state: 'bound' - ptyId: string - incarnationId: string - shellPid: number | null - /** The startup command was written or revoked; no producer can still deliver it. */ - startupSettled: boolean - } - | { state: 'ended' } - | { state: 'unverifiable' } - -/** The owner's answer, tagged with who answered: a different owner never speaks for this one. */ -export type TerminalSwitchOwnerAnswer = { owner: string; observation: TerminalSwitchObservation } - -/** The owners a provider reaches: who takes its next fresh start, and every owner's answer. */ -export type TerminalSwitchOwners = { - owner: () => Promise - /** `fence`: an unseen generation becomes unusable and reads ended; without it, pending. */ - observe: (generation: string, fence: boolean) => Promise -} - -/** Saved on the chat a switch left, from acceptance until the owner proves the start over. - * Identity only: never command text, environment, credentials or a running state. */ -export type TerminalSwitchDescriptor = { - generation: string - agent: string - providerSessionId: string - owner: string - paneKey: string - ptyId?: string - incarnationId?: string - shell?: AgentProcessIdentity - processes: AgentProcessIdentity[] - /** The execution host's boot when accepted: a later boot proves its processes gone. */ - boot?: string -} - -/** The exact execution a switch's launch identity row describes; presentation binds to it. */ -export type TerminalExecutionStamp = { ptyId: string; incarnationId: string } - -/** Each names its generation: an acceptance takes an empty slot, an update or a clear only its own. */ -export type TerminalSwitchWrite = - | { accept: TerminalSwitchDescriptor } - | { update: TerminalSwitchDescriptor } - | { clear: string } - -const MAX = 512 -const bounded = (value: unknown): value is string => - typeof value === 'string' && value.length > 0 && value.length <= MAX - -export function isTerminalSwitchDescriptor(value: unknown): value is TerminalSwitchDescriptor { - if (typeof value !== 'object' || value === null) { - return false - } - const d: Partial> = value - return ( - [d.generation, d.agent, d.providerSessionId, d.owner, d.paneKey].every(bounded) && - [d.ptyId, d.incarnationId, d.boot].every((field) => field === undefined || bounded(field)) && - (d.shell === undefined || readAgentProcessIdentity(d.shell) !== undefined) && - Array.isArray(d.processes) && - d.processes.length <= 16 && - d.processes.every((process) => readAgentProcessIdentity(process) !== undefined) - ) -} From 2eeb1828cdcfb6f20dd331835ad707f17eb253c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:31:49 -0700 Subject: [PATCH 029/207] Complete native chat return readiness and placement contracts --- ...red-agent-session-conversation-lifetime.ts | 23 +- ...ured-agent-session-create-adoption.test.ts | 77 ++++- .../structured-agent-session-host-types.ts | 2 + ...ructured-agent-session-switch-admission.ts | 14 +- ...nt-session-terminal-writer-acquire.test.ts | 45 +++ .../mobile-session-layout-projection.ts | 25 ++ .../mobile-session-switch-placement.test.ts | 86 +++++ .../orchestration/chat-assignee.test.ts | 42 ++- .../runtime/orchestration/chat-assignee.ts | 25 ++ .../structured-agent-session-runtime.ts | 2 + ...-chat-coordinator-mail-rig.test-fixture.ts | 6 +- ...red-chat-dispatch-worker-lifecycle.test.ts | 42 +++ .../launch/mobile-terminal-owned-create.ts | 45 +-- .../workspaces/workspace-process-listing.ts | 5 + .../ai-vault-history-native-return.test.tsx | 296 ++++++++++++++++++ .../ai-vault-session-cli-fork-copy.tsx | 2 +- .../ai-vault-session-in-place-switch.ts | 6 +- ...t-session-resume-in-chat-workspace.test.ts | 2 + ...-vault-session-resume-in-chat-workspace.ts | 13 +- .../ai-vault-session-resume-in-chat.ts | 10 +- .../use-ai-vault-panel-session-resume.ts | 11 +- .../tab-bar/cli-chat-resume-feedback.ts | 84 +++-- .../tab-bar/cli-chat-resume-run.test.ts | 88 +++++- .../components/tab-bar/cli-chat-resume-run.ts | 6 +- .../tab-bar/cli-chat-resume-source.ts | 6 +- ...minal-request-ipc-bridge-placement.test.ts | 47 +++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../src/lib/in-place-switch-support.ts | 14 +- .../src/lib/launch-ai-vault-session.test.ts | 25 ++ .../src/lib/launch-ai-vault-session.ts | 13 +- .../web-runtime-terminal-create-operation.ts | 15 +- ...b-runtime-terminal-placement-settlement.ts | 52 ++- ...-runtime-terminal-switch-placement.test.ts | 160 ++++++++++ src/shared/session-tab-placement.ts | 64 ++++ 39 files changed, 1255 insertions(+), 116 deletions(-) create mode 100644 src/main/runtime/mobile-session-switch-placement.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx create mode 100644 src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index d272f5422c89..8c89eee41e40 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -87,13 +87,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { ) } - const hasOpenDispatch = (sessionId: string): boolean => { - const record = deps().store.getRecord(sessionId) - return record !== null && deps().hasOpenDispatch?.(record) === true - } - const providerHoldsDispatch = (sessionId: string): boolean => - deps().adapter.holdsDispatch?.(sessionId) === true - const holdStopClose = async ( sessionId: string, stop: () => Promise, @@ -185,8 +178,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { getRecord: (sessionId) => deps().store.getRecord(sessionId), replacedRuntime: (sessionId) => deps().store.replacedRuntime(sessionId) }, - hasOpenDispatch, - providerHoldsDispatch, + hasOpenDispatch: (sessionId) => { + const record = deps().store.getRecord(sessionId) + return record !== null && deps().hasOpenDispatch?.(record) === true + }, + providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, // The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop. stopAgent: (sessionId) => stopAgent(sessionId, { cause: 'evict', resting: true }), // A host stop: the child's end rejects what it was handed and writes the one error row, and @@ -335,9 +331,14 @@ export function createStructuredAgentSessionConversationLifetime(host: { switchToTerminal: (id: string): Promise => serialize(id, () => switchAdmission.stopIdleStructuredAgentSessionForSwitch( - { session: sessions.get(id), delivering: host.deliveryActive(id), idleSweep }, + { + session: sessions.get(id), + delivering: host.deliveryActive(id), + idleSweep + }, () => holdStopClose(id, () => stopAgent(id, { cause: 'user-close' })), - () => deps().store.getRecord(id) + deps(), + id ) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index dc97dc6f89ac..bca23631a2b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -20,6 +20,8 @@ import { import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' +import { createCodexStructuredLaunchResolver } from '../../runtime/structured-agent-launch-composition.test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' let rig: RestTestRig @@ -66,7 +68,38 @@ function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachPar it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => { const params = await adoptedParams() const history = vi.fn(async () => null) - rig.host.deps.adapter.providerHistoryWindow = history + const codex = fakeCodex({ + // Codex 0.162 answers excludeTurns resumes with metadata, not historical turns. + 'thread/resume': () => ({ + thread: { id: THREAD, turns: [] }, + model: 'gpt-5.4', + reasoningEffort: 'medium' + }), + 'turn/start': () => ({ turn: { id: 'new-turn' } }), + 'model/list': () => ({ + data: [ + { + model: 'gpt-5.4', + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + }) + }) + const adapter = adapterFor(codex, {}, [], { + resolveLaunch: createCodexStructuredLaunchResolver({ + store: { + getRecord: (id) => rig.store.getRecord(id), + pinLaunchDirectory: (...args) => rig.store.pinLaunchDirectory(...args) + }, + resolveWorkspacePath: async () => rig.root, + resolveCommand: () => 'codex', + resolveLaunchArgs: () => [] + }) + }) + await rig.restart({ adapter: Object.assign(adapter, { providerHistoryWindow: history }) }) expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true, value: { @@ -92,19 +125,39 @@ it('imports adoption at rest and resumes its original provider identity after it adopt: { providerHandle: codexProviderHandle(THREAD) } }) ).toMatchObject({ ok: true, replayed: true }) - expect(await rig.host.send(CALLER, restTestSend('new work'))).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(rig.adapter.acquire).toHaveBeenCalledWith( - expect.objectContaining({ - identity: expect.objectContaining({ - providerHandle: expect.objectContaining({ nativeId: THREAD }) - }) - }) - ) + expect(await rig.host.switchToChat(SESSION)).toEqual({ ok: true }) + const caughtUp = await rig.host.journalSnapshot(SESSION) + expect( + caughtUp.items.filter((item) => item.body.kind === 'message' && item.body.role === 'user') + ).toHaveLength(1) + expect(codex.connections[0].calls.some((call) => call.method === 'turn/start')).toBe(false) + expect(await rig.host.switchToChat(SESSION)).toEqual({ ok: true }) + expect(codex.connections).toHaveLength(1) + expect(codex.connections[0].calls.filter((call) => call.method === 'thread/resume')).toEqual([ + { + method: 'thread/resume', + params: expect.objectContaining({ threadId: THREAD, excludeTurns: true }) + } + ]) + expect( + (await rig.host.journalSnapshot(SESSION)).items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'user' + ) + ).toHaveLength(1) expect(history).toHaveBeenCalledOnce() - expect(history.mock.invocationCallOrder[0]).toBeLessThan( - rig.adapter.acquire.mock.invocationCallOrder[0] + expect( + await rig.host.send( + CALLER, + restTestSend('new work', rig.store.getRecord(SESSION)?.lease.runtimeFence) + ) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(codex.connections[0].calls.filter((call) => call.method === 'turn/start')).toHaveLength( + 1 + ) ) + expect(history).toHaveBeenCalledOnce() + await adapter.closeAll() expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 4a3caddd92da..65e1f5dae375 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -158,6 +158,8 @@ export type StructuredAgentSessionHostDeps = { startupLimits?: Partial /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean + /** Explicit switch admission also includes direct chat assignments, even at rest. */ + hasOwedSwitchWork?: (record: AgentSessionRecord) => boolean /** A chat tab left the screen: closed, or its workspace removed. Advisory; a throw is logged. */ onSessionTabHidden?: (sessionId: string) => void /** Throws while a terminal may still write the record's head conversation; run before a spawn. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts index 46d075a0f749..98e5c59130fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-admission.ts @@ -4,9 +4,11 @@ import { agentSessionLeaseOwnerVerdict } from '../../../shared/agent-session-lease-adjudication' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' /** In the chat's ordered step: refuses unless nothing is running, starting, queued or owed, then * admits, stops through close's body, and proves the agent gone before anything starts elsewhere. */ @@ -19,11 +21,15 @@ export async function stopIdleStructuredAgentSessionForSwitch( } }, stop: () => Promise, - record: () => AgentSessionRecord | null + deps: StructuredAgentSessionHostDeps, + sessionId: string ): Promise { const { session } = idle + const currentRecord = deps.store.getRecord(sessionId) + const owesWork = deps.hasOwedSwitchWork ?? deps.hasOpenDispatch if ( idle.delivering || + (currentRecord !== null && owesWork?.(currentRecord) === true) || (session !== undefined && (session.child?.phase === 'starting' || session.journal.submissions().some(isQueuedAgentJournalSubmission) || @@ -33,7 +39,7 @@ export async function stopIdleStructuredAgentSessionForSwitch( throw agentSessionRefusalError('agent_session_operation_invalid', { reason: 'turnActive' }) } await stop() - const lease = record()?.lease + const lease = deps.store.getRecord(sessionId)?.lease if (!lease || agentSessionLeaseOwnerVerdict(lease) !== 'exited') { throw agentSessionRefusalError('agent_session_ownership_unknown', { reason: 'ownerUnproven' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index e8414016524e..cf6af4ff7901 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -247,3 +247,48 @@ it('retires only after an explicit admitted launch without stopping a later nati expect(host.collaboratorsForTests().sessions.get(SESSION)?.child).toBe(laterChild) expect(host.deps.adapter.closeSession).toHaveBeenCalledTimes(closes) }) + +it.each(['ready', 'failed', 'closed', 'replaced'] as const)( + 'awaits provider open for the selected child, whose result is %s', + async (ending) => { + await host.close(SESSION, 'evict') + const held = Promise.withResolvers() + open.mockImplementationOnce(async () => { + await held.promise + if (ending === 'failed') { + throw new AgentSessionAcquisitionRefusal('provider failed') + } + return { reportedOptions: { model: '' } } + }) + let settled = false + const switching = host.switchToChat(SESSION).then((result) => { + settled = true + return result + }) + await vi.waitFor(() => expect(open).toHaveBeenCalledTimes(2)) + const child = host.collaboratorsForTests().sessions.get(SESSION)?.child + expect(child?.phase).toBe('starting') + expect(settled).toBe(false) + let replacement = child + if (ending === 'closed' || ending === 'replaced') { + await host.close(SESSION, 'evict') + } + if (ending === 'replaced') { + expect(await host.switchToChat(SESSION)).toEqual({ ok: true }) + replacement = host.collaboratorsForTests().sessions.get(SESSION)?.child + expect(replacement?.generation).not.toBe(child?.generation) + } + held.resolve() + expect(await switching).toMatchObject({ ok: ending === 'ready' }) + expect(vi.mocked(host.deps.adapter.dispatch)).not.toHaveBeenCalled() + if (ending === 'replaced') { + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child).toBe(replacement) + } + if (ending === 'closed') { + expect(await host.switchToChat(SESSION)).toEqual({ ok: true }) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.generation).not.toBe( + child?.generation + ) + } + } +) diff --git a/src/main/runtime/mobile-session-layout-projection.ts b/src/main/runtime/mobile-session-layout-projection.ts index 60ab2aec3d1d..eed2e67efd73 100644 --- a/src/main/runtime/mobile-session-layout-projection.ts +++ b/src/main/runtime/mobile-session-layout-projection.ts @@ -1,3 +1,7 @@ +import { + placeUnpinnedSessionTabAtCapture, + type CapturedSessionTabPlacement +} from '../../shared/session-tab-placement' import { createHash, randomUUID } from 'node:crypto' import { isTerminalLeafId } from '../../shared/stable-pane-id' import type { @@ -301,3 +305,24 @@ export function buildMaterializedHeadlessParentLayout( } } } + +/** Captured neighbors settle into the current group through ordinary pin-aware insertion. */ +export function placeHeadlessSessionTabAtCapture( + groups: RuntimeMobileSessionTabGroup[], + tabs: readonly RuntimeMobileSessionSnapshotTab[], + tabId: string, + captured: CapturedSessionTabPlacement +): void { + const group = groups.find((candidate) => candidate.tabOrder.includes(tabId)) + if (group) { + group.tabOrder = placeUnpinnedSessionTabAtCapture( + group.tabOrder, + tabs.map((tab) => ({ + id: tab.type === 'terminal' ? tab.parentTabId : tab.id, + isPinned: tab.isPinned === true + })), + tabId, + captured + ) + } +} diff --git a/src/main/runtime/mobile-session-switch-placement.test.ts b/src/main/runtime/mobile-session-switch-placement.test.ts new file mode 100644 index 000000000000..38a68850b2b8 --- /dev/null +++ b/src/main/runtime/mobile-session-switch-placement.test.ts @@ -0,0 +1,86 @@ +import { expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsSnapshot, + RuntimeMobileSessionSnapshotTab +} from '../../shared/runtime-types' +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { store, TEST_WORKTREE_ID: WT } = await import('./orca-runtime-test-fixtures.spec') + +it.each([ + { only: false, pinned: false, retire: true }, + { only: true, pinned: false, retire: true }, + { only: false, pinned: true, retire: true }, + { only: false, pinned: true, retire: false } +])( + 'preserves current headless placement and pin partitions ($only/$pinned/$retire)', + async ({ only, pinned, retire }) => { + const runtime = new OrcaRuntimeService(store) + const chat = (id: string): RuntimeMobileSessionSnapshotTab => ({ + type: 'agent-session', + id, + sessionId: id, + agent: 'codex', + title: id, + isActive: false, + isPinned: pinned && ['A', 'C', 'B'].includes(id) + }) + const source = only ? ['C'] : pinned ? ['A', 'C', 'B', 'U'] : ['A', 'C', 'B'] + const initial: RuntimeMobileSessionTabsSnapshot = { + worktree: WT, + publicationEpoch: 'headless:test', + snapshotVersion: 1, + activeGroupId: 'R', + activeTabId: 'D', + activeTabType: 'agent-session', + tabs: [...source, 'D'].map(chat), + tabGroups: [ + { id: 'L', activeTabId: 'C', tabOrder: source }, + { id: 'R', activeTabId: 'D', tabOrder: ['D'] } + ] + } + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [initial] }) + runtime.setPtyController({ + spawn: vi.fn(async () => { + if (retire) { + expect(runtime.retireStructuredAgentSessionTabFromSnapshot('C')).toBe(true) + } + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId: 'concurrent', + agent: 'codex', + activate: false + }) + return { id: 'pty-placement', incarnationId: '45454545-4545-4545-8545-454545454545' } + }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const created = await runtime.createMobileSessionTerminal(`id:${WT}`, { + afterTabId: 'C', + targetGroupId: 'L', + command: 'codex resume conversation', + activate: false + }) + const result = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(result.tabs.some((tab) => tab.id === 'C')).toBe(!retire) + expect( + result.tabs.some((tab) => tab.type === 'agent-session' && tab.sessionId === 'concurrent') + ).toBe(true) + if (!only) { + expect(result.tabGroups?.find((group) => group.id === 'L')?.tabOrder).toEqual( + pinned + ? ['A', ...(!retire ? ['C'] : []), 'B', created.tab.parentTabId, 'U'] + : ['A', created.tab.parentTabId, 'B'] + ) + expect(result.tabGroups?.find((group) => group.id === 'R')?.tabOrder).toContain('D') + } else { + expect( + result.tabGroups?.some((group) => group.tabOrder.includes(created.tab.parentTabId)) + ).toBe(true) + } + const refreshed = await runtime.listMobileSessionTabs(`id:${WT}`) + expect(refreshed.tabGroups).toEqual(result.tabGroups) + } +) diff --git a/src/main/runtime/orchestration/chat-assignee.test.ts b/src/main/runtime/orchestration/chat-assignee.test.ts index 5b1ac6252a54..f3493a7ee680 100644 --- a/src/main/runtime/orchestration/chat-assignee.test.ts +++ b/src/main/runtime/orchestration/chat-assignee.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { sessionRecord } from '../rpc/orchestration-session-caller-test-fixture' -import { chatAssigneeSessionId, observeChatAssignee } from './chat-assignee' +import { chatAssigneeSessionId, observeChatAssignee, chatAssigneeOwesWork } from './chat-assignee' +import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' import type { AgentSessionRecordReader } from './structured-session-lineage' const CHAT = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') @@ -64,3 +66,41 @@ describe('a chat assignee', () => { }) }) }) + +it('derives direct debt through legacy clear lineage and stops pinning once that assignment settles', () => { + const db = new OrchestrationDb(':memory:') + try { + const successor = testOrcaSessionId('3f9a1c7e-6b2d-4e85-a0c4-9d1e7b3f5a26') + const root: AgentSessionRecord = { + ...sessionRecord(CHAT), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: successor, + operationId: 'clear', + callerKey: 'caller', + phase: 'committed' + } + } + const records = [root, sessionRecord(successor, { lease: { claimStatus: 'released' } })] + const reader = { + getRecord: (id: string) => records.find((record) => record.sessionId === id) ?? null, + listRecords: () => records + } + const task = db.createTask({ runId: 'run_legacy_local', spec: 'original work' }) + const dispatch = createRootDispatch(db, task.id, `orca_session_id:${CHAT}`) + for (const status of ['pending', 'dispatched']) { + db.db.prepare('UPDATE dispatch_contexts SET status = ? WHERE id = ?').run(status, dispatch.id) + expect(chatAssigneeOwesWork(db, successor, reader)).toBe(true) + expect(chatAssigneeOwesWork(db, CHAT, reader)).toBe(false) + expect(chatAssigneeOwesWork(db, 'unrelated', reader)).toBe(false) + } + db.db + .prepare('UPDATE dispatch_contexts SET status = ? WHERE id = ?') + .run('completed', dispatch.id) + expect(chatAssigneeOwesWork(db, successor, reader)).toBe(false) + expect(chatAssigneeOwesWork(null, successor, reader)).toBe(false) + } finally { + db.close() + } +}) diff --git a/src/main/runtime/orchestration/chat-assignee.ts b/src/main/runtime/orchestration/chat-assignee.ts index 06aaeeeea7d4..8af28dcacaed 100644 --- a/src/main/runtime/orchestration/chat-assignee.ts +++ b/src/main/runtime/orchestration/chat-assignee.ts @@ -109,3 +109,28 @@ export function exitedChatDispatchesForSession( } ) } + +/** Direct assignments outlive a provider child; /clear keeps their original assignee address. */ +export function chatAssigneeOwesWork( + db: OrchestrationDb | null, + sessionId: string, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): boolean { + if (!db || !store) { + return false + } + const rows = db.db + .prepare(`SELECT DISTINCT assignee_handle FROM dispatch_contexts + WHERE status IN ('pending', 'dispatched') AND substr(assignee_handle, 1, ?) = ?`) + .all(ORCA_SESSION_ADDRESS_PREFIX.length, ORCA_SESSION_ADDRESS_PREFIX) + return rows.some((row) => { + const assignee = + typeof row === 'object' && + row !== null && + 'assignee_handle' in row && + typeof row.assignee_handle === 'string' + ? chatAssigneeSessionId(row.assignee_handle) + : null + return assignee !== null && lineageLiveSession(store, assignee)?.sessionId === sessionId + }) +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 873893236ed3..d29dbc8f4655 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -124,6 +124,7 @@ export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportO statusSink?: StructuredAgentSessionHostDeps['statusSink'] /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] + hasOwedSwitchWork?: StructuredAgentSessionHostDeps['hasOwedSwitchWork'] resolveCodexAccountKind?: (home: string) => AgentSessionAccountKind | undefined /** Launch prep's sync for a probed home; see `CodexModelCatalogProbeDeps.prepareHome`. */ prepareCodexCatalogProbeHome?: (homePath: string) => void @@ -315,6 +316,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.hasOwedSwitchWork ? { hasOwedSwitchWork: deps.hasOwedSwitchWork } : {}), ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(deps.assertNoTerminalWriter ? { assertNoTerminalWriter: deps.assertNoTerminalWriter } : {}), diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 0b5e99fdda81..530bcafc9fcd 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -16,6 +16,8 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { activeProviderContext } from '../../shared/agent-session-provider-context' import { OrcaRuntimeService } from './orca-runtime' +import { chatAssigneeOwesWork } from './orchestration/chat-assignee' +import { structuredWorkerOwesWork } from './structured-worker-custody' import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' import { formatMessagePointer } from './orchestration/formatter' @@ -261,7 +263,9 @@ beforeEach(async () => { ...scriptedCodexTransport(codex.openConnection), // The same calls the runtime's own host install makes. onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary), - onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId) + onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId), + hasOwedSwitchWork: (record) => + structuredWorkerOwesWork(db, record) || chatAssigneeOwesWork(db, record.sessionId) }) dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) }) diff --git a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts index 044a73701661..f1f8f66d8f76 100644 --- a/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts +++ b/src/main/runtime/structured-chat-dispatch-worker-lifecycle.test.ts @@ -14,6 +14,7 @@ import { WAIT, call, clearChat, + connectionFor, coordinatorRunAndTask, db, dispatcher, @@ -234,3 +235,44 @@ describe("a chat assignee's liveness, stop and close", () => { expect(JSON.stringify(read.transcript)).toContain('working on it') }) }) + +// An idle reply does not settle an assignment; the same chat/caller must still finish it. +it.each(['pending', 'dispatched'] as const)( + 'keeps %s direct work at rest through switch refusal and original completion', + async (status) => { + const { taskId, dispatchId } = await dispatchToChat() + await clearChat(PEER_CHAT) + await host.close(PEER_CHAT, 'evict') + db.db.prepare('UPDATE dispatch_contexts SET status = ? WHERE id = ?').run(status, dispatchId) + const before = db.getDispatchContextById(dispatchId) + const close = vi.spyOn(host.deps.adapter, 'closeSession') + const acquire = vi.spyOn(host.deps.adapter, 'acquire') + await expect(host.switchToTerminal(PEER_CHAT)).rejects.toMatchObject({ + refusal: { details: { reason: 'turnActive' } } + }) + expect(close).not.toHaveBeenCalled() + expect(acquire).not.toHaveBeenCalled() + expect(db.getDispatchContextById(dispatchId)).toEqual(before) + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toContain(PEER_CHAT) + db.db + .prepare('UPDATE dispatch_contexts SET status = ? WHERE id = ?') + .run('dispatched', dispatchId) + await startClearedContext(PEER_CHAT) + await call( + 'orchestration.send', + { to: WORKER, subject: 'follow up', body: 'finish original work' }, + { sessionId: COORDINATOR } + ) + const mail = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) + expect(JSON.stringify(mail)).toContain('follow up') + await vi.waitFor(() => expect(connectionFor(PEER_CHAT).turns).toHaveLength(2), WAIT) + await settleTurn(PEER_CHAT, 1) + await call('orchestration.send', workerDone(taskId, dispatchId), { sessionId: PEER_CHAT }) + expect(db.getTask(taskId)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + await vi.waitFor(async () => { + await host.switchToTerminal(PEER_CHAT) + }, WAIT) + expect(host.deps.store.getRecord(PEER_CHAT)?.lease.claimStatus).toBe('released') + } +) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 456d88e2eb46..ec13b3cfacd6 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -17,10 +17,13 @@ import { randomUUID } from 'node:crypto' import { copySleepingAgentLaunchConfig } from '../../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../../remote-runtime-terminal-create-identity' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { placeCreatedSessionTab } from '../../../../shared/session-tab-placement' -import { insertTabIdIntoOrder } from '../../../../shared/workspace-layout/tab-order' +import { + placeCreatedSessionTab, + captureSessionTabPlacement +} from '../../../../shared/session-tab-placement' import { buildHeadlessMobileSessionTabGroups, + placeHeadlessSessionTabAtCapture, buildMaterializedHeadlessParentLayout, getHeadlessMobileSessionGroupId } from '../../mobile-session-layout-projection' @@ -153,6 +156,19 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext signal: opts.signal, ...(opts.refuseSleptWorktree ? { refuseSleptWorktree: true } : {}) }) + const beforeCreate = this.mobileSessionTabsByWorktree.get(worktreeId) + const anchor = beforeCreate?.tabs.find((tab) => tab.id === afterTabId) + const anchorParentId = + opts.afterDesktopTabId ?? (anchor?.type === 'terminal' ? anchor.parentTabId : anchor?.id) + const sourceGroup = beforeCreate?.tabGroups?.find((group) => + group.tabOrder.includes(anchorParentId ?? '') + ) + const targetGroupId = opts.targetGroupId ?? sourceGroup?.id + const capturedOrder = captureSessionTabPlacement(sourceGroup?.tabOrder ?? [], anchorParentId) + const capturedTabs = captureSessionTabPlacement( + beforeCreate?.tabs.map((tab) => tab.id) ?? [], + afterTabId + ) const terminal = opts.createMutation ? await this.dedupeTerminalCreate( opts.createMutation.clientIdentity, @@ -243,30 +259,17 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext })), tab, afterTabId, - { afterParentGroup: opts.supportsSplitGroupPlacement !== false } + { afterParentGroup: opts.supportsSplitGroupPlacement !== false, captured: capturedTabs } ) const tabGroups = buildHeadlessMobileSessionTabGroups( worktreeId, tabs, activate ? tab : null, existing?.tabGroups, - opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined + targetGroupId ? { tabId: parentTabId, groupId: targetGroupId } : undefined ) - if (opts.afterDesktopTabId) { - const group = tabGroups.find((candidate) => candidate.tabOrder.includes(parentTabId)) - if (group?.tabOrder.includes(opts.afterDesktopTabId)) { - const topLevelTabs = tabs.map((candidate) => ({ - id: candidate.type === 'terminal' ? candidate.parentTabId : candidate.id, - isPinned: candidate.isPinned === true - })) - group.tabOrder = insertTabIdIntoOrder( - group.tabOrder, - topLevelTabs, - parentTabId, - tab.isPinned === true, - opts.afterDesktopTabId - ) - } + if (capturedOrder && (!opts.targetGroupId || opts.targetGroupId === sourceGroup?.id)) { + placeHeadlessSessionTabAtCapture(tabGroups, tabs, parentTabId, capturedOrder) } const next: RuntimeMobileSessionTabsSnapshot = { worktree: worktreeId, @@ -275,8 +278,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, // Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too. activeGroupId: - activate && opts.targetGroupId - ? opts.targetGroupId + activate && targetGroupId && tabGroups.some((group) => group.id === targetGroupId) + ? targetGroupId : (existing?.activeGroupId ?? getHeadlessMobileSessionGroupId(worktreeId)), activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), diff --git a/src/main/runtime/workspaces/workspace-process-listing.ts b/src/main/runtime/workspaces/workspace-process-listing.ts index 916ce04af7be..ed67881f900a 100644 --- a/src/main/runtime/workspaces/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/workspace-process-listing.ts @@ -1,3 +1,4 @@ +import { chatAssigneeOwesWork } from '../orchestration/chat-assignee' import { resolveStructuredCodexAccountKind } from '../structured-agent-account-home' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { collectRuntimeWorktreeAgentSources } from '../runtime-worktree-agent-sources' @@ -310,6 +311,10 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithWorktr reconcile: (state) => this.mobileNotifications.reconcileStructuredPromptAttention(state), now: () => Date.now() }), + hasOwedSwitchWork: (record) => { + const db = this.getOrchestrationDbIfAvailable?.() ?? null + return structuredWorkerOwesWork(db, record) || chatAssigneeOwesWork(db, record.sessionId) + }, // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps // its agent running. No database answers no. hasOpenDispatch: (record) => diff --git a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx new file mode 100644 index 000000000000..5630ec11d3e9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx @@ -0,0 +1,296 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { + refreshLocalRuntimeCapabilities, + setLocalRuntimeCapabilitiesForTests +} from '@/runtime/local-runtime-capabilities' +import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY as HISTORY } from '../../../../shared/protocol-version' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { AiVaultVirtualRow } from './AiVaultVirtualRow' +import { useAiVaultPanelSessionResume } from './use-ai-vault-panel-session-resume' +import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' + +const mocks = vi.hoisted(() => ({ + probe: vi.fn<() => Promise>(), + owner: vi.fn(() => 'local'), + newChat: vi.fn(), + fork: vi.fn(), + startOwner: vi.fn(async () => false), + run: vi.fn(), + source: vi.fn(), + panes: vi.fn< + () => { + providerSession: { id: string } + agent: 'codex' + executionHostId: 'runtime:paired' + tabId: string + worktreeId: string + paneKey: string + }[] + >(() => []) +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + supportsInPlaceSwitch: mocks.probe, + subscribeStructuredAgentSessionStatus: vi.fn(async () => ({ unsubscribe: vi.fn() })) +})) +vi.mock('@/runtime/structured-agent-session-owner', async (original) => ({ + ...(await original()), + resolveStructuredAgentSessionOwner: mocks.owner +})) +// Ordinary route feasibility has its own contracts; these tests exercise the History gates above it. +vi.mock('@/lib/agent-session-launch-plan', async (original) => ({ + ...(await original()), + structuredAgentSessionLaunchFeasible: () => true +})) +vi.mock('./ai-vault-session-resume-in-chat-launch', () => ({ + resumeAiVaultSessionInNewChat: mocks.newChat +})) +vi.mock('../tab-bar/cli-chat-resume-feedback', () => ({ switchIntoOwner: mocks.startOwner })) +vi.mock('../tab-bar/cli-chat-resume-run', () => ({ runCliChatResume: mocks.run })) +vi.mock('../tab-bar/cli-chat-resume-source', () => ({ resolveCliChatResumeSource: mocks.source })) +vi.mock('@/lib/ai-vault-tab-title-requests', async (original) => ({ + ...(await original()), + collectAgentPaneConversations: mocks.panes +})) + +const row: AiVaultSession = { + id: 'codex-history', + agent: 'codex', + sessionId: 'conversation', + executionHostId: 'local', + structuredSession: { sessionId: 'owner', workspaceId: 'workspace' }, + title: 'Conversation', + cwd: '/repo', + branch: null, + model: null, + filePath: '/rollout.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-21T00:00:00Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [{ role: 'user', text: 'hello', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null +} +function History({ session }: { session: AiVaultSession }) { + const state = useAppStore.getState() + const targetState = { + ...state, + repos: [], + worktreesByRepo: { + repo: [makeWorktree({ id: 'workspace', repoId: 'repo', path: '/repo', hostId: 'local' })] + }, + folderWorkspaces: [], + projectGroups: [] + } + const resume = useAiVaultPanelSessionResume({ + sessions: [session], + repos: [], + allWorktrees: [], + workspaceId: 'workspace', + resumeTargetState: targetState, + settings: { ...state.settings, experimentalNativeChat: true } + }) + const actions = useAiVaultSessionLaunchActions({ + activeWorktree: null, + activeWorktreeId: 'workspace', + targetState + }) + return ( + + null} + isStructuredSessionOpen={() => false} + getSessionLiveState={() => null} + getWorktreeInfo={resume.getSessionWorktreeInfo} + getSessionResumeState={resume.getSessionResumeState} + getSessionResumeActions={resume.getSessionResumeActions} + getSessionResumeInChat={resume.getSessionResumeInChat} + onToggleGroup={vi.fn()} + onToggleSessionDetails={vi.fn()} + onJumpToOriginalPane={vi.fn()} + onJumpToWorktree={vi.fn()} + onResume={vi.fn()} + onContinueInNewSession={vi.fn()} + onResumeInNewChat={actions.handleResumeInNewChat} + onResumeInNewCli={mocks.fork} + onCopyResume={vi.fn()} + onCopyId={vi.fn()} + onCopyPath={vi.fn()} + onOpenLog={vi.fn()} + onRevealLog={vi.fn()} + onOpenCwd={vi.fn()} + onRequestDelete={vi.fn()} + /> + + ) +} +beforeEach(async () => { + vi.clearAllMocks() + Object.assign(window, { + api: { + ...window.api, + aiVault: {}, + runtime: { getStatus: async () => ({ capabilities: [HISTORY] }) } + } + }) + useAppStore.setState(useAppStore.getInitialState(), true) + setLocalRuntimeCapabilitiesForTests([HISTORY]) + await refreshLocalRuntimeCapabilities() + mocks.owner.mockReturnValue('local') + mocks.probe.mockResolvedValue(true) + mocks.source.mockReturnValue(null) + mocks.panes.mockReturnValue([]) +}) +afterEach(cleanup) + +it.each(['local', 'runtime:paired'] as const)( + 'composes the owned %s row/details/menu and refreshes unknown capability', + async (host) => { + mocks.owner.mockReturnValue(host) + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + ['paired', { status: { capabilities: [HISTORY] }, checkedAt: 1 }] + ]) + }) + let answer = (_value: boolean) => {} + mocks.probe.mockImplementationOnce( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const session = { ...row, executionHostId: host } + render() + const button = screen.getByRole('button', { name: 'Switch to native chat' }) + expect(button.hasAttribute('disabled')).toBe(true) + await act(async () => answer(true)) + await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false)) + await userEvent.setup().click(button) + expect(mocks.startOwner).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace' }), + row.structuredSession, + host === 'local' ? { kind: 'local' } : { kind: 'environment', environmentId: 'paired' } + ) + await userEvent.setup().click(screen.getByTestId('ai-vault-session-more-actions')) + await userEvent + .setup() + .click(await screen.findByRole('menuitem', { name: 'Switch to native chat' })) + expect(mocks.startOwner).toHaveBeenCalledTimes(2) + fireEvent.contextMenu(screen.getByText('Conversation')) + await userEvent + .setup() + .click(await screen.findByRole('menuitem', { name: 'Switch to native chat' })) + expect(mocks.startOwner).toHaveBeenCalledTimes(3) + } +) + +it('routes a capable paired unowned row with a matching terminal through the same return', async () => { + mocks.owner.mockReturnValue('runtime:paired') + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + ['paired', { status: { capabilities: [HISTORY] }, checkedAt: 1 }] + ]) + }) + const session = { + ...row, + structuredSession: undefined, + executionHostId: 'runtime:paired' as const + } + const source = { + agent: 'codex', + tabId: 'terminal', + workspaceId: 'workspace', + paneKey: 'terminal:leaf', + executionHostId: 'runtime:paired', + providerSessionId: 'conversation' + } + mocks.panes.mockReturnValue([ + { + providerSession: { id: 'conversation' }, + agent: 'codex', + executionHostId: 'runtime:paired', + tabId: 'terminal', + worktreeId: 'workspace', + paneKey: 'terminal:leaf' + } + ]) + mocks.source.mockReturnValue(source) + render() + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Switch to native chat' }).hasAttribute('disabled') + ).toBe(false) + ) + await userEvent.setup().click(screen.getByRole('button', { name: 'Switch to native chat' })) + expect(mocks.run).toHaveBeenCalledExactlyOnceWith(source) +}) + +it.each([ + { + executionHostId: 'runtime:paired' as const, + agent: 'codex' as const, + filePath: '/rollout.jsonl' + }, + { executionHostId: 'ssh:box' as const, agent: 'codex' as const, filePath: '/rollout.jsonl' }, + { + executionHostId: 'local' as const, + agent: 'codex' as const, + filePath: '//wsl.localhost/Ubuntu/rollout.jsonl' + }, + { executionHostId: 'local' as const, agent: 'omp' as const, filePath: '/session.jsonl' } +])('keeps ordinary restrictions for an unsupported owned row: %s', async (overrides) => { + mocks.probe.mockResolvedValue(false) + mocks.owner.mockReturnValue( + overrides.executionHostId.startsWith('ssh:') ? null : overrides.executionHostId + ) + render() + await waitFor(() => + expect(screen.queryByRole('button', { name: 'Switch to native chat' })).toBeNull() + ) + expect(mocks.run).not.toHaveBeenCalled() + expect(mocks.startOwner).not.toHaveBeenCalled() +}) + +it.each(['codex', 'claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'] as const)( + 'keeps ordinary local History resume and owned fork usable for %s', + async (agent) => { + mocks.probe.mockResolvedValue(false) + const session = { ...row, agent, structuredSession: undefined } + const view = render() + const resume = await screen.findByRole('button', { name: 'Resume in New Chat' }) + await userEvent.setup().click(resume) + expect(mocks.newChat).toHaveBeenCalledExactlyOnceWith( + session, + agent, + 'workspace', + expect.any(String) + ) + expect(mocks.run).not.toHaveBeenCalled() + expect(mocks.startOwner).not.toHaveBeenCalled() + view.rerender() + await userEvent + .setup() + .click(await screen.findByRole('button', { name: 'Resume in New Terminal' })) + expect(mocks.fork).toHaveBeenCalledOnce() + expect(screen.queryByRole('button', { name: 'Switch to CLI' })).toBeNull() + } +) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx index 2cc5a6d6f316..7e2971f84e8d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-cli-fork-copy.tsx @@ -30,7 +30,7 @@ export function SwitchToCliTooltipText({ agent }: { agent: AiVaultAgent }): Reac <> {translate( 'components.tab.bar.switch.toCliTooltip', - 'Switch to CLI: stops {{agent}} here and continues the same conversation in the terminal, in this tab.', + 'Switch to CLI: stops {{agent}} in native chat and continues the same conversation in a terminal.', { agent: agentLabel(agent) } )} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts index 446245eaaf09..fdb957dddc24 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-in-place-switch.ts @@ -1,11 +1,13 @@ import { switchIntoOwner } from '../tab-bar/cli-chat-resume-feedback' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { cliConversationRowsShareAgent } from '../../../../shared/structured-agent-cli-conversation-adoption' +import { + cliConversationRowsShareAgent, + adoptsCliConversations +} from '../../../../shared/structured-agent-cli-conversation-adoption' import { useAppStore } from '@/store' import { collectAgentPaneConversations } from '@/lib/ai-vault-tab-title-requests' import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import { adoptsCliConversations } from '../../../../shared/structured-agent-cli-conversation-adoption' import { resolveCliChatResumeSource } from '../tab-bar/cli-chat-resume-source' import { runCliChatResume } from '../tab-bar/cli-chat-resume-run' import { runNativeChatCliSwitch } from '../tab-bar/native-chat-cli-switch-run' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts index d8387ddb90c0..890a137ba7c4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ state: { runtimeStatusByEnvironmentId: new Map() } })) +vi.mock('@/lib/in-place-switch-support', () => ({ inPlaceSwitchCapabilityNow: () => false })) + vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) vi.mock('@/runtime/structured-agent-session-owner', () => ({ resolveStructuredAgentSessionOwner: mocks.owner diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index c07e1e886089..d7f125054eca 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -1,3 +1,4 @@ +import { inPlaceSwitchCapabilityNow } from '@/lib/in-place-switch-support' import { resolveHostCapabilities, workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { structuredAgentSessionLaunchFeasible, @@ -30,9 +31,11 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { targetState: AiVaultSessionResumeTargetState settings: AgentSessionStructuredFeasibilityRequest['settings'] }): AiVaultResumeInChatEligibility { - const targetWorkspaceId = args.resumeState.usesSessionWorktree - ? args.resumeState.worktreeId - : (args.resumeState.worktreeId ?? args.activeWorkspaceId) + const targetWorkspaceId = + args.session.structuredSession?.workspaceId ?? + (args.resumeState.usesSessionWorktree + ? args.resumeState.worktreeId + : (args.resumeState.worktreeId ?? args.activeWorkspaceId)) const targetWorkspacePath = targetWorkspaceId ? resolveAiVaultTargetWorkspacePath(args.targetState, targetWorkspaceId) : null @@ -46,6 +49,10 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { session: args.session, targetWorkspaceId, targetWorkspacePath, + inPlaceSwitchAvailable: + targetOwner !== null && + targetOwner === normalizeExecutionHostId(args.session.executionHostId) && + inPlaceSwitchCapabilityNow(targetOwner, args.session.agent) !== false, structuredRouteAvailable: adoptsCliConversations(args.session.agent) && targetWorkspaceId !== null && diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index 489715d1d880..18053c9ad990 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -78,18 +78,20 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: { * not apply. Reused rather than re-derived: it already encodes host capability, platform refusals * and the WSL/repair refusal, and a second copy of those conditions would drift from it. */ structuredRouteAvailable: boolean + /** Unknown switch capability keeps an eligible disabled action on its existing surface. */ + inPlaceSwitchAvailable?: boolean }): AiVaultResumeInChatEligibility { const { session } = args if (isAntigravityReferenceSession(session) || !adoptsCliConversations(session.agent)) { return { available: false, reason: 'agent' } } - // An already-adopted row reopens its own chat instead; offering a second resume of it would ask - // for a conflict the host would rightly refuse. - if (session.structuredSession) { + const canSwitch = + args.inPlaceSwitchAvailable === true && !isWslStoredAiVaultSessionFile(session.filePath) + if (session.structuredSession && !canSwitch) { return { available: false, reason: 'already-structured' } } if ( - session.executionHostId !== LOCAL_EXECUTION_HOST_ID || + (session.executionHostId !== LOCAL_EXECUTION_HOST_ID && !canSwitch) || isWslStoredAiVaultSessionFile(session.filePath) ) { return { available: false, reason: 'remote' } diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts index 352c58ea95d1..4aa6519eda5a 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-session-resume.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { useInPlaceSwitchRevision } from '@/lib/in-place-switch-support' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -26,6 +27,7 @@ export function useAiVaultPanelSessionResume(args: { settings: AppState['settings'] }) { const { sessions, repos, allWorktrees, workspaceId, resumeTargetState, settings } = args + useInPlaceSwitchRevision() // Folder workspaces own their sessions by cwd too, so they resume and jump like a worktree. const ownerWorkspaces = useAiVaultSessionOwnerWorkspaces(allWorktrees, resumeTargetState) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ @@ -68,12 +70,9 @@ export function useAiVaultPanelSessionResume(args: { // workspace host a PTY" but "will the provider still find this conversation from the workspace we // would run it in". The workspace it targets is the session's own when that is open, because // Claude looks its transcript up under a directory derived from the launch cwd. - const getSessionResumeInChat = useCallback( - (session: AiVaultSession): AiVaultResumeInChatEligibility => - resolveAiVaultHistoryRowResume({ ...getSessionRowResumeArgs(session), settings }) - .resumeInChat, - [getSessionRowResumeArgs, settings] - ) + // Capability updates rerender this hook and refresh memoized row readers. + const getSessionResumeInChat = (session: AiVaultSession): AiVaultResumeInChatEligibility => + resolveAiVaultHistoryRowResume({ ...getSessionRowResumeArgs(session), settings }).resumeInChat return { getSessionWorktreeInfo, getSessionResumeState, diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 25f88ce7c571..52e9ab59f884 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -1,4 +1,7 @@ import { useAppStore } from '@/store' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { getIndexedWorktreeById } from '@/store/worktree-repo-index' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { structuredLaunchFailure } from '@/lib/structured-agent-session-launch-failure' @@ -43,24 +46,7 @@ export async function switchIntoOwner( await activateAiVaultStructuredSession({ structuredSession }, undefined, target) return false } - const switched = await callStructuredAgentSession< - { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } - >(target, 'agentSession.switchToChat', { sessionId: structuredSession.sessionId }).catch( - () => null - ) - if (!switched?.ok) { - const failure = switched - ? structuredLaunchFailure(Object.assign(new Error(switched.refusal.message), switched)) - : undefined - // Never the owner shortcut again: a refused start keeps the terminal and says why. - toast.error( - failure?.code === 'agent_session_operation_invalid' && - failure.details?.reason === 'conversationOpenInTerminal' - ? cliConversationExitGuidance(source.agent, true) - : agentSessionWriteFailureText(failure ?? { kind: 'unconfirmed' }, 'read-history', { - agentName: agentLabel(source.agent) - }) - ) + if (!(await startCliChatReturn(source, structuredSession.sessionId, target))) { return false } const shown = await activateAiVaultStructuredSessionVerified( @@ -68,7 +54,28 @@ export async function switchIntoOwner( undefined, target ) - if (shown === 'activated' && structuredSession.workspaceId === source.workspaceId) { + if (shown !== 'activated') { + return false + } + if (structuredSession.workspaceId !== source.workspaceId) { + const state = useAppStore.getState() + const scope = parseWorkspaceKey(structuredSession.workspaceId) + const folder = + scope?.type === 'folder' + ? state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) + : undefined + const worktree = getIndexedWorktreeById(state.worktreesByRepo, structuredSession.workspaceId) + const workspace = + folder?.name ?? + (worktree ? resolveWorktreeDisplayName(worktree) : structuredSession.workspaceId) + toast.success( + translate('components.tab.bar.switch.openedInWorkspace', 'Opened chat in {{workspace}}.', { + workspace + }) + ) + return false + } + { const state = useAppStore.getState() const tabs = state.unifiedTabsByWorktree[source.workspaceId] ?? [] const chat = tabs.find( @@ -78,15 +85,46 @@ export async function switchIntoOwner( g.tabOrder.includes(source.tabId) ) if (chat && group) { - state.moveUnifiedTabToGroup(chat.id, group.id, { - index: group.tabOrder.filter((id) => id !== chat.id).indexOf(source.tabId) + 1, - activate: true - }) + const order = group.tabOrder.filter((id) => id !== chat.id) + const index = order.indexOf(source.tabId) + 1 + if (chat.groupId === group.id) { + order.splice(index, 0, chat.id) + state.reorderUnifiedTabs(group.id, order) + } else { + state.moveUnifiedTabToGroup(chat.id, group.id, { index, activate: true }) + } } } return shown === 'activated' } +/** Explicit returns await the selected child; ordinary lazy creation remains unchanged. */ +export async function startCliChatReturn( + source: Pick, + sessionId: string, + target: RuntimeClientTarget +): Promise { + const switched = await callStructuredAgentSession< + { ok: true } | { ok: false; refusal: AgentSessionWireRefusal } + >(target, 'agentSession.switchToChat', { sessionId }).catch(() => null) + if (!switched?.ok) { + const failure = switched + ? structuredLaunchFailure(Object.assign(new Error(switched.refusal.message), switched)) + : undefined + // Never the owner shortcut again: a refused start keeps the terminal and says why. + toast.error( + failure?.code === 'agent_session_operation_invalid' && + failure.details?.reason === 'conversationOpenInTerminal' + ? cliConversationExitGuidance(source.agent, true) + : agentSessionWriteFailureText(failure ?? { kind: 'unconfirmed' }, 'read-history', { + agentName: agentLabel(source.agent) + }) + ) + return false + } + return true +} + function toastCliChatResumeFailure(source: CliChatResumeSource, error: unknown): void { const refusal = structuredLaunchFailure(error) if ( diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index db6a95985d69..2d6082eeb6d1 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => { createCapability: vi.fn(), call: vi.fn(), toast: vi.fn(), + success: vi.fn(), activate: vi.fn(async () => true), activateVerified: vi.fn(async () => 'activated'), switchCapability: vi.fn(async () => false), @@ -54,7 +55,7 @@ vi.mock('@/runtime/structured-session-tab-inventory', () => ({ vi.mock('@/lib/structured-agent-session-create-hydration', () => ({ publishStructuredAgentSessionCreateHydration: vi.fn() })) -vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) +vi.mock('sonner', () => ({ toast: { error: mocks.toast, success: mocks.success } })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: mocks.activate, activateAiVaultStructuredSessionVerified: mocks.activateVerified @@ -243,6 +244,89 @@ describe('CLI identity resume through the existing launch owner', () => { expect(tabs().find((tab) => tab.id === chat.id)?.groupId).toBe(source.groupId) }) + it('reorders an owner already in the source group and retains a cross-workspace source', async () => { + const [a, source, b] = seedTabs() + const chat = useAppStore + .getState() + .createUnifiedTab('wt-1', 'agent-session', { entityId: 'owner-chat' }) + useAppStore.getState().reorderUnifiedTabs(source.groupId, [chat.id, a.id, source.id, b.id]) + mocks.switchCapability.mockResolvedValue(true) + mocks.call.mockResolvedValue({ ok: true }) + expect( + await switchIntoOwner( + { ...SOURCE, tabId: source.id }, + { sessionId: 'owner-chat', workspaceId: 'wt-1' }, + { kind: 'local' } + ) + ).toBe(true) + useAppStore.getState().closeUnifiedTab(source.id) + expect(useAppStore.getState().groupsByWorktree['wt-1'][0].tabOrder).toEqual([ + a.id, + chat.id, + b.id + ]) + mocks.activateVerified.mockResolvedValueOnce('unconfirmed') + useAppStore.setState({ + worktreesByRepo: { + repo: [ + makeWorktree({ id: 'another-workspace', repoId: 'repo', displayName: 'Owner workspace' }) + ] + } + }) + const owner = { sessionId: 'owner-chat', workspaceId: 'another-workspace' } + expect(await switchIntoOwner(SOURCE, owner, { kind: 'local' })).toBe(false) + expect(mocks.success).not.toHaveBeenCalled() + expect(await switchIntoOwner(SOURCE, owner, { kind: 'local' })).toBe(false) + expect(mocks.success).toHaveBeenCalledExactlyOnceWith('Opened chat in Owner workspace.') + expect(mocks.rpc.mock.calls.filter(([, method]) => method === 'terminal.close')).toHaveLength(0) + }) + + it.each([true, false, null])( + 'holds a new at-rest receipt until its selected child answers %s', + async (ready) => { + mocks.switchCapability.mockResolvedValue(true) + const start = deferred() + mocks.call.mockImplementation( + async (_target, method, params: StructuredAgentSessionCreateParams) => + method === 'agentSession.switchToChat' + ? start.promise + : { ok: true, value: { sessionId: params.envelope.sessionId, fence: 1 } } + ) + const running = runCliChatResume(SOURCE) + await vi.waitFor(() => + expect( + mocks.call.mock.calls.some(([, method]) => method === 'agentSession.switchToChat') + ).toBe(true) + ) + expect(mocks.rpc.mock.calls.some(([, method]) => method === 'terminal.close')).toBe(false) + start.resolve( + ready === null + ? null + : ready + ? { ok: true } + : { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'conversationOpenElsewhere' }, + message: 'Cannot start' + } + } + ) + await running + expect(mocks.rpc.mock.calls.filter(([, method]) => method === 'terminal.close')).toHaveLength( + ready ? 1 : 0 + ) + const created = mocks.call.mock.calls.find( + ([, method]) => method === 'agentSession.create' + )![2] + expect(created.firstMessage).toBeUndefined() + expect( + mocks.call.mock.calls.find(([, method]) => method === 'agentSession.switchToChat')![2] + ).toEqual({ sessionId: created.envelope.sessionId }) + } + ) + it('retires an unsent click during create capability waiting, even if the same source returns', async () => { const capability = deferred() mocks.createCapability.mockReturnValue(capability.promise) @@ -311,7 +395,7 @@ describe('CLI identity resume through the existing launch owner', () => { }) it('starts the chat that holds the conversation before the terminal closes; a refusal keeps it', async () => { - const owner = { sessionId: 'owner-chat', workspaceId: 'owner-workspace' } + const owner = { sessionId: 'owner-chat', workspaceId: 'wt-1' } let switched: unknown = { ok: false, refusal: { diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index a2f0f005c5ae..a3ddd7601e54 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -13,7 +13,7 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent- import { waitForPromiseWithSignal } from '../../../../shared/abort-signal-reason' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured-agent-cli-conversation-adoption' import { resolveCliChatResumeSource, type CliChatResumeSource } from './cli-chat-resume-source' -import { reportCliChatResumeFailure } from './cli-chat-resume-feedback' +import { reportCliChatResumeFailure, startCliChatReturn } from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' import { panePtyId } from '@/lib/ai-vault-tab-title-requests' import { subscribeToPtyExit } from '../terminal-pane/pty-dispatcher' @@ -247,7 +247,9 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise sourceExited, sourceIncarnation) + if (await startCliChatReturn(source, result.sessionId, target)) { + await retireSwitchedTerminalPane(source, () => sourceExited, sourceIncarnation) + } } else if ( result.kind === 'failed' && !result.notified && diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index 7871e5f81822..d285fc6c84ea 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -1,5 +1,8 @@ import { useAppStore } from '@/store' -import { useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' +import { + useInPlaceSwitchCapability, + inPlaceSwitchSupportedNow +} from '@/lib/in-place-switch-support' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import { @@ -7,7 +10,6 @@ import { panePtyId, resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' -import { inPlaceSwitchSupportedNow } from '@/lib/in-place-switch-support' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { makePaneKey } from '../../../../shared/stable-pane-id' import { diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts index 4649c299bd85..8143855faee3 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts @@ -1,7 +1,9 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTabsSliceMockApi } from '../../store/slices/tabs-slice-test-harness' import { createTestStore } from '../../store/slices/store-test-helpers' +import { applyLocalStructuredSessionTabSnapshots } from '../../runtime/local-structured-session-tabs-sync/snapshot-apply' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => ({ @@ -40,6 +42,8 @@ type CreateRequest = { requestId: string worktreeId: string targetGroupId?: string + afterTabId?: string + source?: 'runtime-session' presentation?: 'focused' | 'background' } @@ -53,6 +57,7 @@ beforeEach(() => { replyTerminalCreate.mockClear() vi.stubGlobal('window', { ...globalThis.window, + dispatchEvent: vi.fn(() => true), api: { ...globalThis.window?.api, ui: { @@ -106,3 +111,45 @@ describe("a host's focused terminal create into a requested group", () => { expect(store.getState().sortEpoch).toBe(epoch + 1) }) }) + +it('uses the source group while another group is active and keeps the slot after early retirement', () => { + const a = store.getState().createTab(WT) + const c = store.getState().createUnifiedTab(WT, 'agent-session', { entityId: 'C' }) + const b = store.getState().createTab(WT) + const left = c.groupId + const right = store.getState().createEmptySplitGroup(WT, left, 'right')! + store.getState().createTab(WT, right) + store.getState().focusGroup(WT, right) + replyTerminalCreate.mockImplementationOnce(() => { + // The admitted host removes presentation through its publication, never a generic close. + store.setState((state) => + applyLocalStructuredSessionTabSnapshots(state, [ + { + worktree: WT, + publicationEpoch: 'headless:bridge-test', + snapshotVersion: 1, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ]) + ) + }) + request({ + requestId: 'early-retire', + worktreeId: WT, + source: 'runtime-session', + targetGroupId: left, + afterTabId: c.id, + presentation: 'background' + }) + const createdId = replyTerminalCreate.mock.calls[0][0].tabId + expect(replyTerminalCreate.mock.calls[0][0]).toMatchObject({ tabId: expect.any(String) }) + expect(store.getState().unifiedTabsByWorktree[WT]?.some((tab) => tab.id === c.id)).toBe(false) + expect( + store.getState().groupsByWorktree[WT]?.find((group) => group.id === left)?.tabOrder + ).toEqual([a.id, createdId, b.id]) + expect( + store.getState().groupsByWorktree[WT]?.find((group) => group.id === right)?.tabOrder + ).toHaveLength(1) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 83882812605c..be65f8a17b21 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19405,7 +19405,8 @@ "switch": { "toCli": "Switch to CLI", "toNativeChat": "Switch to native chat", - "toCliTooltip": "Switch to CLI: stops {{agent}} here and continues the same conversation in the terminal, in this tab.", + "toCliTooltip": "Switch to CLI: stops {{agent}} in native chat and continues the same conversation in a terminal.", + "openedInWorkspace": "Opened chat in {{workspace}}.", "toNativeChatTooltip": "Switch to native chat: continues the same conversation in a chat, in this tab.", "exitAgentFirst": "Exit {{agent}} in this terminal to switch.", "waitForReply": "Wait for the reply to finish or stop it, then switch.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d5c3fd056dc1..fafdc15e6775 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -19078,7 +19078,8 @@ "switch": { "toCli": "Cambiar a la CLI", "toNativeChat": "Cambiar al chat nativo", - "toCliTooltip": "Cambiar a la CLI: detiene {{agent}} aquí y continúa la misma conversación en la terminal, en esta pestaña.", + "toCliTooltip": "Cambiar a la CLI: detiene {{agent}} en el chat nativo y continúa la misma conversación en una terminal.", + "openedInWorkspace": "Chat abierto en {{workspace}}.", "toNativeChatTooltip": "Cambiar al chat nativo: continúa la misma conversación en un chat, en esta pestaña.", "exitAgentFirst": "Salga de {{agent}} en esta terminal para cambiar.", "waitForReply": "Espere a que termine la respuesta o deténgala y luego cambie.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9c0638ee6f64..02a300e92237 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -19187,7 +19187,8 @@ "switch": { "toCli": "Passer à la CLI", "toNativeChat": "Passer au chat natif", - "toCliTooltip": "Passer à la CLI : arrête {{agent}} ici et poursuit la même conversation dans le terminal, dans cet onglet.", + "toCliTooltip": "Passer à la CLI : arrête {{agent}} dans le chat natif et poursuit la même conversation dans un terminal.", + "openedInWorkspace": "Chat ouvert dans {{workspace}}.", "toNativeChatTooltip": "Passer au chat natif : poursuit la même conversation dans un chat, dans cet onglet.", "exitAgentFirst": "Quittez {{agent}} dans ce terminal pour basculer.", "waitForReply": "Attendez la fin de la réponse ou arrêtez-la, puis basculez.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 622113c6c23a..95bd701a0267 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -19118,7 +19118,8 @@ "switch": { "toCli": "CLI に切り替える", "toNativeChat": "ネイティブチャットに切り替える", - "toCliTooltip": "CLI に切り替え: ここで {{agent}} を停止し、このタブのターミナルで同じ会話を続けます。", + "toCliTooltip": "CLI に切り替え: ネイティブチャットで {{agent}} を停止し、ターミナルで同じ会話を続けます。", + "openedInWorkspace": "{{workspace}} でチャットを開きました。", "toNativeChatTooltip": "ネイティブチャットに切り替え: このタブのチャットで同じ会話を続けます。", "exitAgentFirst": "切り替えるには、このターミナルで {{agent}} を終了してください。", "waitForReply": "応答が終わるまで待つか停止してから、切り替えてください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b2e251e1c5cb..e9016c8f1ca3 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19118,7 +19118,8 @@ "switch": { "toCli": "CLI로 전환", "toNativeChat": "네이티브 채팅으로 전환", - "toCliTooltip": "CLI로 전환: 여기서 {{agent}}을(를) 중지하고 이 탭의 터미널에서 같은 대화를 이어갑니다.", + "toCliTooltip": "CLI로 전환: 네이티브 채팅에서 {{agent}}을(를) 중지하고 터미널에서 같은 대화를 이어갑니다.", + "openedInWorkspace": "{{workspace}}에서 채팅을 열었습니다.", "toNativeChatTooltip": "네이티브 채팅으로 전환: 이 탭의 채팅에서 같은 대화를 이어갑니다.", "exitAgentFirst": "전환하려면 이 터미널에서 {{agent}}을(를) 종료하세요.", "waitForReply": "응답이 끝날 때까지 기다리거나 응답을 중지한 후 전환하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 623740bbe122..fe5b23b416dc 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -19083,7 +19083,8 @@ "switch": { "toCli": "切换到 CLI", "toNativeChat": "切换到原生聊天", - "toCliTooltip": "切换到 CLI:在此停止 {{agent}},并在此标签页的终端中继续同一对话。", + "toCliTooltip": "切换到 CLI:停止原生聊天中的 {{agent}},并在终端中继续同一对话。", + "openedInWorkspace": "已在 {{workspace}} 中打开聊天。", "toNativeChatTooltip": "切换到原生聊天:在此标签页的聊天中继续同一对话。", "exitAgentFirst": "请先在此终端中退出 {{agent}},然后再切换。", "waitForReply": "请等待回复完成或停止回复,然后再切换。", diff --git a/src/renderer/src/lib/in-place-switch-support.ts b/src/renderer/src/lib/in-place-switch-support.ts index 8e4f708db5bf..7f30f8449543 100644 --- a/src/renderer/src/lib/in-place-switch-support.ts +++ b/src/renderer/src/lib/in-place-switch-support.ts @@ -14,7 +14,11 @@ import type { AiVaultAgent } from '../../../shared/ai-vault-types' const known = new Map() const asking = new Map>() const listeners = new Set<() => void>() -const changed = () => listeners.forEach((listener) => listener()) +let revision = 0 +const changed = () => { + revision += 1 + listeners.forEach((listener) => listener()) +} const forget = () => { known.clear() asking.clear() @@ -42,6 +46,14 @@ const subscribe = (listener: () => void) => { } } +export function useInPlaceSwitchRevision(): number { + return useSyncExternalStore( + subscribe, + () => revision, + () => 0 + ) +} + /** The cached answer, asking in the background when there is none yet. */ export function inPlaceSwitchCapabilityNow( executionHostId: string | null | undefined, diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 5e8ed72b57fe..b911598e7b02 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -21,6 +21,7 @@ const mockState = { closeEmptyGroup: mockCloseEmptyGroup, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, + unifiedTabsByWorktree: {} as Record, tabsByWorktree: {} as Record, openFiles: [] as { id: string; worktreeId: string }[], browserTabsByWorktree: {} as Record, @@ -66,6 +67,7 @@ describe('launchAiVaultSessionInNewTab', () => { runtimeMocks.runtimeEnvironmentIdWithLegacyFallbackUnlessTwin.mockReturnValue(null) runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(false) runtimeMocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) + mockState.unifiedTabsByWorktree = {} mockState.tabsByWorktree = {} mockState.openFiles = [] mockState.browserTabsByWorktree = {} @@ -233,6 +235,29 @@ describe('launchAiVaultSessionInNewTab', () => { expect(mockCloseEmptyGroup).not.toHaveBeenCalled() }) + it.each([undefined, 'explicit-group'])( + 'derives the source group before the paired launch and preserves explicit target %s', + (targetGroupId) => { + runtimeMocks.runtimeEnvironmentIdWithLegacyFallbackUnlessTwin.mockReturnValue('env-1') + runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(true) + mockState.unifiedTabsByWorktree['wt-1'] = [{ id: 'chat-C', groupId: 'source-group' }] + launchAiVaultSessionInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + afterTabId: 'chat-C', + targetGroupId, + command: 'codex resume session-2' + }) + expect(runtimeMocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + afterTabId: 'chat-C', + targetGroupId: targetGroupId ?? 'source-group' + }), + { notifyFailure: false } + ) + } + ) + it('removes the still-empty split group when the runtime create does not succeed', async () => { runtimeMocks.runtimeEnvironmentIdWithLegacyFallbackUnlessTwin.mockReturnValue('env-1') runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(true) diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index ab279af68d19..804452cd7fe0 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -33,6 +33,10 @@ export function launchAiVaultSessionInNewTab(args: { resumeOrigin?: 'switch' }): LaunchAiVaultSessionInNewTabResult { const store = useAppStore.getState() + const slot = args.afterTabId + ? store.unifiedTabsByWorktree[args.worktreeId]?.find((tab) => tab.id === args.afterTabId) + : undefined + const sourceGroupId = args.targetGroupId ?? slot?.groupId const switchStart = args.resumeOrigin ? { resumeOrigin: args.resumeOrigin } : {} // Why both paths split: the runtime path used to return first and drop the split (#22791). const split = @@ -48,7 +52,7 @@ export function launchAiVaultSessionInNewTab(args: { const splitGroupId = split ? store.createEmptySplitGroup(args.worktreeId, split.sourceGroupId, split.splitDirection) : null - const targetGroupId = splitGroupId ?? args.targetGroupId + const targetGroupId = splitGroupId ?? sourceGroupId const runtimeLaunch = createWebRuntimeSessionTerminal( { worktreeId: args.worktreeId, @@ -81,10 +85,7 @@ export function launchAiVaultSessionInNewTab(args: { return { tabId: null, runtimeLaunch: observedRuntimeLaunch } } - const slot = args.afterTabId - ? store.unifiedTabsByWorktree[args.worktreeId]?.find((tab) => tab.id === args.afterTabId) - : undefined - const tab = store.createTab(args.worktreeId, slot?.groupId ?? args.targetGroupId, undefined, { + const tab = store.createTab(args.worktreeId, sourceGroupId, undefined, { ...(split ? { split } : {}), ...(args.cwd ? { startupCwd: args.cwd } : {}), pendingStartup: { @@ -104,7 +105,7 @@ export function launchAiVaultSessionInNewTab(args: { store.setActiveTabType('terminal', args.worktreeId) persistAgentLaunchTabOrder(args.worktreeId, tab.id) - if (slot) { + if (slot && !split && (!args.targetGroupId || args.targetGroupId === slot.groupId)) { const order = ( useAppStore.getState().groupsByWorktree[args.worktreeId]?.find((g) => g.id === slot.groupId) ?.tabOrder ?? [] diff --git a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts index 5c6dffce2100..ea099a634a97 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-create-operation.ts @@ -43,7 +43,10 @@ import { createdTerminalLeafId, readCreatedAgentTerminalIdentity } from './web-runtime-terminal-identity' -import { settleWebRuntimeTerminalPlacement } from './web-runtime-terminal-placement-settlement' +import { + captureWebRuntimeTerminalPlacement, + settleWebRuntimeTerminalPlacement +} from './web-runtime-terminal-placement-settlement' import { buildPlainCreateTerminalParams } from './web-runtime-plain-terminal-create-params' export async function createWebRuntimeSessionTerminalResult( @@ -79,6 +82,7 @@ export async function createWebRuntimeSessionTerminalResult( } } } + const { captured, placementGroupId } = captureWebRuntimeTerminalPlacement(args) let hostCreated = false let createdTabId: string | undefined let createdLeafId: string | undefined @@ -94,7 +98,7 @@ export async function createWebRuntimeSessionTerminalResult( // Why: structured creation cannot yet express afterTabId; keep the exact legacy placement contract until it can. // Why: focus belongs to the paired client; a headless execution host has no renderer to focus. // Why: rebuilding a prepared command through host authority can discard its embedded prompt and delivery flags. - // A switch keeps its prepared recipe and its claim, which only the prepared create carries. + // A switch uses the ordinary prepared command and admission. const mustUseLegacyAgentCreate = args.preparedAgentCommand || args.afterTabId || args.resumeOrigin === 'switch' const hostAuthority = mustUseLegacyAgentCreate @@ -237,14 +241,14 @@ export async function createWebRuntimeSessionTerminalResult( createdTabId = created.tab.id createdLeafId = created.tab.leafId } - if (args.targetGroupId && createdTabId) { + if (placementGroupId && createdTabId) { // Why: the host drops client-minted group ids, so this client's own record is what // lands the mirrored tab in the requested pane under client-owned placement. recordWebSessionTerminalPlacement({ environmentId, worktreeId: args.worktreeId, hostTabId: webTerminalPlacementParentTabId(createdTabId), - groupId: args.targetGroupId + groupId: placementGroupId }) } if (args.activate !== false && createdTabId && matchesWebSessionIntentOwner(intentOwner)) { @@ -268,8 +272,9 @@ export async function createWebRuntimeSessionTerminalResult( args.worktreeId, webTerminalPlacementParentTabId(placementTabId), { - groupId: args.targetGroupId, + groupId: placementGroupId, afterTabId: args.afterTabId, + captured, activate: args.activate !== false } ) diff --git a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts index d82fdcfefc63..478e2fd15d44 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-placement-settlement.ts @@ -1,3 +1,8 @@ +import { + captureSessionTabPlacement, + placeUnpinnedSessionTabAtCapture, + type CapturedSessionTabPlacement +} from '../../../shared/session-tab-placement' import { insertUnifiedTabAfterAnchor } from '../lib/unified-tab-anchor-insertion' import { useAppStore } from '../store' import { @@ -20,12 +25,37 @@ function anchorUnifiedTabId(worktreeId: string, afterTabId: string): string { : toWebTerminalSurfaceTabId(webTerminalPlacementParentTabId(toHostSessionTabId(afterTabId))) } +/** Capture before creation can retire the anchor on the execution host. */ +export function captureWebRuntimeTerminalPlacement(args: { + worktreeId: string + afterTabId?: string + targetGroupId?: string +}) { + const sourceGroup = useAppStore + .getState() + .groupsByWorktree[args.worktreeId]?.find( + (group) => args.afterTabId && group.tabOrder.includes(args.afterTabId) + ) + return { + placementGroupId: args.targetGroupId ?? sourceGroup?.id, + captured: + !args.targetGroupId || args.targetGroupId === sourceGroup?.id + ? captureSessionTabPlacement(sourceGroup?.tabOrder ?? [], args.afterTabId) + : undefined + } +} + /** Settle the placement once the mirrored tab exists (bounded poll), then consume the record. */ export async function settleWebRuntimeTerminalPlacement( environmentId: string, worktreeId: string, hostTabId: string, - placement: { groupId?: string; afterTabId?: string; activate: boolean } + placement: { + groupId?: string + afterTabId?: string + activate: boolean + captured?: CapturedSessionTabPlacement + } ): Promise { const unifiedTabId = toWebTerminalSurfaceTabId(hostTabId) const findTab = () => @@ -64,9 +94,25 @@ export async function settleWebRuntimeTerminalPlacement( recordInteraction: false }) } - if (anchorId) { - // The create caller owns this insertion; subsequent host snapshots preserve client order. + if (anchorId && targetGroup.tabOrder.includes(anchorId)) { insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, anchorId) + } else if (placement.captured) { + const current = useAppStore.getState() + const group = current.groupsByWorktree[worktreeId]?.find( + (group) => group.id === targetGroup.id + ) + if (group) { + current.reorderUnifiedTabs( + group.id, + placeUnpinnedSessionTabAtCapture( + group.tabOrder, + current.unifiedTabsByWorktree[worktreeId] ?? [], + unifiedTabId, + placement.captured + ), + { recordInteraction: false } + ) + } } } finally { forgetWebSessionTerminalPlacement({ environmentId, worktreeId, hostTabId }) diff --git a/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts new file mode 100644 index 000000000000..a9f407faa3ee --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { createWebRuntimeSessionTerminalResult } from './web-runtime-terminal-create-operation' +import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' +import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' + +const ENV = 'paired-placement', + WT = 'repo::/repo' +const chat = (id: string) => ({ + type: 'agent-session' as const, + id: `agent-session:${id}`, + sessionId: id, + agent: 'codex' as const, + title: id, + isActive: false +}) +let frame: RuntimeMobileSessionTabsResult +beforeEach(() => { + resetWebSessionTabsSnapshotFreshnessForTests() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: WT, + activeWorkspaceExecutionHostId: `runtime:${ENV}`, + settings: { ...useAppStore.getState().settings, activeRuntimeEnvironmentId: ENV } + }) + frame = { + worktree: WT, + publicationEpoch: 'host-placement', + snapshotVersion: 1, + activeGroupId: 'R', + activeTabId: 'agent-session:D', + activeTabType: 'agent-session', + tabs: ['A', 'C', 'B', 'D'].map(chat), + tabGroups: [ + { + id: 'L', + activeTabId: 'agent-session:C', + tabOrder: ['A', 'C', 'B'].map((id) => `agent-session:${id}`) + }, + { id: 'R', activeTabId: 'agent-session:D', tabOrder: ['agent-session:D'] } + ] + } + useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) +}) +it.each([false, true])( + 'keeps client neighbors and pin partitions through early retirement and refresh (pinned: %s)', + async (pinned) => { + if (pinned) { + frame = { + ...frame, + snapshotVersion: frame.snapshotVersion + 1, + tabs: [ + ...frame.tabs.map((tab) => ({ + ...tab, + isPinned: ['agent-session:A', 'agent-session:C', 'agent-session:B'].includes(tab.id) + })), + chat('U') + ], + tabGroups: frame.tabGroups?.map((group) => + group.id === 'L' ? { ...group, tabOrder: [...group.tabOrder, 'agent-session:U'] } : group + ) + } + useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) + } + const before = useAppStore.getState() + const source = before.unifiedTabsByWorktree[WT].find((tab) => tab.entityId === 'C')! + const sourceGroup = source.groupId + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.createTerminal') { + frame = { + ...frame, + snapshotVersion: frame.snapshotVersion + 1, + tabs: [ + ...frame.tabs.filter((tab) => tab.id !== 'agent-session:C'), + { + type: 'terminal', + id: 'T::leaf', + parentTabId: 'T', + leafId: 'leaf', + title: 'Terminal', + terminal: 'term_T', + isActive: false, + status: 'ready' + } + ], + tabGroups: [ + { + id: 'L', + activeTabId: 'T', + tabOrder: [ + 'agent-session:A', + 'agent-session:B', + 'T', + ...(pinned ? ['agent-session:U'] : []) + ] + }, + frame.tabGroups![1] + ] + } + // Publication beats the create response, with C already gone and T at the host's old fallback. + useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) + expect( + useAppStore.getState().unifiedTabsByWorktree[WT].some((tab) => tab.entityId === 'C') + ).toBe(false) + useAppStore.getState().createUnifiedTab(WT, 'editor', { + id: 'concurrent', + entityId: '/repo/concurrent', + targetGroupId: sourceGroup + }) + return { id: 'create', ok: true as const, result: { tab: frame.tabs.at(-1) } } + } + if (method === 'session.tabs.list') { + return { id: 'list', ok: true as const, result: frame } + } + throw new Error(`Unexpected ${method}`) + }) + Object.assign(window, { + api: { + ...window.api, + runtimeEnvironments: { ...window.api?.runtimeEnvironments, call: runtimeCall } + } + }) + const result = await createWebRuntimeSessionTerminalResult({ + environmentId: ENV, + worktreeId: WT, + afterTabId: source.id, + targetGroupId: sourceGroup, + launchAgent: 'codex', + command: 'codex resume conversation', + resumeOrigin: 'switch', + providerSession: { key: 'session_id', id: 'conversation' }, + selectWorktree: false, + activate: true + }) + expect(result.outcome.status).toBe('created') + const order = () => + useAppStore.getState().groupsByWorktree[WT].find((group) => group.id === sourceGroup)! + .tabOrder + expect(order()).toEqual([ + 'structured-agent-session-A', + ...(pinned ? ['structured-agent-session-B'] : []), + toWebTerminalSurfaceTabId('T'), + ...(pinned ? ['structured-agent-session-U'] : ['structured-agent-session-B']), + 'concurrent' + ]) + expect(useAppStore.getState().activeGroupIdByWorktree[WT]).toBe(sourceGroup) + await refreshWebRuntimeSessionTabsSnapshot(ENV, WT, { acceptCurrentSnapshot: true }) + expect(order()).toEqual([ + 'structured-agent-session-A', + ...(pinned ? ['structured-agent-session-B'] : []), + toWebTerminalSurfaceTabId('T'), + ...(pinned ? ['structured-agent-session-U'] : ['structured-agent-session-B']), + 'concurrent' + ]) + } +) diff --git a/src/shared/session-tab-placement.ts b/src/shared/session-tab-placement.ts index 1b4729a6156b..f1bbcbec9dcb 100644 --- a/src/shared/session-tab-placement.ts +++ b/src/shared/session-tab-placement.ts @@ -1,5 +1,57 @@ +import { insertTabIdIntoOrder, partitionPinnedTabOrder } from './workspace-layout/tab-order' + +/** Call-local neighbors survive an anchor retiring while creation is awaited. */ +export type CapturedSessionTabPlacement = { preceding: string[]; following: string[] } + +export function captureSessionTabPlacement( + order: readonly string[], + afterTabId: string | null | undefined +): CapturedSessionTabPlacement | undefined { + const index = afterTabId ? order.indexOf(afterTabId) : -1 + return index < 0 + ? undefined + : { + preceding: order.slice(0, index + 1), + following: order.slice(index + 1) + } +} + +/** Apply only insertion to current order, keeping unrelated concurrent edits. */ +export function placeSessionTabAtCapture( + order: readonly string[], + createdId: string, + captured: CapturedSessionTabPlacement +): string[] { + const next = order.filter((id) => id !== createdId) + const previous = captured.preceding.toReversed().find((id) => next.includes(id)) + const following = captured.following.find((id) => next.includes(id)) + const index = + following !== undefined + ? next.indexOf(following) + : previous !== undefined + ? next.indexOf(previous) + 1 + : Math.min(captured.preceding.length, next.length) + next.splice(index, 0, createdId) + return next +} + +/** Captured placement uses the same pin partitions as an ordinary tab insertion. */ +export function placeUnpinnedSessionTabAtCapture( + order: readonly string[], + tabs: readonly { id: string; isPinned?: boolean }[], + createdId: string, + captured: CapturedSessionTabPlacement +): string[] { + const placed = placeSessionTabAtCapture(order, createdId, captured) + const preceding = placed[placed.indexOf(createdId) - 1] + return preceding + ? insertTabIdIntoOrder(placed, tabs, createdId, false, preceding) + : partitionPinnedTabOrder(placed, tabs, createdId) +} + export type SessionTabPlacementOptions = { afterParentGroup?: boolean + captured?: CapturedSessionTabPlacement } /** Places a created tab after the anchor, or after its parent group when enabled. */ @@ -12,6 +64,18 @@ export function placeCreatedSessionTab tab.id !== created.id) const anchor = afterTabId ? next.findIndex((tab) => tab.id === afterTabId) : -1 if (anchor < 0) { + if (options.captured) { + const order = placeSessionTabAtCapture( + next.map((tab) => tab.id), + created.id, + options.captured + ) + const byId = new Map([...next, created].map((tab) => [tab.id, tab])) + return order.flatMap((id) => { + const tab = byId.get(id) + return tab ? [tab] : [] + }) + } next.push(created) return next } From fdb0ddfeac896ff1be283cb663e382e7a682a1cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:48:07 -0700 Subject: [PATCH 030/207] Align switch resume contracts and checked test fixtures --- .../structured-session-ownership.test.ts | 5 +++-- src/main/ipc/pty/runtime/spawn-state.ts | 2 +- ...structured-agent-session-switch-to-chat.ts | 3 ++- ...nt-session-terminal-writer-acquire.test.ts | 9 ++++---- .../ai-vault-history-native-return.test.tsx | 22 ++++++++++++------- ...minal-request-ipc-bridge-placement.test.ts | 1 + .../src/lib/launch-ai-vault-session.test.ts | 3 ++- ...-runtime-terminal-switch-placement.test.ts | 6 ++++- 8 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index 4b9ea5e46296..fa2f00b4f3fa 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -9,6 +9,7 @@ import { projectStructuredAiVaultSessions } from './structured-session-ownership' import type { AiVaultSearchHit, AiVaultSearchResponse } from '../../shared/ai-vault-search-types' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { codexChatHolding, installChats, @@ -117,8 +118,8 @@ describe('structured AI Vault ownership', () => { return projectStructuredAiVaultSessions(listResult(), OPENS_EVERY_STRUCTURED_CHAT).sessions[0] ?.structuredSession?.sessionId } - const resume = { - agent: 'codex' as const, + const resume: AiVaultPrepareSessionResumeArgs = { + agent: 'codex', filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, codexHome: null, executionHostId: 'local' diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index 90c5a3d6a18d..5ca761ba57ab 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -115,7 +115,7 @@ export type RuntimePtySpawnArgs = { envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata /** `saved`: built from a pane's saved facts, which the host refuses for a chat's conversation. */ - resumeOrigin?: 'saved' + resumeOrigin?: 'saved' | 'switch' connectionId?: string | null worktreeId?: string preAllocatedHandle?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts index 810cc60b66f9..7a7c9711c660 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-switch-to-chat.ts @@ -54,7 +54,8 @@ export async function startStructuredAgentSessionForSwitch( if (!selection.ok || !selection.child) { return selection.ok ? operationDidNotRun() : selection } - ;({ child, readiness } = selection) + child = selection.child + readiness = selection.readiness } const outcome = (await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS)) ?? 'ready' const awaited = child diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index cf6af4ff7901..87d58ca66087 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -47,6 +47,7 @@ const acquire = vi.fn(async ({ fence, const open = vi.fn(async () => ({ reportedOptions: { model: '' } })) +const closeSession = vi.fn>() const assertNoTerminalWriter = vi.fn(async () => { if (terminalWriting) { throw agentSessionRefusalError('agent_session_conflict', { @@ -61,6 +62,7 @@ beforeEach(async () => { terminalWriting = false acquire.mockClear() open.mockClear() + closeSession.mockReset().mockResolvedValue(true) assertNoTerminalWriter.mockClear() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ @@ -73,7 +75,7 @@ beforeEach(async () => { state: 'accepted' as const, providerIdentity: { provider: 'codex' as const, threadId: THREAD, turnId: 't', ordinal: 1 } })), - closeSession: vi.fn(async () => true), + closeSession, releaseAcquisition: vi.fn(async () => true), cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), @@ -151,7 +153,6 @@ it('refuses the chat agent start while a terminal writes its conversation, once // The switch is idle-only and proves the stop; subsequent sends use ordinary G-acquire. it('switches an idle chat to its terminal and a racing send starts no second writer', async () => { const order: string[] = [] - const closeSession = vi.mocked(host.deps.adapter.closeSession) closeSession.mockImplementation(async () => { order.push('stop') return true @@ -240,12 +241,12 @@ it('retires only after an explicit admitted launch without stopping a later nati } expect(await host.switchToChat(SESSION)).toEqual({ ok: true }) const laterChild = host.collaboratorsForTests().sessions.get(SESSION)?.child - const closes = vi.mocked(host.deps.adapter.closeSession).mock.calls.length + const closes = closeSession.mock.calls.length await retireChatForAdmittedTerminalLaunch(runtime, args, { savedResumeDecision: decision }) expect(store.getVisibleSessionTabIndex()?.sessionIds).not.toContain(SESSION) expect(runtime.retireStructuredAgentSessionTabFromSnapshot).toHaveBeenCalledWith(SESSION) expect(host.collaboratorsForTests().sessions.get(SESSION)?.child).toBe(laterChild) - expect(host.deps.adapter.closeSession).toHaveBeenCalledTimes(closes) + expect(closeSession).toHaveBeenCalledTimes(closes) }) it.each(['ready', 'failed', 'closed', 'replaced'] as const)( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx index 5630ec11d3e9..d88e429ccfb1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx @@ -5,6 +5,8 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { refreshLocalRuntimeCapabilities, setLocalRuntimeCapabilitiesForTests @@ -17,7 +19,7 @@ import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-action const mocks = vi.hoisted(() => ({ probe: vi.fn<() => Promise>(), - owner: vi.fn(() => 'local'), + owner: vi.fn<() => string | null>(() => 'local'), newChat: vi.fn(), fork: vi.fn(), startOwner: vi.fn(async () => false), @@ -98,7 +100,7 @@ function History({ session }: { session: AiVaultSession }) { allWorktrees: [], workspaceId: 'workspace', resumeTargetState: targetState, - settings: { ...state.settings, experimentalNativeChat: true } + settings: createGlobalSettingsFixture({ ...state.settings, experimentalNativeChat: true }) }) const actions = useAiVaultSessionLaunchActions({ activeWorktree: null, @@ -162,14 +164,20 @@ beforeEach(async () => { }) afterEach(cleanup) +function pairedHistoryStatus() { + const response = createCompatibleRuntimeStatusResponse('paired') + if (!response.ok) { + throw new Error('Expected status fixture') + } + return { status: { ...response.result, capabilities: [HISTORY] }, checkedAt: 1 } +} + it.each(['local', 'runtime:paired'] as const)( 'composes the owned %s row/details/menu and refreshes unknown capability', async (host) => { mocks.owner.mockReturnValue(host) useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - ['paired', { status: { capabilities: [HISTORY] }, checkedAt: 1 }] - ]) + runtimeStatusByEnvironmentId: new Map([['paired', pairedHistoryStatus()]]) }) let answer = (_value: boolean) => {} mocks.probe.mockImplementationOnce( @@ -206,9 +214,7 @@ it.each(['local', 'runtime:paired'] as const)( it('routes a capable paired unowned row with a matching terminal through the same return', async () => { mocks.owner.mockReturnValue('runtime:paired') useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - ['paired', { status: { capabilities: [HISTORY] }, checkedAt: 1 }] - ]) + runtimeStatusByEnvironmentId: new Map([['paired', pairedHistoryStatus()]]) }) const session = { ...row, diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts index 8143855faee3..f67d05bdbff1 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts @@ -128,6 +128,7 @@ it('uses the source group while another group is active and keeps the slot after worktree: WT, publicationEpoch: 'headless:bridge-test', snapshotVersion: 1, + activeGroupId: right, activeTabId: null, activeTabType: null, tabs: [] diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index b911598e7b02..c1135bd4d70b 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -15,13 +15,14 @@ const runtimeMocks = vi.hoisted(() => ({ isWebRuntimeSessionActive: vi.fn(() => false) })) +const unifiedTabsByWorktree: Record = {} const mockState = { createTab: mockCreateTab, createEmptySplitGroup: mockCreateEmptySplitGroup, closeEmptyGroup: mockCloseEmptyGroup, setActiveTabType: mockSetActiveTabType, setTabBarOrder: mockSetTabBarOrder, - unifiedTabsByWorktree: {} as Record, + unifiedTabsByWorktree, tabsByWorktree: {} as Record, openFiles: [] as { id: string; worktreeId: string }[], browserTabsByWorktree: {} as Record, diff --git a/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts index a9f407faa3ee..a3346b1c9b03 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts @@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' import { createWebRuntimeSessionTerminalResult } from './web-runtime-terminal-create-operation' import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' @@ -25,7 +26,10 @@ beforeEach(() => { useAppStore.setState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: `runtime:${ENV}`, - settings: { ...useAppStore.getState().settings, activeRuntimeEnvironmentId: ENV } + settings: createGlobalSettingsFixture({ + ...useAppStore.getState().settings, + activeRuntimeEnvironmentId: ENV + }) }) frame = { worktree: WT, From ce282572b215b19d302dd052d925cb06a0b6a843 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:04:48 -0700 Subject: [PATCH 031/207] Exercise both conversation switch methods in the wire contract --- ...ss-version-agent-session-wire.unit.test.ts | 4 +- .../structured-agent-session-host-fixture.ts | 62 +++++- .../structured-agent-session-surface-calls.ts | 12 ++ ...uctured-agent-session-surface-execution.ts | 191 ++++++++++++------ ...ructured-agent-session-surface-manifest.ts | 2 + 5 files changed, 211 insertions(+), 60 deletions(-) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 99cc52bd9a3c..8005d3d68b9f 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -39,7 +39,7 @@ import { resolveBaselineReleaseRef } from './release-checkout' import { callBuild, expectDeclaredSurfaceExecutes, - expectItemDetailNegotiation, + expectItemDetailAndSwitchNegotiation, runtimeStub } from './structured-agent-session-surface-execution' import { @@ -301,7 +301,7 @@ describe('cross-version structured agent sessions', () => { build.capabilities.includes(AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY) ).toBe(build.methodNames.includes(CONVERSATION_OUTLINE_METHOD)) } - expectItemDetailNegotiation([current, baseline]) + expectItemDetailAndSwitchNegotiation([current, baseline]) // Additive surface: bumping the protocol number would strand every paired // device on this release rather than degrade one feature. expect(current.protocolVersion).toBe(baseline.protocolVersion) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 2e6951632c75..084c5f13665f 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -1,4 +1,13 @@ import { vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + agentSessionRecordFixture, + agentSessionLeaseFixture +} from '../../../src/shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../../src/shared/agent-session-record' +import type { AiVaultChatForkSource } from '../../../src/shared/ai-vault-resume-preparation' import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' @@ -19,6 +28,9 @@ export function structuredHostStub( workspaceId: string ): Record> { return { + getRecord: vi.fn<(sessionId: string) => AgentSessionRecord | null>(() => null), + switchToTerminal: vi.fn(async () => undefined), + switchToChat: vi.fn(async () => ({ ok: true })), // The restart-resume surface hangs off a host MEMBER rather than the root, but its spies stay // flat here: callers iterate this map asserting every entry is a spy that did not run, and // `installableHost` below is what reassembles the member. Keeping them flat also lets the @@ -127,7 +139,7 @@ export function installableHost( // The catalog read checks the session's record for a floating chat's own folder; none here. deps: { modelCatalog: { read: hostCalls.modelCatalog }, - store: { getRecord: () => null, getOperationRow: () => null } + store: { getRecord: hostCalls.getRecord, getOperationRow: () => null } }, restartResume: { list: hostCalls.restartResumableList, @@ -142,6 +154,54 @@ export function installableHost( return host as unknown as StructuredAgentSessionHost } +/** A saved conversation for the real switch handler's account and transcript checks. */ +export async function structuredSwitchSource( + sessionId: string, + workspaceId: string, + thread: string +) { + const root = await mkdtemp(join(tmpdir(), 'orca-cross-version-switch-')) + const codexHome = join(root, 'codex-home') + const sessions = join(codexHome, 'sessions') + const filePath = join(sessions, `rollout-${thread}.jsonl`) + await mkdir(sessions, { recursive: true }) + await writeFile( + filePath, + `${JSON.stringify({ type: 'session_meta', payload: { id: thread, cwd: root } })}\n` + ) + const record: AgentSessionRecord = { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })), + provider: 'codex', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId, + workspaceKind: 'git-worktree' + }, + accountHome: { variable: 'CODEX_HOME', path: codexHome }, + launchDirectory: root, + providerHandleChain: [ + { + linkId: 'link-1', + origin: 'created', + mintedAtFence: 7, + observedAt: 1_000, + handle: codexProviderHandle(thread) + } + ] + } + const source: AiVaultChatForkSource = { + agent: 'codex', + sourceAgent: 'codex', + sessionId: thread, + cwd: root, + codexHome, + filePath, + executionHostPlatform: process.platform + } + return { record, source, dispose: () => rm(root, { recursive: true, force: true }) } +} + const TURN = { turnId: 'turn-1', state: 'completed' as const, startedAt: 1, completedAt: 6 } const TURN_ROW = { itemId: 'legacy:codex:s:turn-1', revision: 1, sequence: 1, observedAt: 1 } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index ed257aa92f55..1a1c0babdbe4 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -14,6 +14,8 @@ export const CONVERSATION_OUTLINE_METHOD = 'agentSession.conversationOutline' export const ITEM_DETAIL_METHOD = 'agentSession.item' export const STATUS_FEED_METHOD = 'agentSession.subscribeStatus' export const TURN_COMPLETION_FEED_METHOD = 'agentSession.subscribeTurnCompletions' +export const SWITCH_TO_TERMINAL_METHOD = 'agentSession.switchToTerminal' +export const SWITCH_TO_CHAT_METHOD = 'agentSession.switchToChat' /** Every method the structured surface publishes: the host method it must reach, * and the result it must hand back. A gate that hides one method and leaks @@ -228,5 +230,15 @@ export const STRUCTURED_CALLS: { method: 'agentSession.subscribeAvailability', hostMethod: null, result: { type: 'availability', agents: [] } + }, + { + method: SWITCH_TO_TERMINAL_METHOD, + hostMethod: 'switchToTerminal', + result: { source: { agent: 'codex', sourceAgent: 'codex', sessionId: THREAD } } + }, + { + method: SWITCH_TO_CHAT_METHOD, + hostMethod: 'switchToChat', + result: { ok: true, sessionId: SESSION } } ] diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 92cb0939c129..b0203a17d198 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -2,10 +2,20 @@ import { expect, vi } from 'vitest' import { AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' +import { inPlaceSwitchCapability } from '../../../src/shared/structured-agent-cli-conversation-adoption' +import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key' +import type { AgentJournalSnapshot } from '../../../src/shared/agent-session-journal-types' +import type { AiVaultSessionResumePreparation } from '../../../src/shared/ai-vault-resume-preparation' +import { structuredSwitchSource } from './structured-agent-session-host-fixture' import { attachParams, ATTENTION_READ, paramsFor, + SESSION, + WORKSPACE, + THREAD, + SWITCH_TO_TERMINAL_METHOD, + SWITCH_TO_CHAT_METHOD, STRUCTURED_CALLS } from './structured-agent-session-surface-manifest' import type { @@ -77,74 +87,141 @@ export async function expectDeclaredSurfaceExecutes( clientCapabilities: readonly string[] ): Promise { const retirement = vi.fn() - const runtime = runtimeStub({ retireStructuredAttention: retirement }) - for (const { method, hostMethod, result, clientCapability } of STRUCTURED_CALLS) { - const atRestCreate = - method === 'agentSession.create' && - build.capabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) && - clientCapabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) - const expectedHostMethod = atRestCreate ? 'create' : hostMethod - const otherCreateMethod = - method === 'agentSession.create' ? (atRestCreate ? 'attach' : 'create') : null - // Two methods share one host method, so "has been called" would already be - // true from the earlier one: only this call's own delta pins the pairing. - const before = expectedHostMethod ? hostCalls[expectedHostMethod].mock.calls.length : 0 - const otherBefore = otherCreateMethod ? hostCalls[otherCreateMethod].mock.calls.length : 0 - const replies = await callBuild( - build, - method, - paramsFor(method), - { clientKind: 'runtime', clientCapabilities }, - runtime - ) - if (clientCapability && !clientCapabilities.includes(clientCapability)) { - expect(replies).toMatchObject([ - { - ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } + const saved = await structuredSwitchSource(SESSION, WORKSPACE, THREAD) + const preparation = vi.fn(async () => ({ + useRealCodexHome: false + })) + const runtime = runtimeStub({ + retireStructuredAttention: retirement, + prepareAiVaultSessionResume: preparation + }) + try { + for (const { method, hostMethod, result, clientCapability } of STRUCTURED_CALLS) { + const switching = method === SWITCH_TO_TERMINAL_METHOD || method === SWITCH_TO_CHAT_METHOD + hostCalls.getRecord.mockImplementation((sessionId: string) => + switching && sessionId === SESSION ? saved.record : null + ) + if (method === SWITCH_TO_TERMINAL_METHOD) { + const snapshot: AgentJournalSnapshot = { + sessionId: SESSION, + cursor: { epoch: 'epoch-a', sequence: 1 }, + submissions: [], + items: [ + { + itemId: agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: 'turn-1', + ordinal: 0 + }), + revision: 1, + sequence: 1, + observedAt: 1_000, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Saved prompt' }] + } + } + ] + } + hostCalls.journalSnapshot.mockReturnValueOnce(snapshot) + } + const atRestCreate = + method === 'agentSession.create' && + build.capabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) && + clientCapabilities.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) + const expectedHostMethod = atRestCreate ? 'create' : hostMethod + const otherCreateMethod = + method === 'agentSession.create' ? (atRestCreate ? 'attach' : 'create') : null + // Two methods share one host method, so "has been called" would already be + // true from the earlier one: only this call's own delta pins the pairing. + const before = expectedHostMethod ? hostCalls[expectedHostMethod].mock.calls.length : 0 + const otherBefore = otherCreateMethod ? hostCalls[otherCreateMethod].mock.calls.length : 0 + const replies = await callBuild( + build, + method, + paramsFor(method), + { clientKind: 'runtime', clientCapabilities }, + runtime + ) + if (clientCapability && !clientCapabilities.includes(clientCapability)) { + expect(replies).toMatchObject([ + { + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + } + ]) + if (expectedHostMethod) { + expect(hostCalls[expectedHostMethod].mock.calls.length).toBe(before) } - ]) + continue + } if (expectedHostMethod) { - expect(hostCalls[expectedHostMethod].mock.calls.length).toBe(before) + expect( + hostCalls[expectedHostMethod].mock.calls.length - before, + `${build.label}: ${method} did not reach the host: ${JSON.stringify(replies)}` + ).toBe(1) + } + if (otherCreateMethod) { + expect(hostCalls[otherCreateMethod].mock.calls.length - otherBefore).toBe(0) + } + if (method === 'agentSession.acknowledgeAttention') { + expect(retirement).toHaveBeenCalledExactlyOnceWith(ATTENTION_READ) + } + if (method === SWITCH_TO_CHAT_METHOD) { + expect(hostCalls.switchToChat).toHaveBeenLastCalledWith(SESSION) + } + if (method === SWITCH_TO_TERMINAL_METHOD) { + expect(hostCalls.switchToTerminal).toHaveBeenLastCalledWith(SESSION) + expect(preparation).toHaveBeenCalledExactlyOnceWith({ + agent: 'codex', + sessionId: THREAD, + filePath: saved.source.filePath, + codexHome: saved.source.codexHome, + executionHostId: 'local' + }) + expect(preparation.mock.invocationCallOrder[0]).toBeLessThan( + hostCalls.switchToTerminal.mock.invocationCallOrder[0] + ) + expect(replies[0]).toMatchObject({ ok: true, result: { source: saved.source } }) + } + for (const reply of replies) { + expect( + reply, + `${build.label}: ${method} was refused: ${JSON.stringify(reply)}` + ).toMatchObject({ ok: true }) + } + if (result) { + // The declared answer, not merely a non-refusal: a handler that is + // registered and returns an execution error, or hands back someone else's + // envelope, fails here rather than passing as "reached the host". + expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) + expect(replies[0], `${build.label}: ${method} answered off-contract`).toMatchObject({ + ok: true, + result + }) } - continue - } - if (expectedHostMethod) { - expect( - hostCalls[expectedHostMethod].mock.calls.length - before, - `${build.label}: ${method} did not reach the host: ${JSON.stringify(replies)}` - ).toBe(1) - } - if (otherCreateMethod) { - expect(hostCalls[otherCreateMethod].mock.calls.length - otherBefore).toBe(0) - } - if (method === 'agentSession.acknowledgeAttention') { - expect(retirement).toHaveBeenCalledExactlyOnceWith(ATTENTION_READ) - } - for (const reply of replies) { - expect( - reply, - `${build.label}: ${method} was refused: ${JSON.stringify(reply)}` - ).toMatchObject({ ok: true }) - } - if (result) { - // The declared answer, not merely a non-refusal: a handler that is - // registered and returns an execution error, or hands back someone else's - // envelope, fails here rather than passing as "reached the host". - expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) - expect(replies[0], `${build.label}: ${method} answered off-contract`).toMatchObject({ - ok: true, - result - }) } + } finally { + hostCalls.getRecord.mockReturnValue(null) + await saved.dispose() } } -export function expectItemDetailNegotiation(builds: readonly AgentSessionWireBuild[]): void { +export function expectItemDetailAndSwitchNegotiation( + builds: readonly AgentSessionWireBuild[] +): void { expect(builds[0]?.capabilities).toContain(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY) + expect(builds[0]?.capabilities).toContain(inPlaceSwitchCapability('codex')) for (const build of builds) { expect(build.capabilities.includes(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY)).toBe( build.methodNames.includes('agentSession.item') ) + for (const method of [SWITCH_TO_TERMINAL_METHOD, SWITCH_TO_CHAT_METHOD]) { + expect(build.capabilities.includes(inPlaceSwitchCapability('codex'))).toBe( + build.methodNames.includes(method) + ) + } } } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 5cfaefd5605f..e2c78db34e67 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -37,6 +37,8 @@ export { ITEM_DETAIL_METHOD, STATUS_FEED_METHOD, TURN_COMPLETION_FEED_METHOD, + SWITCH_TO_TERMINAL_METHOD, + SWITCH_TO_CHAT_METHOD, STRUCTURED_CALLS } from './structured-agent-session-surface-calls' From f9cb99da7ec60619cdb03a5dbe071043603f776f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:09:44 -0700 Subject: [PATCH 032/207] Complete existing runtime and History test fixtures --- config/scripts/vitest-sqlite-database-runtime-files.mjs | 1 + src/main/runtime/rpc/runtime-close-attribution.test.ts | 4 +++- .../right-sidebar/AiVaultPanel.legacy-filter.test.tsx | 3 ++- .../AiVaultSessionRow.structured-title.test.tsx | 9 +++++++-- .../src/runtime/web-runtime-session-test-harness.ts | 2 ++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 9af339e9cf7f..c260150f1206 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -55,6 +55,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/orchestration/coordinator-escalation-triage.test.ts', 'src/main/runtime/orchestration/coordinator-terminal-census-unavailable.test.ts', 'src/main/runtime/orchestration/coordinator.test.ts', + 'src/main/runtime/orchestration/chat-assignee.test.ts', 'src/main/runtime/orchestration/db-empty-dispatch-shortcircuit.benchmark.test.ts', 'src/main/runtime/orchestration/db-heartbeat-straggler-guard.test.ts', 'src/main/runtime/orchestration/db-message-timestamp.test.ts', diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts index 12290423a750..bf8ba2f50551 100644 --- a/src/main/runtime/rpc/runtime-close-attribution.test.ts +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -88,7 +88,9 @@ describe('runtime close attribution', () => { ) expect(JSON.parse(replies[0]!)).toMatchObject({ ok: true }) - expect(close).toHaveBeenCalledWith('term-1') + expect(close).toHaveBeenCalledWith( + ...(method === 'terminal.close' ? ['term-1', undefined] : ['term-1']) + ) expect(sink.records).toEqual([ expect.objectContaining({ type: 'effect-span', diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx index b4e6b09539e2..3476093dec44 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -26,7 +26,8 @@ const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boo vi.mock('@/store', () => ({ useAppStore: Object.assign((select: (state: typeof mockState) => unknown) => select(mockState), { - getState: () => ({ ...mockState, updateSettingsOrThrow, markFeatureTipsSeen: vi.fn() }) + getState: () => ({ ...mockState, updateSettingsOrThrow, markFeatureTipsSeen: vi.fn() }), + subscribe: vi.fn(() => vi.fn()) }) })) vi.mock('@/store/selectors', () => ({ diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx index f8507970523d..839c779daa7e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.structured-title.test.tsx @@ -8,9 +8,14 @@ import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { Tab } from '../../../../shared/tab-types' -const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), supports: vi.fn() })) +const mocks = vi.hoisted(() => ({ + subscribe: vi.fn(), + supports: vi.fn(), + supportsInPlaceSwitch: vi.fn(async () => false) +})) vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSessionStatus: mocks.subscribe + subscribeStructuredAgentSessionStatus: mocks.subscribe, + supportsInPlaceSwitch: mocks.supportsInPlaceSwitch })) vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/renderer/src/runtime/web-runtime-session-test-harness.ts b/src/renderer/src/runtime/web-runtime-session-test-harness.ts index 1fb481cfb752..0c973664a39a 100644 --- a/src/renderer/src/runtime/web-runtime-session-test-harness.ts +++ b/src/renderer/src/runtime/web-runtime-session-test-harness.ts @@ -194,6 +194,8 @@ export function stubTerminalCreateEnvironment(mocks: WebRuntimeSessionMocks): vo }, activeWorktreeId: WORKTREE_ID, tabsByWorktree: {}, + groupsByWorktree: {}, + unifiedTabsByWorktree: {}, terminalLayoutsByTabId: {}, activeTabIdByWorktree: {}, activeGroupIdByWorktree: {}, From 5d4185ebe913eeb8c50adc4d01db6e985706bfe9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:29:48 -0700 Subject: [PATCH 033/207] Fix terminal create type contracts --- src/main/runtime/runtime-pty-controller-contract.ts | 2 ++ src/main/runtime/terminal/launch/terminal-create.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 50f8691c2e1d..b31955730728 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -23,6 +23,7 @@ import type { } from '../providers/pty-provider-contract' import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' +import type { StartupCwdFallback } from '../../shared/startup-cwd-fallback' import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type PtyInventoryRefreshOptions = { @@ -116,6 +117,7 @@ export type RuntimePtyController = { }): Promise< PtySpawnResult & { stablePaneOwner?: { handle: string; tabId: string; leafId: string } + startupCwdFallback?: StartupCwdFallback savedStartupCwd?: string } > diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index f4d65fea9ae7..7382082c410a 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -274,7 +274,7 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi cwd: savedStartupCwd, launchOpts: { ...launchOpts, - title: pty?.title ?? launch.effectiveTitle ?? null, + title: pty?.title ?? launch.effectiveTitle ?? undefined, launchAgent: pty?.launchAgent ?? undefined, viewMode: launch.effectiveViewMode }, From 1a37f704316f2023c05039d50555131f764a4f0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:31:31 -0700 Subject: [PATCH 034/207] Align integrated terminal types and workspace readers --- src/main/ipc/pty/runtime/kill.ts | 2 +- src/main/runtime/runtime-pty-controller-contract.ts | 2 ++ .../runtime/terminal/launch/terminal-create-reveal.ts | 3 ++- .../src/components/tab-bar/cli-chat-resume-feedback.ts | 10 ++++++++-- src/renderer/src/lib/launch-ai-vault-session.test.ts | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 6744758a679e..0f9d63a63ecb 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -215,7 +215,7 @@ export async function stopAndWaitPtyFromRuntimeController( async function stopAndWaitPty( deps: PtyRuntimeControllerDeps, ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: Parameters[2] ): Promise { const { runtime, diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 8972afc0a7ca..dbb3bcdae629 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -24,6 +24,7 @@ import type { import type { PtyProcessInspection } from '../providers/pty-process-inspection' import type { WriteSettlement } from '../../shared/pty-write-settlement' import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import type { StartupCwdFallback } from '../../shared/startup-cwd-fallback' export type PtyInventoryRefreshOptions = { includeForegroundProcessEvidence?: boolean @@ -117,6 +118,7 @@ export type RuntimePtyController = { PtySpawnResult & { stablePaneOwner?: { handle: string; tabId: string; leafId: string } savedStartupCwd?: string + startupCwdFallback?: StartupCwdFallback } > /** The local terminal owner's automatic-restore state; null when it has no fork gate. */ diff --git a/src/main/runtime/terminal/launch/terminal-create-reveal.ts b/src/main/runtime/terminal/launch/terminal-create-reveal.ts index 825e62dbb99b..8b997f021e54 100644 --- a/src/main/runtime/terminal/launch/terminal-create-reveal.ts +++ b/src/main/runtime/terminal/launch/terminal-create-reveal.ts @@ -9,7 +9,8 @@ export function revealCreatedTerminal(args: { ptyId: string handle: string cwd: string - launchOpts: dependencies.TerminalCreateOptions + launchOpts: Omit & + Pick effectiveLaunchConfig: dependencies.TerminalCreateOptions['launchConfig'] launchToken: string | undefined presentation: RuntimeTerminalPresentation | undefined diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 52e9ab59f884..13a300b1e921 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getIndexedWorktreeById } from '@/store/worktree-repo-index' +import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' @@ -62,9 +63,14 @@ export async function switchIntoOwner( const scope = parseWorkspaceKey(structuredSession.workspaceId) const folder = scope?.type === 'folder' - ? state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) + ? selectFolderWorkspaces(state).find( + (workspace) => workspace.id === scope.folderWorkspaceId + ) : undefined - const worktree = getIndexedWorktreeById(state.worktreesByRepo, structuredSession.workspaceId) + const worktree = getIndexedWorktreeById( + selectWorktreesByRepo(state), + structuredSession.workspaceId + ) const workspace = folder?.name ?? (worktree ? resolveWorktreeDisplayName(worktree) : structuredSession.workspaceId) diff --git a/src/renderer/src/lib/launch-ai-vault-session.test.ts b/src/renderer/src/lib/launch-ai-vault-session.test.ts index 691faa9bfd71..c06543b6d694 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.test.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.test.ts @@ -238,7 +238,7 @@ describe('launchAiVaultSessionInNewTab', () => { it.each([undefined, 'explicit-group'])( 'derives the source group before the paired launch and preserves explicit target %s', (targetGroupId) => { - runtimeMocks.runtimeEnvironmentIdWithLegacyFallbackUnlessTwin.mockReturnValue('env-1') + runtimeMocks.runtimeEnvironmentIdOrLocalUnlessTwin.mockReturnValue('env-1') runtimeMocks.isWebRuntimeSessionActive.mockReturnValue(true) mockState.unifiedTabsByWorktree['wt-1'] = [{ id: 'chat-C', groupId: 'source-group' }] launchAiVaultSessionInNewTab({ From 043601ec4625f903effb8f5a9ed6f29cbf8942be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:59:06 -0700 Subject: [PATCH 035/207] Fix pinned switch placement and terminal resume fallback --- .../TabSessionSurfaceSwitchMenuItems.test.tsx | 4 +- .../TerminalPaneResumeInChatButton.test.tsx | 40 ++++++--- .../TerminalPaneResumeInChatButton.tsx | 20 +++-- .../launch-ai-vault-session-placement.test.ts | 87 +++++++++++++++++++ .../src/lib/launch-ai-vault-session.ts | 8 +- 5 files changed, 129 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/lib/launch-ai-vault-session-placement.test.ts diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx index 436ab2399fa4..e10e7dcbfe5a 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx @@ -51,7 +51,7 @@ vi.mock('./tab-session-history-switch', async (importOriginal) => ({ })) vi.mock('./cli-chat-resume-source', () => ({ - useCliChatResumeCapability: () => true, + useCliChatResumeCapability: (_worktreeId: string, agent: string) => agent === 'codex', resolveCliChatResumeSource: () => mocks.cliSource })) vi.mock('./cli-chat-resume-run', () => ({ @@ -140,7 +140,7 @@ describe('TabSessionSurfaceSwitchMenuItems', () => { } mocks.cliSource = source renderItemsNow() - fireEvent.click(screen.getByRole('menuitem', { name: 'Switch to native chat' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Resume in New Chat' })) await act(async () => {}) expect(mocks.runCliChatResume).toHaveBeenCalledWith(source) expect(mocks.listSessions).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx index 0cf16cbe51d8..fca1d1d8b496 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx @@ -1,28 +1,31 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' +import type { CliChatResumeSource } from '../tab-bar/cli-chat-resume-source' import { TerminalPaneResumeInChatButton } from './TerminalPaneResumeInChatButton' -const mocks = vi.hoisted(() => ({ - source: { +const mocks = vi.hoisted(() => { + const source: CliChatResumeSource = { tabId: 'tab-1', workspaceId: 'wt-1', paneKey: 'tab-1:leaf', agent: 'claude', providerSessionId: 'provider-1', - executionHostId: 'local' - }, - resolve: vi.fn(), - run: vi.fn(), - pending: false -})) + executionHostId: 'local', + ptyId: 'pty-1' + } + return { source, resolve: vi.fn(), run: vi.fn(), pending: false, running: true } +}) vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => - selector({ paneForegroundAgentByPaneKey: {}, agentStatusByPaneKey: {} }) + selector({ + paneForegroundAgentByPaneKey: {}, + agentStatusByPaneKey: mocks.running ? { 'tab-1:leaf': 'working' } : {} + }) })) vi.mock('../tab-bar/cli-chat-resume-source', () => ({ - useCliChatResumeCapability: () => true, + useCliChatResumeCapability: (_worktreeId: string, agent: string) => agent === 'codex', resolveCliChatResumeSource: (_state: unknown, args: unknown) => { mocks.resolve(args) return mocks.source @@ -33,14 +36,25 @@ vi.mock('../tab-bar/cli-chat-resume-run', () => ({ useCliChatResumePending: () => mocks.pending })) afterEach(cleanup) +beforeEach(() => { + mocks.source.agent = 'claude' + mocks.pending = false + mocks.running = true + vi.clearAllMocks() +}) -it('dispatches the exact pane and shares pending', () => { +it.each([ + ['claude', 'Resume in New Chat'], + ['codex', 'Switch to native chat'] +] as const)('offers the %s action for the exact pane and shares pending', (agent, label) => { + mocks.source.agent = agent + mocks.running = agent === 'claude' const { rerender } = render( ) - fireEvent.click(screen.getByRole('button', { name: 'Switch to native chat' })) + fireEvent.click(screen.getByRole('button', { name: label })) expect(mocks.resolve).toHaveBeenCalledWith({ tab: { id: 'tab-1', worktreeId: 'wt-1' }, paneKey: 'tab-1:leaf' diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx index 08e9fdd1741a..023c2c0d3736 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx @@ -35,18 +35,20 @@ export function TerminalPaneResumeInChatButton({ ) }) const pending = useCliChatResumePending(source) - const label = translate('components.tab.bar.switch.toNativeChat', 'Switch to native chat') + const label = + capability === false + ? translate( + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewNativeChat', + 'Resume in New Chat' + ) + : translate('components.tab.bar.switch.toNativeChat', 'Switch to native chat') const tooltip = !source ? translate( 'components.tab.bar.switch.noConversation', 'No saved conversation is available in this terminal yet.' ) : capability === false - ? translate( - 'components.tab.bar.switch.unavailable', - 'Switching {{agent}} is not available yet.', - { agent: agentLabel(source.agent) } - ) + ? label : running && source ? translate( 'components.tab.bar.switch.exitAgentFirst', @@ -70,13 +72,13 @@ export function TerminalPaneResumeInChatButton({ // Same class as split so it shares the hover/active reveal. className="pane-title-split-trigger" aria-label={label} - disabled={!source || pending || capability !== true} + disabled={!source || pending || capability === undefined} // Not `disabled`: the tooltip has to say why. - aria-disabled={running || undefined} + aria-disabled={(capability === true && running) || undefined} aria-busy={pending || undefined} onClick={(event) => { event.stopPropagation() - if (source && !running && capability === true) { + if (source && capability !== undefined && (capability === false || !running)) { void runCliChatResume(source) } }} diff --git a/src/renderer/src/lib/launch-ai-vault-session-placement.test.ts b/src/renderer/src/lib/launch-ai-vault-session-placement.test.ts new file mode 100644 index 000000000000..2972fbc73348 --- /dev/null +++ b/src/renderer/src/lib/launch-ai-vault-session-placement.test.ts @@ -0,0 +1,87 @@ +import { expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session' + +const mocks = vi.hoisted(() => { + const held: { store: unknown } = { store: null } + return held +}) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + runtimeEnvironmentIdOrLocalUnlessTwin: () => null +})) +vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ + beginStructuredAgentSessionTabClose: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +createTabsSliceMockApi() + +it('keeps the pinned partition and neighbor order after a local switch retires its source', () => { + const worktreeId = 'repo1::/work/app' + const groupId = 'group-1' + const tab = (id: string, extra: Partial = {}): Tab => ({ + id, + entityId: id, + groupId, + worktreeId, + contentType: 'browser', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...extra + }) + const source = tab('agent-session:chat', { + entityId: 'chat', + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: 'local', + isPinned: true + }) + const store = createTestStore() + mocks.store = store + store.setState({ + unifiedTabsByWorktree: { + [worktreeId]: [source, tab('pinned', { isPinned: true }), tab('unpinned')] + }, + groupsByWorktree: { + [worktreeId]: [ + { + id: groupId, + worktreeId, + activeTabId: source.id, + tabOrder: [source.id, 'pinned', 'unpinned'] + } + ] + }, + activeGroupIdByWorktree: { [worktreeId]: groupId }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId } } + }) + + const result = launchAiVaultSessionInNewTab({ + agent: 'codex', + worktreeId, + command: 'codex resume provider-1', + afterTabId: source.id, + resumeOrigin: 'switch' + }) + expect(result.tabId).toEqual(expect.any(String)) + store.getState().closeUnifiedTab(source.id) + + expect(store.getState().groupsByWorktree[worktreeId]?.[0]?.tabOrder).toEqual([ + 'pinned', + result.tabId, + 'unpinned' + ]) + expect(store.getState().unifiedTabsByWorktree[worktreeId]?.some((t) => t.id === source.id)).toBe( + false + ) +}) diff --git a/src/renderer/src/lib/launch-ai-vault-session.ts b/src/renderer/src/lib/launch-ai-vault-session.ts index 13fa40e2838b..3f4200cdb66c 100644 --- a/src/renderer/src/lib/launch-ai-vault-session.ts +++ b/src/renderer/src/lib/launch-ai-vault-session.ts @@ -1,5 +1,6 @@ import { useAppStore } from '@/store' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' +import { insertUnifiedTabAfterAnchor } from '@/lib/unified-tab-anchor-insertion' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' @@ -103,12 +104,7 @@ export function launchAiVaultSessionInNewTab(args: { persistAgentLaunchTabOrder(args.worktreeId, tab.id) if (slot && !split && (!args.targetGroupId || args.targetGroupId === slot.groupId)) { - const order = ( - useAppStore.getState().groupsByWorktree[args.worktreeId]?.find((g) => g.id === slot.groupId) - ?.tabOrder ?? [] - ).filter((id) => id !== tab.id) - order.splice(order.indexOf(slot.id) + 1, 0, tab.id) - useAppStore.getState().reorderUnifiedTabs(slot.groupId, order) + insertUnifiedTabAfterAnchor(args.worktreeId, tab.id, slot.id) } return { tabId: tab.id } From 79bcf113e341bea75b0063a6f842f97d34ab66ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:20:21 -0700 Subject: [PATCH 036/207] fix: return the terminal main started to its waiting view --- src/main/ipc/pty-attach-view-joins-main-start.test.ts | 1 + src/main/ipc/pty/ipc/spawn-begin.ts | 7 +++++-- src/main/ipc/pty/ipc/spawn-run.ts | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/pty-attach-view-joins-main-start.test.ts b/src/main/ipc/pty-attach-view-joins-main-start.test.ts index d300c857e872..169f4d68e4a6 100644 --- a/src/main/ipc/pty-attach-view-joins-main-start.test.ts +++ b/src/main/ipc/pty-attach-view-joins-main-start.test.ts @@ -106,6 +106,7 @@ describe('a window view of a pane main is starting', () => { controller = value }), createPreAllocatedTerminalHandle: vi.fn(() => 'term_main'), + getAgentProviderSessionRows: vi.fn(() => []), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), onPtySpawned: vi.fn(), diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index daec13b22152..8c42ff43ef79 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -12,7 +12,8 @@ import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home' import { getAppPtyId } from '../provider/registry' import { makePaneSpawnReservationKey, - pendingRuntimePaneCreatesByOwnerKey + pendingRuntimePaneCreatesByOwnerKey, + type PaneSpawnReservationResult } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' @@ -36,7 +37,9 @@ export function resolveEarlyPaneSpawnReservationKey(args: PtySpawnIpcArgs): stri return makePaneSpawnReservationKey(args.worktreeId, args.connectionId, resolveEarlyPaneKey(args)) } -export async function beginPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { +export async function beginPtyIpcSpawn( + ctx: PtyIpcSpawnState +): Promise { const args = ctx.args // A pane an agent launch laid out attaches to its agent or says why it can't, never runs a shell. // A replacing spawn already holds the pane: the user restarted it, and that is theirs to run. diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index aa8dbf55e5ca..c3a81913c852 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -181,7 +181,10 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg }) } triggerPtySpawnPushTargetMaterialization(deps, args) - await beginPtyIpcSpawn(ctx) + const early = await beginPtyIpcSpawn(ctx) + if (early) { + return early + } const paneKey = resolveEarlyPaneKey(args) if (!paneKey || !args.worktreeId || !args.tabId || !args.leafId) { return await fresh({ mustExist: false }) From 60cc704982b4cebeaefa6854baaf463c5d11d221 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:49:30 -0700 Subject: [PATCH 037/207] fix: register reattached terminals before publishing their handles --- src/main/ipc/pty-pane-materialization-race.test.ts | 6 ++++++ .../cli-terminal-create-host-session-binding.test.ts | 9 +++++++-- src/main/runtime/terminal/launch/terminal-create.ts | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index b04e02587eb4..4b9c77f5939f 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -390,6 +390,12 @@ describe('registerPtyHandlers', () => { expect(rendererResult).toEqual({ id: 'pty-renderer' }) expect(runtimeResult).toEqual({ id: 'pty-renderer', + isReattach: true, + launchAgent: undefined, + launchToken: undefined, + resumeProviderSession: undefined, + snapshotKittyKeyboardFlags: undefined, + wslDistro: undefined, stablePaneOwner: { handle: 'term_trusted', tabId: 'tab-race', diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index 96c9d85ed87e..d0ec13264909 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -94,8 +94,8 @@ it.each(['refused', 'adopted', 'stable-adopted', 'ensure-adopted', 'fresh-unavai tabId: 'owner-tab', leafId: '11111111-1111-4111-8111-111111111111' } - if (adopted) { - // The lower adopter registers its owner before returning the result. + if (adopted && kind !== 'adopted') { + // An adopted surface is registered; a bare provider reattach need not be. runtime.registerPty('effective-pty', ownerSurface.worktreeId, null, { tabId: ownerSurface.tabId, leafId: ownerSurface.leafId, @@ -210,6 +210,11 @@ it.each(['refused', 'adopted', 'stable-adopted', 'ensure-adopted', 'fresh-unavai : {}), warning: expect.any(String) }) + expect(runtime.resolveTerminalPane(result.paneKey ?? '', result.worktreeId)).toMatchObject({ + handle: result.handle, + ptyId: 'effective-pty', + connected: true + }) expect(result.agentResumeRefused).toBe(kind === 'refused' ? 'chat-home' : undefined) expect(result.launchAgent).toBe(kind === 'refused' ? undefined : 'codex') expect(result.launchConfig).toEqual(kind === 'refused' ? undefined : config) diff --git a/src/main/runtime/terminal/launch/terminal-create.ts b/src/main/runtime/terminal/launch/terminal-create.ts index 7382082c410a..b9e6739a41d5 100644 --- a/src/main/runtime/terminal/launch/terminal-create.ts +++ b/src/main/runtime/terminal/launch/terminal-create.ts @@ -222,7 +222,8 @@ export abstract class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTermi paneKey = dependencies.makePaneKey(tabId, leafId) } this.assertCreatedPtyRegistrationAllowed(result.id, result.incarnationId) - if (!launch.adoptedStablePane) { + // Provider reattachment still needs this runtime's handle and workspace binding. + if (!result.stablePaneOwner) { this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) From 626429d6fc2dbcb90d79be45f4092a9456353066 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:56:04 -0700 Subject: [PATCH 038/207] test: check the terminal return fixture session shape --- ...li-chat-resume-exit-return.test-fixture.ts | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts index e825f51a647b..22a8b236866a 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts @@ -1,4 +1,10 @@ import { expect, vi } from 'vitest' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { SearchAddon } from '@xterm/addon-search' +import { SerializeAddon } from '@xterm/addon-serialize' +import { PaneManager } from '@/lib/pane-manager/pane-manager' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' import { makeTerminalTab, @@ -18,8 +24,6 @@ import { installSleepingRecordAccess } from '../terminal-pane/pty-connection/sle import type { ConnectPanePtySession } from '../terminal-pane/pty-connection/connect-pane-pty-session' import { createMockTransport, - createPane, - createManager, LEAF_1, LEAF_2 } from '../terminal-pane/pty-connection-test-pane-fixtures' @@ -103,16 +107,61 @@ export async function verifyExitedPaneHistoryReturn( agentEvidence: 'process-read', shellForeground: false }) - // SAFETY: these installers exercise only the real process-exit cleanup, with inert terminal I/O. - const connection = { + if (!isTerminalLeafId(LEAF_1)) { + throw new Error('Expected a valid fixture leaf ID') + } + const terminal = new Terminal({ cols: 120, rows: 40 }) + const manager = new PaneManager(document.createElement('div'), { linkOpenHint: () => '' }) + const connection: ConnectPanePtySession = { cacheKey: paneKey, - pane: createPane(1), - manager: createManager(1), - deps: { tabId: 'shell', worktreeId: 'wt-1' }, - transport: createMockTransport('pty-1'), + pane: { + id: 1, + leafId: LEAF_1, + stablePaneId: LEAF_1, + terminal, + container: document.createElement('div'), + linkTooltip: document.createElement('div'), + fitAddon: new FitAddon(), + searchAddon: new SearchAddon(), + serializeAddon: new SerializeAddon() + }, + manager, + deps: { + tabId: 'shell', + worktreeId: 'wt-1', + mountFollowsTerminalPark: false, + paneTransportsRef: { current: new Map() }, + paneMode2031Ref: { current: new Map() }, + paneKittyKeyboardModesRef: { current: new Map() }, + paneLastThemeModeRef: { current: new Map() }, + replayingPanesRef: { current: new Map() }, + isActiveRef: { current: true }, + isVisibleRef: { current: true }, + onPtyExitRef: { current: vi.fn() }, + onAgentExitedRef: { current: vi.fn() }, + clearTabPtyId: vi.fn(), + consumeSuppressedPtyExit: vi.fn(() => false), + isPtyShutdownPending: vi.fn(() => false), + updateTabTitle: vi.fn(), + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabPtyId: vi.fn(), + markTerminalBell: vi.fn(), + onShowSessionRestoredBanner: vi.fn(), + dispatchNotification: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + syncPanePtyLayoutBinding: vi.fn(), + clearExitedPanePtyLayoutBinding: vi.fn() + }, + transport: { + ...createMockTransport('pty-1'), + connect: vi.fn(async () => {}), + isConnected: vi.fn(() => true) + }, + hiddenOutputRestoreUnhideSeq: null, paneStartup: null, writeInputModeGround: vi.fn() - } as unknown as ConnectPanePtySession + } installSleepingRecordAccess(connection) installPaneAgentIdentity(connection) connection.paneForegroundAgentTracker.onProcessExitConfirmed({ @@ -245,5 +294,7 @@ export async function verifyExitedPaneHistoryReturn( window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, closed) connection.paneForegroundAgentTracker.dispose() connection.commandLifecycle.dispose() + manager.destroy() + terminal.dispose() } } From d69cbec211294a48d1a7d767275f273cc4807711 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:12:50 -0700 Subject: [PATCH 039/207] test: correct terminal return transport fixture typing --- ...li-chat-resume-exit-return.test-fixture.ts | 13 +++-------- .../cli-chat-resume-transport.test-fixture.ts | 23 +++++++++++++++++++ .../src/i18n/en-runtime-required.json | 11 +++++++-- 3 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/cli-chat-resume-transport.test-fixture.ts diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts index 22a8b236866a..ab0001e8b571 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts @@ -22,11 +22,8 @@ import { switchAiVaultSessionToNativeChat } from '../right-sidebar/ai-vault-sess import { installPaneAgentIdentity } from '../terminal-pane/pty-connection/pane-agent-identity' import { installSleepingRecordAccess } from '../terminal-pane/pty-connection/sleeping-record-access' import type { ConnectPanePtySession } from '../terminal-pane/pty-connection/connect-pane-pty-session' -import { - createMockTransport, - LEAF_1, - LEAF_2 -} from '../terminal-pane/pty-connection-test-pane-fixtures' +import { LEAF_1, LEAF_2 } from '../terminal-pane/pty-connection-test-pane-fixtures' +import { createExitReturnTransport } from './cli-chat-resume-transport.test-fixture' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal' @@ -153,11 +150,7 @@ export async function verifyExitedPaneHistoryReturn( syncPanePtyLayoutBinding: vi.fn(), clearExitedPanePtyLayoutBinding: vi.fn() }, - transport: { - ...createMockTransport('pty-1'), - connect: vi.fn(async () => {}), - isConnected: vi.fn(() => true) - }, + transport: createExitReturnTransport('pty-1'), hiddenOutputRestoreUnhideSeq: null, paneStartup: null, writeInputModeGround: vi.fn() diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-transport.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-transport.test-fixture.ts new file mode 100644 index 000000000000..a8d06fbe46d2 --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-transport.test-fixture.ts @@ -0,0 +1,23 @@ +import { vi } from 'vitest' +import type { PtyTransport } from '../terminal-pane/pty-transport-types' + +export function createExitReturnTransport(initialPtyId: string): PtyTransport { + let ptyId: string | null = initialPtyId + return { + attach: vi.fn(({ existingPtyId }: { existingPtyId: string }) => { + ptyId = existingPtyId + }), + connect: vi.fn(async () => {}), + disconnect: vi.fn(() => { + ptyId = null + }), + sendInput: vi.fn(() => true), + sendInputImmediate: vi.fn(() => true), + sendInputAccepted: vi.fn(async () => true), + claimViewport: vi.fn(() => true), + resize: vi.fn(() => true), + isConnected: vi.fn(() => true), + getPtyId: vi.fn(() => ptyId), + getConnectionId: vi.fn(() => null) + } +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e3a956f39c68..da916b8299cd 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3084,6 +3084,8 @@ "compactionFailed": "Compaction failed.", "compactionFailedQuoted": "Compaction failed: {{detail}}.", "compactionUnconfirmed": "Compaction completion is unconfirmed.", + "conversationOpenElsewhere": "Codex still has this conversation open somewhere else. Close it there, then try again.", + "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "couldNotRestart": "{{agent}} couldn't restart.", "couldNotStart": "{{agent}} couldn't start.", "couldNotStop": "Couldn't stop {{agent}}.", @@ -3121,8 +3123,6 @@ "runCommandAgain": "Run /{{command}} again.", "runCommandWhenDone": "Run /{{command}} when it's done.", "runtimeNotInstalled": "{{agent}} isn't installed on the computer running this chat. Install it from the chat in a current version of Orca.", - "conversationOpenElsewhere": "Codex still has this conversation open somewhere else. Close it there, then try again.", - "conversationOpenInTerminal": "Orca can't confirm {{agent}} has exited in its terminal. Stop what's running there or close the terminal, then try again.", "sendAgainToTryOnceMore": "Send your message again to try once more.", "sendToTryAgain": "Send your message to try again.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", @@ -3325,6 +3325,13 @@ } } }, + "tab": { + "bar": { + "SortableTabContextMenu": { + "closeTabsToLeft": "Close All Tabs To The Left" + } + } + }, "workspace": { "cleanup": { "browse": { From 8dc0318ee9947b0e47bd745622484b72aa83e52e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:22:19 -0700 Subject: [PATCH 040/207] test: name the portable CLI resume mock contract --- .../tab-bar/cli-chat-resume-run.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index b5b6cfb816b8..239eda206dfe 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -1,12 +1,28 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { CliChatResumeSource } from './cli-chat-resume-source' import type * as ResumeSourceModule from './cli-chat-resume-source' import type * as RuntimeRpcModule from '@/runtime/runtime-rpc-client' import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' -const mocks = vi.hoisted(() => { +export type CliChatResumeTestMocks = { + source: CliChatResumeSource | null + realSource: boolean + sourceCapability: Mock + createCapability: Mock + call: Mock + toast: Mock + success: Mock + activate: Mock<() => Promise> + activateVerified: Mock<() => Promise> + switchCapability: Mock<() => Promise> + exitWatchers: Map void> + rpc: Mock<(...args: unknown[]) => Promise> + inspect: Mock<(ptyId: string) => Promise<{ foregroundProcessEvidence?: unknown }>> +} + +const mocks: CliChatResumeTestMocks = vi.hoisted(() => { const state: { source: CliChatResumeSource | null } = { source: null } return { ...state, @@ -24,7 +40,6 @@ const mocks = vi.hoisted(() => { inspect: vi.fn(async (_ptyId: string): Promise<{ foregroundProcessEvidence?: unknown }> => ({})) } }) -export type CliChatResumeTestMocks = typeof mocks vi.mock('@/runtime/runtime-terminal-inspection', () => ({ inspectRuntimeTerminalProcess: mocks.inspect From 7177cc1532917aeb9a358c38c908f2444910c646 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:22:11 -0700 Subject: [PATCH 041/207] fix: show conversation switches only with Chat UI enabled --- .../NativeChatTopRightControls.tsx | 4 +- .../AiVaultSessionActionMenuItems.tsx | 27 ++++---- .../AiVaultSessionDetailsSwitchButtons.tsx | 8 ++- .../ai-vault-history-native-return.test.tsx | 1 + .../tab-bar/CliChatResumeMenuItems.tsx | 4 +- .../tab-bar/NativeChatCliForkMenuItems.tsx | 4 +- .../TabSessionSurfaceSwitchMenuItems.test.tsx | 3 +- .../tab-bar/TabSessionSwitchHoverAction.tsx | 5 ++ .../TerminalPaneResumeInChatButton.test.tsx | 66 +++++++++++++++---- .../TerminalPaneResumeInChatButton.tsx | 7 +- 10 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx index d37d5477a87c..1a8db3af1e9b 100644 --- a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx @@ -1,3 +1,4 @@ +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -40,10 +41,11 @@ export function NativeChatTopRightControls({ const agent = AI_VAULT_AGENTS.find((candidate) => candidate === session.agent) const inPlace = useInPlaceSwitchCapability(hostId, agent) const pending = useNativeChatCliSwitchPending(session.sessionId) + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) if (find.isOpen) { return } - if (!worktreeId || !hostId || !agent || (inPlace === false && agent !== 'omp')) { + if (!enabled || !worktreeId || !hostId || !agent || (inPlace === false && agent !== 'omp')) { return null } const label = translate('components.tab.bar.switch.toCli', 'Switch to CLI') diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index 4642d7ca8611..386caa1e5eb8 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -15,6 +15,8 @@ import { translate } from '@/i18n/i18n' import { AiVaultSessionSurfaceSwitchMenuItems } from './AiVaultSessionSurfaceSwitchMenuItems' import type { AiVaultAgent } from '../../../../shared/ai-vault-types' import { isAiVaultFolderWorkspaceId } from './ai-vault-session-worktree-affordances' +import { useAppStore } from '@/store' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' export function SessionActionMenuItems({ menuKind = 'dropdown', @@ -67,6 +69,8 @@ export function SessionActionMenuItems({ switchLoading?: boolean inPlaceAgent?: AiVaultAgent }) { + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + const showSurfaceActions = enabled || (inPlaceAgent === undefined && !switchLoading) const Item = menuKind === 'context' ? ContextMenuItem : DropdownMenuItem const Separator = menuKind === 'context' ? ContextMenuSeparator : DropdownMenuSeparator const hasLocalPathActions = Boolean(onOpenLog || onRevealLog || onOpenCwd) @@ -75,8 +79,7 @@ export function SessionActionMenuItems({ onJumpToOriginalPane || showJumpToWorktree || !resumeHidden || - onResumeInNewChat || - onResumeInNewCli || + (showSurfaceActions && (onResumeInNewChat || onResumeInNewCli)) || onCopyResume ) const deleteLabel = translate('auto.components.right.sidebar.AiVaultSessionRow.delete', 'Delete') @@ -125,15 +128,17 @@ export function SessionActionMenuItems({ {resumeLabel} ) : null} - + {showSurfaceActions ? ( + + ) : null} {onCopyResume ? ( diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx index a871edbd9546..92b8a385d72f 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetailsSwitchButtons.tsx @@ -1,3 +1,5 @@ +import { useAppStore } from '@/store' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { MessagesSquare, SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -15,10 +17,14 @@ export function AiVaultSessionDetailsSwitchButtons({ session: Pick onResumeInNewChat?: () => void onResumeInNewCli?: () => void -}): React.JSX.Element { +}): React.JSX.Element | null { const inPlaceAgent = useAiVaultInPlaceAgent(session) const switchLoading = useInPlaceSwitchCapability(session.executionHostId, session.agent) === undefined + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!enabled && (inPlaceAgent !== undefined || switchLoading)) { + return null + } return ( <> {onResumeInNewChat ? ( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx index d88e429ccfb1..9444e2e6a035 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx @@ -155,6 +155,7 @@ beforeEach(async () => { } }) useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) }) setLocalRuntimeCapabilitiesForTests([HISTORY]) await refreshLocalRuntimeCapabilities() mocks.owner.mockReturnValue('local') diff --git a/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx b/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx index 168e6c7659d2..dfc25f1f9cd4 100644 --- a/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/CliChatResumeMenuItems.tsx @@ -1,3 +1,4 @@ +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' @@ -18,7 +19,8 @@ export function CliChatResumeMenuItems({ const source = useAppStore(useShallow((state) => resolveCliChatResumeSource(state, { tab }))) const capability = useCliChatResumeCapability(tab.worktreeId, source?.agent) const pending = useCliChatResumePending(source) - if (!source) { + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!source || (!enabled && capability !== false)) { return null } return ( diff --git a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx index 9af7d09d2d22..09372f5561ae 100644 --- a/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx +++ b/src/renderer/src/components/tab-bar/NativeChatCliForkMenuItems.tsx @@ -1,3 +1,4 @@ +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { useState } from 'react' import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' @@ -32,7 +33,8 @@ export function NativeChatCliForkMenuItems({ const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) const inPlace = useInPlaceSwitchCapability(chat?.request.executionHostScope, agent) - if (!chat || !agent) { + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!chat || !agent || (!enabled && inPlace !== false)) { return null } return ( diff --git a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx index e10e7dcbfe5a..3b7feed815d0 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSurfaceSwitchMenuItems.test.tsx @@ -40,7 +40,8 @@ vi.mock('./native-chat-cli-fork-run', () => ({ vi.mock('@/store', () => ({ useAppStore: Object.assign( - (selector: (state: Record) => unknown) => selector({ settings: null }), + (selector: (state: Record) => unknown) => + selector({ settings: { experimentalNativeChat: true } }), { getState: () => ({}) } ) })) diff --git a/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx index 47c2fb5b8310..6f1b9555cdba 100644 --- a/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx +++ b/src/renderer/src/components/tab-bar/TabSessionSwitchHoverAction.tsx @@ -1,3 +1,4 @@ +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { MessagesSquare, SquareTerminal } from 'lucide-react' @@ -22,6 +23,10 @@ export function TabSessionSwitchHoverAction({ tab: { id: string; worktreeId: string } nativeChat?: NativeChatTabSession }): React.JSX.Element | null { + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!enabled) { + return null + } return nativeChat ? ( ) : ( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx index fca1d1d8b496..87a929374db0 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.test.tsx @@ -1,7 +1,15 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { useAppStore } from '@/store' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import { CliChatResumeMenuItems } from '../tab-bar/CliChatResumeMenuItems' import type { CliChatResumeSource } from '../tab-bar/cli-chat-resume-source' import { TerminalPaneResumeInChatButton } from './TerminalPaneResumeInChatButton' @@ -15,15 +23,8 @@ const mocks = vi.hoisted(() => { executionHostId: 'local', ptyId: 'pty-1' } - return { source, resolve: vi.fn(), run: vi.fn(), pending: false, running: true } + return { source, resolve: vi.fn(), run: vi.fn(), pending: false } }) -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: Record) => unknown) => - selector({ - paneForegroundAgentByPaneKey: {}, - agentStatusByPaneKey: mocks.running ? { 'tab-1:leaf': 'working' } : {} - }) -})) vi.mock('../tab-bar/cli-chat-resume-source', () => ({ useCliChatResumeCapability: (_worktreeId: string, agent: string) => agent === 'codex', resolveCliChatResumeSource: (_state: unknown, args: unknown) => { @@ -39,7 +40,8 @@ afterEach(cleanup) beforeEach(() => { mocks.source.agent = 'claude' mocks.pending = false - mocks.running = true + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) }) vi.clearAllMocks() }) @@ -48,7 +50,9 @@ it.each([ ['codex', 'Switch to native chat'] ] as const)('offers the %s action for the exact pane and shares pending', (agent, label) => { mocks.source.agent = agent - mocks.running = agent === 'claude' + if (agent === 'claude') { + useAppStore.getState().setPaneForegroundAgent('tab-1:leaf', { agent, shellForeground: false }) + } const { rerender } = render( @@ -69,3 +73,43 @@ it.each([ expect(screen.getByRole('button').getAttribute('aria-busy')).toBe('true') expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true) }) + +it('hides header and menu switches while Chat UI is off and follows live setting changes', () => { + mocks.source.agent = 'codex' + useAppStore.setState({ settings: createGlobalSettingsFixture({ experimentalNativeChat: false }) }) + render( + + + + Open + + + + + + ) + expect(screen.queryByRole('button', { name: 'Switch to native chat' })).toBeNull() + expect(screen.queryByRole('menuitem')).toBeNull() + act(() => { + useAppStore.getState().setPaneForegroundAgent('tab-1:leaf', { + agent: 'codex', + shellForeground: false + }) + }) + expect(screen.queryByRole('button', { name: 'Switch to native chat', hidden: true })).toBeNull() + expect(screen.queryByRole('menuitem')).toBeNull() + act(() => { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) + }) + }) + expect(screen.getByRole('button', { name: 'Switch to native chat', hidden: true })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: 'Switch to native chat' })).toBeTruthy() + act(() => { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: false }) + }) + }) + expect(screen.queryByRole('button', { name: 'Switch to native chat', hidden: true })).toBeNull() + expect(screen.queryByRole('menuitem')).toBeNull() +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx index 023c2c0d3736..f574cfebcb78 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneResumeInChatButton.tsx @@ -1,3 +1,4 @@ +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import { useShallow } from 'zustand/react/shallow' import { MessagesSquare } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -19,7 +20,7 @@ export function TerminalPaneResumeInChatButton({ tabId: string worktreeId: string paneKey: string -}): React.JSX.Element { +}): React.JSX.Element | null { const source = useAppStore( useShallow((state) => resolveCliChatResumeSource(state, { tab: { id: tabId, worktreeId }, paneKey }) @@ -35,6 +36,10 @@ export function TerminalPaneResumeInChatButton({ ) }) const pending = useCliChatResumePending(source) + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) + if (!enabled && capability !== false) { + return null + } const label = capability === false ? translate( From 9b29e7dcbd50c6ff149e6f6d2f9bbd9fee6ad800 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:55:14 -0700 Subject: [PATCH 042/207] Check unavailable-resume test fixture types --- .../pty-connection-cold-restore-agent-resume.spec.ts | 2 +- .../components/terminal-pane/pty-connection-test-store-state.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts index f0bd0a8d6cda..41ef4000de4e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.spec.ts @@ -382,7 +382,7 @@ export function registerPtyConnectionColdRestoreAgentResumeTests(): void { updatedAt: 1 } } - } as StoreState + } const pane = createPane(1) const manager = createManager(1) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index eda161456bac..c89990da68e2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -102,6 +102,7 @@ export type StoreState = { removeDeferredSshSessionId: ReturnType consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType + consumeTabStartupCommand?: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record retainedAgentsByPaneKey: Record From 65339e36c9cfd5827a3c77b8259e5b774569490b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:14:22 -0700 Subject: [PATCH 043/207] Complete sidebar resume test store fixture --- .../src/lib/sleeping-agent-session-launch-layout-owner.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts index 7725700d97ad..4bcc6f7015c8 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-layout-owner.test.ts @@ -22,6 +22,7 @@ vi.mock('@/store', () => ({ sshConnectionStates: new Map(), sshStateByEnvironment: new Map(), runtimeStatusByEnvironmentId: new Map(), + agentStatusByPaneKey: {}, tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, openFiles: [], browserTabsByWorktree: {}, From 6d1fd572e424af5339648227065335ee2dda858c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:23:24 -0700 Subject: [PATCH 044/207] fix: complete original terminal owner retirement --- ...profile-state-delayed-authority-fixture.ts | 2 +- .../layout-command-persistence.ts | 64 +++++ .../original-terminal-close-store.test.ts | 130 ++++++++++ .../mobile-session-tab-close-outcome.ts | 19 ++ .../mobile-session-terminal-retirement.ts | 2 + ...ca-runtime-original-terminal-close.test.ts | 196 ++++++++++++++ src/main/runtime/orca-runtime.ts | 27 +- .../terminal/terminal-lifecycle-methods.ts | 10 +- src/main/runtime/runtime-layout-apply.ts | 10 +- src/main/runtime/runtime-notifier-contract.ts | 7 +- .../session-graph/session-window-graph.ts | 2 +- .../close/close-mobile-row-in-main-layout.ts | 11 + .../close/close-mobile-session-tab.ts | 55 ++-- ...ld-headless-mobile-session-browser-tabs.ts | 241 +++++++++-------- .../prune-mobile-session-tab-group-layout.ts | 7 +- .../explicit-terminal-close-pty-stops.ts | 86 ++++--- .../explicit-terminal-close-stops.ts | 219 ++++++++-------- .../explicit-terminal-tab-close.ts | 90 +++++++ .../paired-host-terminal-close.ts | 23 +- .../terminal-surface-close-commit.ts | 153 +++++++++++ .../runtime/terminal-intentional-stops.ts | 5 +- src/main/runtime/terminal-surface-close.ts | 61 ++++- .../identity/terminal-handle-bindings.ts | 19 +- .../terminal/launch/failed-start-pane.ts | 26 ++ .../lifecycle/floating-session-liveness.ts | 7 +- .../output/provider-buffer-capture.ts | 21 ++ .../terminal/query/pty-terminal-summary.ts | 9 + .../recovery/resolve-terminal-pane.ts | 75 ++++-- ...ession-terminal-tab-retirement-identity.ts | 64 +++++ .../terminal-tab-close-request-relay.ts | 7 +- ...li-chat-resume-exit-return.test-fixture.ts | 39 +-- .../tab-bar/cli-chat-resume-feedback.ts | 2 +- .../cli-chat-resume-mocks.test-fixture.ts | 18 ++ ...t-resume-parked-retirement.test-fixture.ts | 66 +++++ .../tab-bar/cli-chat-resume-run.test.ts | 121 +++++---- .../components/tab-bar/cli-chat-resume-run.ts | 221 +++++++--------- .../tab-bar/cli-chat-resume-source.ts | 7 +- .../tab-session-history-switch.test.ts | 52 +++- .../terminal-pane-lifecycle-close.ts | 11 +- .../terminal-pane-mount-cleanup.ts | 45 ++-- .../terminal-pane-mount-context.ts | 3 +- .../terminal-pane-mount-preparation.ts | 21 +- .../terminal-pane-mount-registration.ts | 59 +++++ .../terminal-pane-pane-closed.ts | 16 +- .../terminal-parked-watcher-registry.ts | 49 ++++ ...al-presentation-retirement.test-fixture.ts | 212 +++++++++++++++ .../terminal-presentation-retirement.test.ts | 243 ++++++++++++++++++ .../terminal-presentation-retirement.ts | 79 ++++++ .../use-terminal-pane-mount-lifecycle.ts | 58 +++-- .../mobile-terminal-close-ipc-bridge.ts | 21 +- .../src/lib/ai-vault-tab-title-requests.ts | 176 +++++++------ .../pane-manager-tree-mutations.ts | 35 ++- .../lib/pane-manager/pane-manager-types.ts | 2 +- .../src/lib/pane-manager/pane-manager.ts | 4 +- .../src/lib/pane-manager/pane-split-close.ts | 17 +- .../runtime/mounted-terminal-tab-registry.ts | 3 + .../rpc-params-catalog.generated.ts | 3 +- .../rpc-contract/terminal-unary-params.ts | 26 ++ src/shared/runtime-terminal-contracts.ts | 3 + src/shared/terminal-tab-close.ts | 108 ++++++++ 60 files changed, 2622 insertions(+), 746 deletions(-) create mode 100644 src/main/persistence/terminal-topology/original-terminal-close-store.test.ts create mode 100644 src/main/runtime/orca-runtime-original-terminal-close.test.ts create mode 100644 src/main/runtime/session-tabs/explicit-terminal-tab-close.ts create mode 100644 src/main/runtime/session-tabs/terminal-surface-close-commit.ts create mode 100644 src/renderer/src/components/tab-bar/cli-chat-resume-mocks.test-fixture.ts create mode 100644 src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts diff --git a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts index e2078f8b9182..9fbd9059e6eb 100644 --- a/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts +++ b/src/main/persistence/loading-store/profile-state-delayed-authority-fixture.ts @@ -146,7 +146,7 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) { reader.close() } } - return { store, authority, readState } + return { store, authority, readState, directory, path } } /** The runtime writes a pane before it spawns; a runtime spawn commit binds only that pane. */ diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index 2ac27dc97550..0724015a0e73 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -1,3 +1,5 @@ +import type { TerminalSurfaceCloseCapture } from '../../../shared/terminal-tab-close' +import { closeLeafOrTab } from './terminal-topology-commit' import type { CommandOf, LayoutApplyOutcome, @@ -84,6 +86,8 @@ export class LayoutCommandPersistence { command: LayoutCommand, hostIds: readonly string[] = [], run: { + originalPresentation?: TerminalSurfaceCloseCapture + admit?: () => boolean live?: LayoutLiveContext stopBeforeWrite?: (ptyIds: readonly string[]) => boolean onEffects?: (effects: LayoutEffects) => void @@ -91,6 +95,66 @@ export class LayoutCommandPersistence { ): Promise { const context = this[layoutCommandPersistenceContext] const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()] + if (run.originalPresentation && isTerminalClose(command)) { + const original = run.originalPresentation + const source = original.identity + const hostIds = () => [ + ...new Set([ + ...original.holders.map((holder) => holder.hostId), + ...context.sessions + .getWorkspaceSessionHostIds() + .filter((id) => + context.sessions + .getWorkspaceSession(id) + ?.tabsByWorktree[source.worktreeId]?.some((row) => row.id === source.tabId) + ) + ]) + ] + return context.runtime + .runDurableMutation(() => { + let closedTab = false + const mutation = closeLeafOrTab({ + worktreeId: command.workspace, + target: { kind: 'pane', tabId: source.tabId, leafId: source.leafId }, + options: { + allowMissing: true, + originalPresentation: original, + onClosed: (tab) => { + closedTab = tab + } + }, + requestedSession: context.sessions.getWorkspaceSession(resolved[0]), + ownerMatches: () => + (run.admit?.() ?? true) && + command.workspace === source.worktreeId && + ('tabId' in command + ? command.tabId === source.tabId + : command.type === 'closeTabs' && + command.tabIds.length === 1 && + command.tabIds[0] === source.tabId), + hostIds, + getSession: (id) => context.sessions.getWorkspaceSession(id), + setSession: (session, id) => + context.runtime.dirtyProfileStateDomains.add( + assignWorkspaceSessionPartition(context.runtime.state, id, session) + ), + onClosed: () => {} + })() + if (mutation.value) { + return mutation + } + return { + ...mutation, + value: { ok: true, result: { closed: closedTab ? [source.tabId] : [] } } as const + } + }) + .then((outcome) => { + if (outcome instanceof Error) { + throw outcome + } + return outcome + }) + } let effects: LayoutEffects | undefined const commit = raisingBindingGenerations( context.runtime, diff --git a/src/main/persistence/terminal-topology/original-terminal-close-store.test.ts b/src/main/persistence/terminal-topology/original-terminal-close-store.test.ts new file mode 100644 index 000000000000..eb1fd35edd2d --- /dev/null +++ b/src/main/persistence/terminal-topology/original-terminal-close-store.test.ts @@ -0,0 +1,130 @@ +import { expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { + fixture, + admitRuntimeSpawnPane +} from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { + captureTerminalSurfaceIdentity, + type TerminalSurfaceCloseCapture +} from '../../../shared/terminal-tab-close' +import { applyWindowLayoutCommand } from '../../runtime/runtime-layout-apply' + +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) +const pane = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +} +const command = { + type: 'closePane' as const, + workspace: pane.worktreeId, + tabId: pane.tabId, + leafId: pane.leafId +} +async function opened() { + const opened = await fixture() + await admitRuntimeSpawnPane(opened.store, pane) + const identity = captureTerminalSurfaceIdentity( + opened.store.getWorkspaceSession(), + pane.worktreeId, + pane.tabId, + pane.leafId + ) + if (!identity) { + throw new Error('Missing original owner') + } + const originalPresentation: TerminalSurfaceCloseCapture = { + identity, + holders: [{ hostId: 'local', identity }] + } + return { ...opened, originalPresentation } +} + +it.each(['successor', 'new holder'] as const)( + 'checks %s at queued admission, preserving membership and future commands', + async (change) => { + const { store, authority, originalPresentation } = await opened() + const pause = authority.pause() + const preceding = store.runDurableMutation(() => ({ value: undefined })) + await pause.started.promise + const closing = applyWindowLayoutCommand(store, () => ['local'], command, undefined, { + originalPresentation, + admit: () => true + }) + // A replacement commits before the close is admitted, after its caller already dispatched it. + const state = store.getWorkspaceSession() + if (change === 'new holder') { + store.setWorkspaceSession(state, 'ssh:new-holder') + } else { + store.setWorkspaceSession({ + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + [pane.worktreeId]: state.tabsByWorktree[pane.worktreeId].map((row) => ({ + ...row, + generation: (row.generation ?? 0) + 1 + })) + } + }) + } + const refused = expect(closing).rejects.toThrow('terminal_pane_owner_changed') + pause.finish.resolve() + await preceding + await refused + expect( + store.getWorkspaceSession().tabsByWorktree[pane.worktreeId].map((row) => row.id) + ).toContain(pane.tabId) + await expect( + store.applyLayoutCommand({ + type: 'renameTab', + workspace: pane.worktreeId, + tabId: pane.tabId, + kind: 'custom', + title: 'Still usable' + }) + ).resolves.toMatchObject({ ok: true }) + } +) + +it('requires the captured close write, retains the applied removal on failure and reloads its later durable intent', async () => { + const { store, authority, originalPresentation, readState, directory, path } = await opened() + authority.failNextWrite() + await expect( + applyWindowLayoutCommand(store, () => ['local'], command, undefined, { + originalPresentation, + admit: () => true + }) + ).rejects.toThrow('profile_state_write_failed') + expect(store.getWorkspaceSession().tabsByWorktree[pane.worktreeId].map((row) => row.id)).toEqual([ + 'tab-local' + ]) + expect( + readState().workspaceSession.tabsByWorktree[pane.worktreeId].map( + (row: { id: string }) => row.id + ) + ).toContain(pane.tabId) + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + const reloaded = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority(path, 'coordination-test') + }) + try { + expect( + reloaded.getWorkspaceSession().tabsByWorktree[pane.worktreeId].map((row) => row.id) + ).toEqual(['tab-local']) + expect( + reloaded.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[pane.tabId] + ).toBeDefined() + } finally { + await reloaded.freezeWritesAsync() + } +}) diff --git a/src/main/runtime/mobile-session-tab-close-outcome.ts b/src/main/runtime/mobile-session-tab-close-outcome.ts index 214016b2c7b0..4da177919330 100644 --- a/src/main/runtime/mobile-session-tab-close-outcome.ts +++ b/src/main/runtime/mobile-session-tab-close-outcome.ts @@ -1,3 +1,9 @@ +import type { TerminalSurfaceCloseCapture } from '../../shared/terminal-tab-close' +import type { + RuntimeMobileSessionRetiredTerminalSurface, + RuntimeSessionTabCloseReason +} from '../../shared/runtime-types' +import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' import type { RuntimeMobileSessionTabCloseResult } from '../../shared/runtime-types' import type { ClientSessionTabSelectionStore } from './client-session-tab-selection' @@ -56,3 +62,16 @@ export function refusedMobileSessionTabClose( ...(options.snapshotRepublished ? { snapshotRepublished: true as const } : {}) } as MobileSessionTabCloseOutcome } + +export type MobileSessionTabCloseOptions = { + originalPresentation?: TerminalSurfaceCloseCapture + rendererOwnsPresentation?: boolean + retirementProofs?: RuntimeMobileSessionRetiredTerminalSurface[] + reason?: RuntimeSessionTabCloseReason + expectedPublicationEpoch?: string + expectedTerminalHandle?: string + clientNavigationId?: string + localPtyTeardownOwnedExternally?: boolean + expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority + force?: boolean +} diff --git a/src/main/runtime/mobile-session-terminal-retirement.ts b/src/main/runtime/mobile-session-terminal-retirement.ts index cd9f8d2d3cb4..fb36909f5216 100644 --- a/src/main/runtime/mobile-session-terminal-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-retirement.ts @@ -83,6 +83,8 @@ export function retireLeavesFromTerminalLayout( layout.expandedLeafId && retainedLeafIdSet.has(layout.expandedLeafId) ? layout.expandedLeafId : null, + chatLeafId: + layout.chatLeafId && !retiredLeafIds.has(layout.chatLeafId) ? layout.chatLeafId : undefined, ptyIdsByLeafId: omitLeafRecords(layout.ptyIdsByLeafId, retiredLeafIds), buffersByLeafId: omitLeafRecords(layout.buffersByLeafId, retiredLeafIds), scrollbackRefsByLeafId: omitLeafRecords(layout.scrollbackRefsByLeafId, retiredLeafIds), diff --git a/src/main/runtime/orca-runtime-original-terminal-close.test.ts b/src/main/runtime/orca-runtime-original-terminal-close.test.ts new file mode 100644 index 000000000000..a643d32fd704 --- /dev/null +++ b/src/main/runtime/orca-runtime-original-terminal-close.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import { terminalTreeHasLeaf } from '../../shared/terminal-tab-close' +import { + createHarness, + INCARNATION_ID, + LEAF_ID, + PTY_ID, + SIBLING_LEAF_ID, + SIBLING_PTY_ID, + TAB_ID, + WORKTREE_ID +} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' + +function captured(harness: ReturnType) { + const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (!source.presentation || !source.incarnationId) { + throw new Error('Missing original source') + } + return { + source, + close: () => + harness.runtime.closeTerminal(source.handle, source.incarnationId, source.presentation) + } +} + +function addSibling(harness: ReturnType) { + harness.editSession((state) => ({ + ...state, + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [TAB_ID]: { + ...state.terminalLayoutsByTabId[TAB_ID], + root: { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SIBLING_LEAF_ID } + }, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SIBLING_LEAF_ID]: SIBLING_PTY_ID }, + activeLeafId: SIBLING_LEAF_ID, + expandedLeafId: SIBLING_LEAF_ID + } + } + })) +} + +describe('original-aware terminal close owner transaction', () => { + it('stops only the source and keeps membership until explicit owner retirement', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const { close } = captured(harness) + harness.setStopAndWaitAction(async () => { + await harness.runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + expect( + terminalTreeHasLeaf(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root, LEAF_ID) + ).toBe(true) + }) + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => {}) + await expect(close()).resolves.toMatchObject({ ptyKilled: true }) + expect(harness.stopAndWait).toHaveBeenCalledExactlyOnceWith( + PTY_ID, + expect.objectContaining({ expectedIncarnationId: INCARNATION_ID }) + ) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(harness.getSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeDefined() + }) + + it.each([false, true])( + 'accepts earned exit without another stop (readable=%s)', + async (readable) => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const { close, source } = captured(harness) + const settleSleep = readable + ? harness.runtime.intentionalPtyStops.mark(PTY_ID, 'reversible', INCARNATION_ID) + : null + await harness.runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + settleSleep?.(true) + expect(harness.runtime.getTerminalHandleForPaneKey(`${TAB_ID}:${LEAF_ID}`)).toBe( + source.handle + ) + if (readable) { + harness.runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Ended source', + activeLeafId: LEAF_ID, + layout: { type: 'leaf', leafId: LEAF_ID } + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 7, + ptyId: null + } + ] + }) + expect(harness.runtime.getTerminalHandleForPaneKey(`${TAB_ID}:${LEAF_ID}`)).toBe( + source.handle + ) + harness.editSession((state) => ({ + ...state, + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [TAB_ID]: { ...state.terminalLayoutsByTabId[TAB_ID], ptyIdsByLeafId: {} } + } + })) + } + // SAFETY: this invokes the actual archive removal to exercise earned exit with no PTY record. + const archive = harness.runtime as unknown as { dropDisconnectedPtyRecord(id: string): void } + archive.dropDisconnectedPtyRecord(PTY_ID) + if (readable) { + expect( + harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID).handle + ).toBe(source.handle) + } + harness.setCloseTerminalTabAction(() => {}) + await expect(close()).resolves.toMatchObject({ ptyKilled: true }) + expect(harness.stopAndWait).not.toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalled() + } + ) + + it('does not retire a successor published during the required durable write', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const { close } = captured(harness) + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => {}) + harness.flushOrThrow.mockImplementationOnce(() => { + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'successor-after-admission' + }) + }) + await expect(close()).rejects.toThrow('terminal_pane_owner_changed') + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.stopAndWait).toHaveBeenCalledTimes(1) + }) + + it('retains an unknown source and refuses a pinned final tab before stopping', async () => { + const harness = createHarness({ registerPtyBacked: true }) + const { close } = captured(harness) + await expect(close()).rejects.toThrow('terminal_close_unverifiable') + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + harness.editSession((state) => ({ + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((row) => ({ ...row, isPinned: true })) + } + })) + await expect(close()).rejects.toThrow('terminal_tab_pinned') + expect(harness.stopAndWait).toHaveBeenCalledTimes(1) + }) + + it.each(['sibling', 'successor', 'write failure'] as const)( + 'revalidates %s after the acknowledged owner await', + async (change) => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const { close } = captured(harness) + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => { + addSibling(harness) + if (change === 'successor') { + harness.replacePersistedIncarnation('incarnation-successor') + } + if (change === 'write failure') { + harness.rejectPersistenceFlush(new Error('required write failed')) + } + }) + const result = close() + if (change === 'successor') { + await expect(result).rejects.toThrow('terminal_pane_owner_changed') + } else if (change === 'write failure') { + await expect(result).rejects.toThrow('required write failed') + } else { + await expect(result).resolves.toMatchObject({ ptyKilled: true }) + } + const layout = harness.getSession().terminalLayoutsByTabId[TAB_ID] + expect(terminalTreeHasLeaf(layout?.root, SIBLING_LEAF_ID)).toBe(true) + expect(layout?.ptyIdsByLeafId?.[SIBLING_LEAF_ID]).toBe(SIBLING_PTY_ID) + expect(terminalTreeHasLeaf(layout?.root, LEAF_ID)).toBe(change === 'successor') + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.stopAndWait).toHaveBeenCalledTimes(1) + } + ) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3481cdb7d5ea..b1fe8f951e19 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,7 +7,6 @@ import { } from './sidebar-inputs/orca-runtime-sidebar-temporal' import { OrcaRuntimeWithAgentTransitions } from './orca-runtime-agent-transitions' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch' -import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' import type { AgentLaunchTabPublished, @@ -34,8 +33,7 @@ import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-r import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' -import { rekeyMovedPaneOwners } from './moved-pane-owner-rekey' -import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' +import type { WindowLayoutCommand, LayoutCommandRunOptions } from './runtime-layout-apply' import type { TerminalCreateOptions } from './runtime-terminal-contracts' import { installStructuredAgentAvailability, @@ -271,24 +269,11 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { } /** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */ - async applyLayoutCommand(command: WindowLayoutCommand): Promise { - // A workspace's first surface comes after the live terminals main is placing. - const placing = this.livePlacementSettledFor(command) - const apply = (): Promise => - applyWindowLayoutCommand( - this.requireStore(), - (worktreeId, tabId) => - tabId !== null - ? this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) - : [this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ?? []].flat(), - command, - (move) => rekeyMovedPaneOwners(this, move), - this.layoutCommandRun(command) - ) - // A window's sleep or wake lands after the hook statuses main accepted before it, as in the window. - const reply = await (ORCA_LAYOUT_OWNER_IS_MAIN && ['sleep', 'wake'].includes(command.type) - ? this.inResumeRecordOrder(apply) - : (placing?.then(apply) ?? apply())) + async applyLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise { + const reply = await this.applyOwnedWindowLayoutCommand(command, options) // Sent before the reply, so the window can wait for the frame that shows the change. return { ...reply, frameSeq: this.workspaceLayoutStream.settledSeq() } } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 8ac99809cd3b..7f80896b51f1 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -13,6 +13,7 @@ import { TerminalCreateParams, TerminalFocus, TerminalHandle, + TerminalClose, TerminalReveal, TerminalSplit, TerminalWait @@ -225,14 +226,19 @@ export const TERMINAL_LIFECYCLE_METHODS = [ defineMethod({ name: 'terminal.close', permission: 'workspace', - params: TerminalHandle, + params: TerminalClose, handler: async (params, context) => ({ close: await withTerminalCloseAttribution( 'terminal.close', context, 'terminal', params.terminal, - () => context.runtime.closeTerminal(params.terminal, params.expectedIncarnationId) + () => + context.runtime.closeTerminal( + params.terminal, + params.expectedIncarnationId, + params.originalPresentation + ) ) }) }), diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index c9bba8572f88..77a0c5c2bf64 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -91,7 +91,8 @@ export async function applyWindowLayoutCommand( const terminalTabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabIds[0]) const reply = await store.applyLayoutCommand( { ...command, force: command.refusePinned ? command.force === true : true }, - holders + holders, + run ) // Only a tab the close took: a refused (pinned) tab's launches still finish into it. if ( @@ -148,10 +149,13 @@ export async function applyWindowLayoutCommand( } const { workspace, tabId, leafId } = command // A launch still starting in the pane the user closed stops, and says so (as the legacy close). - markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) + if (!run?.originalPresentation) { + markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) + } return store.applyLayoutCommand( { type: 'closePane', workspace, tabId, leafId, userPaneClose: true }, - holders + holders, + run ) } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index ce5f8b1a994d..cc0feaa93811 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' import type { RuntimeEditorTabReveal, RuntimeFileOpenPosition @@ -156,7 +157,11 @@ export type RuntimeNotifier = { closeTerminalPane?(tabId: string, leafId: string): void closeTerminalTab?( tabId: string, - options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } + options?: { + localPtyTeardownOwnedExternally?: boolean + force?: boolean + originalSurface?: TerminalSurfaceCloseIdentity + } ): Promise sleepWorktree(worktreeId: string): void // Why: a phone opening a worktree wakes its slept agents by asking the host diff --git a/src/main/runtime/session-graph/session-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts index 00455e310351..47db78312bca 100644 --- a/src/main/runtime/session-graph/session-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -206,7 +206,7 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAtta const adoptedFirstPty = existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) if (!adoptedFirstPty) { - this.invalidateLeafHandle(leafKey) + this.invalidateLeafHandle(leafKey, ptyId === null) } } } diff --git a/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts b/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts index dced249762f1..05e4fe0ba186 100644 --- a/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts +++ b/src/main/runtime/session-lifecycle/close/close-mobile-row-in-main-layout.ts @@ -1,3 +1,4 @@ +import type { MobileSessionLifecycleCloseHost } from '../../mobile-session-lifecycle-close-adjudication' // Layout-owner builds: a phone's close of a terminal row in a workspace main owns is main's own // layout close, window or not, as the CLI's close by handle is. import { OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose } from './refuse-unattributed-mobile-session-tab-close' @@ -95,4 +96,14 @@ export abstract class OrcaRuntimeWithCloseMobileRowInMainLayout extends OrcaRunt this.notifyMobileSessionTabsChanged(worktreeId) } } + protected getMobileSessionLifecycleCloseHost(): MobileSessionLifecycleCloseHost { + return { + tabs: this.tabs, + leaves: this.leaves, + ptysById: this.ptysById, + findPtyForMobileTerminalTab: (worktreeId, tab) => + this.findPtyForMobileTerminalTab(worktreeId, tab), + republishSnapshot: (worktreeId) => this.republishMobileSessionTabsSnapshot(worktreeId) + } + } } diff --git a/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts b/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts index 1418c0ca8cc3..ceb66b96a29f 100644 --- a/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts +++ b/src/main/runtime/session-lifecycle/close/close-mobile-session-tab.ts @@ -1,15 +1,13 @@ import { OrcaRuntimeWithCloseMobileRowInMainLayout } from './close-mobile-row-in-main-layout' -import type { - RuntimeMobileSessionTerminalTab, - RuntimeSessionTabCloseReason -} from '../../../../shared/runtime-types' -import type { RuntimePtyTabCloseAuthority } from '../../runtime-terminal-state-records' +import type { RuntimeMobileSessionTerminalTab } from '../../../../shared/runtime-types' import { adjudicateAbsentMobileSessionTabClose, - resolveMobileSessionLifecycleCloseContext, - type MobileSessionLifecycleCloseHost + resolveMobileSessionLifecycleCloseContext } from '../../mobile-session-lifecycle-close-adjudication' -import type { MobileSessionTabCloseOutcome } from '../../mobile-session-tab-close-outcome' +import type { + MobileSessionTabCloseOptions, + MobileSessionTabCloseOutcome +} from '../../mobile-session-tab-close-outcome' import { committedMobileSessionTabClose, delegatedMobileSessionTabClose, @@ -29,20 +27,32 @@ export abstract class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWi async closeMobileSessionTab( worktreeSelector: string, tabId: string, - options: { - reason?: RuntimeSessionTabCloseReason - expectedPublicationEpoch?: string - expectedTerminalHandle?: string - clientNavigationId?: string - localPtyTeardownOwnedExternally?: boolean - expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority - force?: boolean - } = {} + options: MobileSessionTabCloseOptions = {} ): Promise { const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null const explicitWorktreeId = this.getExplicitSessionWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id + if (options.originalPresentation) { + const source = options.originalPresentation.identity + if ( + source.worktreeId !== worktreeId || + source.tabId !== tabId || + !options.localPtyTeardownOwnedExternally + ) { + throw new Error('terminal_pane_owner_changed') + } + const closedTab = await this.closeTerminalPane( + worktreeId, + { kind: 'pane', tabId, leafId: source.leafId }, + options + ) + return committedMobileSessionTabClose( + this.clientSessionTabSelections, + worktreeId, + closedTab ? [tabId] : [] + ) + } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() if (graphEpoch !== null) { @@ -325,15 +335,4 @@ export abstract class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWi } return finishCommittedClose() } - - protected getMobileSessionLifecycleCloseHost(): MobileSessionLifecycleCloseHost { - return { - tabs: this.tabs, - leaves: this.leaves, - ptysById: this.ptysById, - findPtyForMobileTerminalTab: (worktreeId, tab) => - this.findPtyForMobileTerminalTab(worktreeId, tab), - republishSnapshot: (worktreeId) => this.republishMobileSessionTabsSnapshot(worktreeId) - } - } } diff --git a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts index 1e2d923cafe3..4acc108a1b27 100644 --- a/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts @@ -1,4 +1,7 @@ -import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from '../../session-tabs/terminal-surface-retirements' +import { windowListsTab } from '../../terminal/surface/main-terminal-surfaces' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { matchesTerminalSurfaceCapture } from '../../../../shared/terminal-tab-close' +import { OrcaRuntimeWithTerminalSurfaceCloseCommit } from '../../session-tabs/terminal-surface-close-commit' import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsResult, @@ -9,25 +12,21 @@ import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry' import { holdAgentSessionInventory } from '../../structured-agent-session-inventory-hold' import type { Tab } from '../../../../shared/tab-types' -import { - captureTerminalCloseRequest, - resolveTerminalCloseTarget, - type PaneCloseResolution, - type RendererTerminalClose, - type TerminalSurfaceCloseOptions -} from '../../terminal-surface-close' -import type { - TerminalPaneCloseTarget, - TerminalSurfaceCloseTarget -} from '../../../../shared/terminal-surface-close-target' +import type { RendererTerminalClose } from '../../terminal-surface-close' +import type { TerminalPaneCloseTarget } from '../../../../shared/terminal-surface-close-target' import { retireTerminalSurfacesFromSnapshot } from '../../mobile-session-terminal-retirement' import type { PtyControllerInventory } from '../../runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { captureAcknowledgedTerminalTabRetirement } from '../../workspace-session-terminal-tab-retirement-identity' -import { closeLeafOrTab } from '../../../persistence/terminal-topology/terminal-topology-commit' import { markAgentLaunchesClosedByUser } from '../../../agent-launch/agent-launch-pane-attachment' -export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { +import type { MobileSessionTabCloseOptions } from '../../mobile-session-tab-close-outcome' + +type TerminalPaneRetirementOptions = Pick< + MobileSessionTabCloseOptions, + 'originalPresentation' | 'rendererOwnsPresentation' | 'retirementProofs' +> + +export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithTerminalSurfaceCloseCommit { // Why: headless serve backs browser panes with offscreen WebContents that live // only in the BrowserManager, never in a renderer graph. Without surfacing them // as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the @@ -99,128 +98,93 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten return tab ? { color: tab.color, isPinned: tab.isPinned } : null } - protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { - const originalHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) - return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { - const resolvedHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) - const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) - // Emptying the last tab may reroute the worktree to its catalog host. - const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) - ? resolvedHostId - : originalHostId - return { - hostId, - session: this.store?.getWorkspaceSession?.(hostId) ?? null, - snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), - incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId - } - }) - } - - /** - * The one close transaction every explicit terminal close reaches: durably commits the membership - * removal in the owning host's partition. Callers publish and kill afterwards. - */ - protected async closeTerminalSurface( - worktreeId: string, - target: TerminalSurfaceCloseTarget, - options: TerminalSurfaceCloseOptions = {} - ): Promise { - const store = this.store - if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { - throw new Error('workspace_session_unavailable') - } - // Why: a tab its layout owner already removed has no newer owner; refusing would only strand it. - const acknowledgeTabRetirement = - target.kind === 'tab' && !options.closedByLayoutOwner - ? this.captureTerminalTabRetirement(worktreeId, target.tabId) - : null - let ptyIdsToKill: string[] = [] - let refusal: Error | undefined - const hostIds = () => this.getWorkspaceSessionHostIdsForTab(worktreeId, target.tabId) - const fenced = acknowledgeTabRetirement !== null - try { - refusal = await store.runDurableMutation( - closeLeafOrTab({ - worktreeId, - target, - options, - ...captureTerminalCloseRequest( - worktreeId, - target, - hostIds(), - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing guard requires this getter; preserve late-bound store access. - store as Parameters[3], - fenced - ), - ownerMatches: () => !acknowledgeTabRetirement || acknowledgeTabRetirement().matches, - hostIds, - getSession: (hostId) => store.getWorkspaceSession!(hostId), - setSession: (session, hostId) => store.setWorkspaceSession!(session, hostId), - onClosed: (closedPtyIds) => { - ptyIdsToKill = closedPtyIds - } - }) - ) - } catch (error) { - console.error('[runtime] failed to persist terminal close:', error) - } - if (refusal) { - throw refusal - } - return ptyIdsToKill - } - /** The desktop renderer's close intent: it already guarded, removed and killed; this only reports. */ async closeTerminalSurfaceFromRenderer({ worktreeId, target, reason }: RendererTerminalClose) { const options = { allowMissing: true, force: true, closedByLayoutOwner: true, reason } await this.closeTerminalSurface(worktreeId, target, options) } - /** Resolves a close main started against the copy of the tab's panes its layout owner holds. */ - protected resolveTerminalCloseTarget( - worktreeId: string, - target: TerminalSurfaceCloseTarget - ): PaneCloseResolution | 'tab' { - const graphLeafIds: string[] = [] - for (const leaf of this.leaves.values()) { - if (leaf.tabId === target.tabId) { - graphLeafIds.push(leaf.leafId) - } - } - return resolveTerminalCloseTarget(target, { - rendererListsTab: this.tabs.has(target.tabId), - snapshotRows: (this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs ?? []).filter( - (row): row is RuntimeMobileSessionTerminalTab => - row.type === 'terminal' && row.parentTabId === target.tabId - ), - graphLeafIds, - sessionLayout: - this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId?.[target.tabId] - }) - } - /** * Commits a split pane's close that main started, then tells the desktop renderer to drop that * pane. Never touches the tab: a pane the session no longer lists commits nothing. */ protected async closeTerminalPane( worktreeId: string, - target: TerminalPaneCloseTarget - ): Promise { + target: TerminalPaneCloseTarget, + options: TerminalPaneRetirementOptions = {} + ): Promise { + let closedTab = false + if ( + options.originalPresentation && + !this.originalTerminalExecutionMatches(options.originalPresentation.identity) + ) { + throw new Error('terminal_pane_owner_changed') + } + if ( + options.originalPresentation && + (options.rendererOwnsPresentation || windowListsTab(this, this.tabs, target.tabId)) + ) { + if (!this.notifier?.closeTerminalTab) { + throw new Error('runtime_unavailable') + } + await this.notifier.closeTerminalTab(target.tabId, { + localPtyTeardownOwnedExternally: true, + originalSurface: options.originalPresentation.identity + }) + } // An explicit close (a phone's, the CLI's): a launch still starting or delivering there stops. - markAgentLaunchesClosedByUser(worktreeId, target) + if (!options.originalPresentation) { + markAgentLaunchesClosedByUser(worktreeId, target) + } try { - await this.closeTerminalSurface(worktreeId, target, { allowMissing: true }) + if (ORCA_LAYOUT_OWNER_IS_MAIN && options.originalPresentation) { + const reply = await this.applyLayoutCommand( + { + type: 'closePane', + workspace: worktreeId, + tabId: target.tabId, + leafId: target.leafId + }, + { + originalPresentation: options.originalPresentation, + admit: () => + this.originalTerminalExecutionMatches(options.originalPresentation!.identity) + } + ) + if (!reply.ok) { + throw new Error(reply.code) + } + closedTab = reply.result.closed?.includes(target.tabId) === true + } else { + await this.closeTerminalSurface(worktreeId, target, { + allowMissing: true, + ...options, + onClosed: (tab) => { + closedTab = tab + } + }) + } } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + if ( + options.originalPresentation || + !(error instanceof Error) || + error.message !== 'workspace_session_unavailable' + ) { throw error } } // Why: no exit may ever arrive to remove the pane. The notice is leaf-addressed, so it and the // renderer's exit handling are each a no-op after the other. - this.retireClosedTerminalLeafFromMobileSnapshot(worktreeId, target.tabId, target.leafId) - this.notifier?.closeTerminalPane?.(target.tabId, target.leafId) + this.retireClosedTerminalLeafFromMobileSnapshot( + worktreeId, + target.tabId, + target.leafId, + options + ) + if (!options.originalPresentation) { + this.notifier?.closeTerminalPane?.(target.tabId, target.leafId) + } + return closedTab } /** A paired client's close of one pane: stops only that pane's process, commits only that pane. */ @@ -246,7 +210,8 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten private retireClosedTerminalLeafFromMobileSnapshot( worktreeId: string, tabId: string, - leafId: string + leafId: string, + options: TerminalPaneRetirementOptions = {} ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( @@ -255,17 +220,47 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten candidate.parentTabId === tabId && candidate.leafId === leafId ) - // Why: a renderer that lists the tab republishes its own snapshot once it drops the pane. - if (!snapshot || !tab || this.tabs.has(tabId)) { + if ( + options.originalPresentation && + (!this.originalTerminalExecutionMatches(options.originalPresentation.identity) || + !matchesTerminalSurfaceCapture( + options.originalPresentation, + this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId), + (hostId) => this.store?.getWorkspaceSession?.(hostId) + )) + ) { + throw new Error('terminal_pane_owner_changed') + } + const original = options.originalPresentation?.identity + const bound = tab?.ptyId ?? tab?.parentLayout?.ptyIdsByLeafId?.[leafId] + if ( + original && + ((bound && bound !== original.ptyId) || + (tab?.incarnationId && tab.incarnationId !== original.incarnationId)) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (original?.ptyId) { + const pty = this.ptysById.get(original.ptyId) + if (pty) { + pty.runtimeSessionOwned = false + } + this.setPairedRendererSessionOwnership(original.ptyId, false) + } + // A renderer can publish removal while its required durable close is awaiting completion. + if (!snapshot || !tab || (!original && this.tabs.has(tabId))) { + if (original) { + this.publishRetiredTerminalSurfaceProofs(worktreeId, options.retirementProofs ?? []) + } return } - const proof = this.getMobileSessionTerminalRetirementProof(worktreeId, tab) + const proof = !original && this.getMobileSessionTerminalRetirementProof(worktreeId, tab) const retired = retireTerminalSurfacesFromSnapshot({ snapshot, - ptyId: tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[leafId] ?? '', + ptyId: original?.ptyId ?? tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[leafId] ?? '', exactSurfaces: [{ parentTabId: tabId, leafId }], exactOnly: true, - ...(proof ? { retirementProofs: [proof] } : {}) + retirementProofs: options.retirementProofs ?? (proof ? [proof] : []) }) if (retired) { this.storeMobileSessionSnapshot(worktreeId, retired.snapshot) diff --git a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts index 798684ab2f2d..d16b5ef49414 100644 --- a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts @@ -317,6 +317,11 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or if (leaf?.ptyId) { return this.issueHandle(leaf) } - return panePty ? this.issuePtyHandle(panePty) : null + if (panePty) { + return this.issuePtyHandle(panePty) + } + const ended = leaf ? this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) : null + const ptyId = ended ? this.handles.get(ended)?.ptyId : null + return ptyId && this.getPtyLivenessVerdict(ptyId)?.status === 'exited' ? (ended ?? null) : null } } diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts index aa8eac1964bd..f0fa321efa6c 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-pty-stops.ts @@ -20,42 +20,62 @@ export abstract class OrcaRuntimeWithExplicitCloseTabPtyStops extends OrcaRuntim for (const ptyId of ptyIds) { this.markPtyStopRequested(ptyId) const expectedIncarnationId = capturedIncarnationId ?? this.ptysById.get(ptyId)?.incarnationId - let stopped = false - if (this.ptyController?.stopAndWait) { - try { - stopped = await this.ptyController.stopAndWait(ptyId, { - deadlineMs, - ...(capturedIncarnationId ? { expectedIncarnationId: capturedIncarnationId } : {}) - }) - } catch (error) { - this.markPtyLivenessUnverifiable(ptyId, errorMessage(error)) - } - // Preserve an observed exit when a broader inventory check could not finish. - if ( - !stopped && - expectedIncarnationId && - this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId && - this.getPtyLivenessVerdict(ptyId)?.status === 'exited' - ) { - stopped = true - } - if (!stopped && !capturedIncarnationId) { - const verdict = this.getPtyLivenessVerdict(ptyId) - const providerAlreadyRetiredPty = - verdict?.status === 'unverifiable' && - verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON - if (!providerAlreadyRetiredPty) { - this.ptyController.kill(ptyId) - if (!verdict || verdict.status === 'live') { - this.markPtyLivenessUnverifiable( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) + const current = this.ptysById.get(ptyId) + const replacement = this.pendingPtyHandleReplacementFences.get(ptyId) + let stopped = + !!capturedIncarnationId && + (!current || current.incarnationId === capturedIncarnationId) && + (!replacement || replacement.incarnationId === capturedIncarnationId) && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + const settle = capturedIncarnationId + ? this.intentionalPtyStops.mark(ptyId, 'closing', capturedIncarnationId) + : null + try { + if (!stopped && this.ptyController?.stopAndWait) { + try { + stopped = await this.ptyController.stopAndWait(ptyId, { + deadlineMs, + ...(capturedIncarnationId ? { expectedIncarnationId: capturedIncarnationId } : {}) + }) + } catch (error) { + this.markPtyLivenessUnverifiable(ptyId, errorMessage(error)) + } + // Preserve an observed exit when a broader inventory check could not finish. + if ( + !stopped && + expectedIncarnationId && + (!this.ptysById.has(ptyId) || + this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId) && + (!this.pendingPtyHandleReplacementFences.has(ptyId) || + this.pendingPtyHandleReplacementFences.get(ptyId)?.incarnationId === + expectedIncarnationId) && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + ) { + stopped = true + } + if (!stopped && !capturedIncarnationId) { + const verdict = this.getPtyLivenessVerdict(ptyId) + const providerAlreadyRetiredPty = + verdict?.status === 'unverifiable' && + verdict.reason === SSH_PROVIDER_UNREGISTERED_REASON + if (!providerAlreadyRetiredPty) { + this.ptyController.kill(ptyId) + if (!verdict || verdict.status === 'live') { + this.markPtyLivenessUnverifiable( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + } } } + } else if (!capturedIncarnationId) { + stopped = this.ptyController?.kill(ptyId) ?? false + } + } finally { + settle?.(stopped) + if (capturedIncarnationId && !stopped) { + this.clearPtyStopRequest(ptyId) } - } else if (!capturedIncarnationId) { - stopped = this.ptyController?.kill(ptyId) ?? false } if (ptyId === addressedPtyId) { addressedPtyStop = { stopped } diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts index 7c0d94f92f2d..65388946a01d 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts @@ -1,3 +1,10 @@ +import { windowListsTab } from '../terminal/surface/main-terminal-surfaces' +import { isOriginalTerminalHandle } from '../workspace-session-terminal-tab-retirement-identity' +import { NO_STOP } from './explicit-terminal-close-pty-stops' +import { + matchesTerminalSurfaceCapture, + type TerminalSurfaceCloseCapture +} from '../../../shared/terminal-tab-close' import type { WorktreePtyHostFence } from '../worktree-pty-host-fence' import type { RuntimeTerminalClose } from '../../../shared/runtime-types' import type { RuntimePtyTabCloseAuthority } from '../runtime-terminal-state-records' @@ -7,16 +14,14 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' import type { WindowLayoutCommand } from '../runtime-layout-apply' import { closeMainOwnedPaneByHandle } from './main-handle-close' +import { OrcaRuntimeWithExplicitTerminalTabClose } from './explicit-terminal-tab-close' import { - NO_STOP, - OrcaRuntimeWithExplicitCloseTabPtyStops -} from './explicit-terminal-close-pty-stops' -import { - closePairedHostLeafFirst, + closeTerminalOnPairedHost, + getPairedHostTerminal, isTerminalTabPinnedInSession } from './paired-host-terminal-close' -export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithExplicitCloseTabPtyStops { +export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithExplicitTerminalTabClose { protected abstract getResolvedWorktreeMap(): Promise> abstract applyLayoutCommand(command: WindowLayoutCommand): Promise protected abstract refreshPtyWorktreeRecordsFromController( @@ -43,58 +48,94 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun terminalHandlesByPtyId: Readonly> }): void - /** null when this machine owns the PTY; see `closePairedHostLeafFirst` otherwise. */ - private closeOnOwningPairedHost( + async closeTerminal( handle: string, - leaf: { worktreeId: string; tabId: string; leafId: string; ptyId: string | null }, - method: 'terminal.close' | 'terminal.closeTab', - closesTab: boolean - ): ReturnType { - return closePairedHostLeafFirst(leaf, method, { - call: (environmentId, hostMethod, params, timeoutMs) => - this.callOrchestrationWorkerServer(environmentId, hostMethod, params, timeoutMs), - isTabPinned: () => - closesTab && - this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId).some((hostId) => - isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(hostId), leaf) - ), - readBoundLeaf: () => this.getLiveLeafForHandle(handle).leaf - }) - } - - async closeTerminal(handle: string, incarnation?: string): Promise { + incarnation?: string, + originalPresentation?: TerminalSurfaceCloseCapture + ): Promise { const pty = this.getLivePtyForHandle(handle) - const addressedPty = - pty?.pty ?? - (incarnation - ? this.ptysById.get(this.getLiveLeafForHandle(handle).leaf.ptyId ?? '') - : undefined) - // The handle's fence: a replacement execution under the same handle is never closed. - if (incarnation && addressedPty?.incarnationId !== incarnation) { - throw new Error('terminal_handle_stale') - } - // A fenced switch stops its captured execution before any presentation await. - const capturedStop = - incarnation && addressedPty - ? await this.stopExplicitlyClosedTabPtys( - [addressedPty.ptyId], - addressedPty.ptyId, - incarnation + if (originalPresentation) { + const remote = getPairedHostTerminal(pty?.pty.ptyId ?? this.handles.get(handle)?.ptyId) + if (remote) { + const leaf = this.getLiveLeafForHandle(handle).leaf + const pane = { kind: 'pane' as const, tabId: leaf.tabId, leafId: leaf.leafId } + if ( + this.resolveTerminalCloseTarget(leaf.worktreeId, pane) === 'last-pane' && + this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId).some((id) => + isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(id), leaf) ) - : null - if ( - incarnation && - (!capturedStop?.stopped || - (addressedPty && - this.ptysById.has(addressedPty.ptyId) && - this.ptysById.get(addressedPty.ptyId)?.incarnationId !== incarnation)) - ) { - throw new Error('terminal_close_unverifiable') + ) { + throw new Error('terminal_tab_pinned') + } + const close = await closeTerminalOnPairedHost( + (...args) => this.callOrchestrationWorkerServer(...args), + remote, + 'terminal.close', + { expectedIncarnationId: incarnation, originalPresentation } + ) + return { handle, tabId: leaf.tabId, ...close } + } + const source = originalPresentation.identity + const record = this.handles.get(handle) + const currentPty = source.ptyId ? this.ptysById.get(source.ptyId) : null + const leaf = this.leaves.get(this.getLeafKey(source.tabId, source.leafId)) + if ( + !source.ptyId || + !incarnation || + !isOriginalTerminalHandle({ + runtimeId: this.runtimeId, + record, + currentPty, + leaf, + source, + incarnation + }) || + !matchesTerminalSurfaceCapture( + originalPresentation, + this.getWorkspaceSessionHostIdsForTab(source.worktreeId, source.tabId), + (hostId) => this.store?.getWorkspaceSession?.(hostId) + ) + ) { + throw new Error('terminal_handle_stale') + } + const target = { kind: 'pane' as const, tabId: source.tabId, leafId: source.leafId } + const rendererOwnsPresentation = windowListsTab(this, this.tabs, source.tabId) + const proof = { + parentTabId: source.tabId, + leafId: source.leafId, + ptyId: source.ptyId, + terminal: handle, + incarnationId: incarnation + } + if ( + this.resolveTerminalCloseTarget(source.worktreeId, target) === 'last-pane' && + this.getWorkspaceSessionHostIdsForTab(source.worktreeId, source.tabId).some((hostId) => + isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(hostId), source) + ) + ) { + throw new Error('terminal_tab_pinned') + } + const stop = await this.stopExplicitlyClosedTabPtys([source.ptyId], source.ptyId, incarnation) + if (!stop.stopped) { + throw new Error('terminal_close_unverifiable') + } + const outcome = await this.closeMobileSessionTab(`id:${source.worktreeId}`, source.tabId, { + originalPresentation, + rendererOwnsPresentation, + localPtyTeardownOwnedExternally: true, + retirementProofs: [proof] + }) + if (outcome.refused) { + throw new Error('terminal_pane_close_failed') + } + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return this.describeTerminalClose(handle, source.tabId, source.ptyId, stop) + } + if (incarnation && pty?.pty.incarnationId !== incarnation) { + throw new Error('terminal_handle_stale') } - const stopCapturedOrCurrent = (ids: readonly string[], addressed: string) => - capturedStop - ? Promise.resolve(capturedStop) - : this.stopExplicitlyClosedTabPtys(ids, addressed) + const stopCurrent = (ids: readonly string[], addressed: string) => + this.stopExplicitlyClosedTabPtys(ids, addressed) // Switch point: a pane main owns closes in main's layout, whatever the window lists. if (ORCA_LAYOUT_OWNER_IS_MAIN) { const mainClose = await closeMainOwnedPaneByHandle( @@ -105,7 +146,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun remoteSessionIdOf: (worktreeId, tabId) => this.getWorkspaceSessionForWorktree(worktreeId)?.remoteSessionIdsByTabId?.[tabId], applyLayoutCommand: (command) => this.applyLayoutCommand(command), - stop: stopCapturedOrCurrent, + stop: stopCurrent, describe: (...args) => this.describeTerminalClose(...args) }, handle, @@ -153,7 +194,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } this.notifyRendererOfHeadlessTerminalClose(tabId) } - const stop = await stopCapturedOrCurrent(ptyIdsToKill, pty.pty.ptyId) + const stop = await stopCurrent(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } if (closesTab && surface && ptyCloseAuthority && !this.tabs.has(surface.tab.parentTabId)) { @@ -167,11 +208,11 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } - const stop = await stopCapturedOrCurrent([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCurrent([pty.pty.ptyId], pty.pty.ptyId) this.notifyRendererOfHeadlessTerminalClose(tabId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } - const stop = await stopCapturedOrCurrent([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCurrent([pty.pty.ptyId], pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } // Why the tabs guard: a headless host has no renderer tab to close through the notifier. @@ -194,11 +235,11 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun tabClosed = false } if (tabClosed) { - const stop = await stopCapturedOrCurrent(ptyIdsToKill, pty.pty.ptyId) + const stop = await stopCurrent(ptyIdsToKill, pty.pty.ptyId) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, stop) } } - const stop = await stopCapturedOrCurrent([pty.pty.ptyId], pty.pty.ptyId) + const stop = await stopCurrent([pty.pty.ptyId], pty.pty.ptyId) if (!closesTab) { // Why: the pane's removal is this close's own commit, not a side effect of its exit. An // unconfirmed stop is unverifiable, never a reason to close the live siblings with it. @@ -251,7 +292,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } return { handle, tabId: leaf.tabId, ...hostClose.close } } - const stop = leaf.ptyId ? await stopCapturedOrCurrent(ptyIdsToKill, leaf.ptyId) : NO_STOP + const stop = leaf.ptyId ? await stopCurrent(ptyIdsToKill, leaf.ptyId) : NO_STOP if (!closesTab) { await this.closeTerminalPane(leaf.worktreeId, paneTarget) } else if (!this.notifier?.closeTerminalTab) { @@ -259,60 +300,4 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun } return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, stop) } - - async closeTerminalTab(handle: string): Promise { - // Switch point: a pane main owns closes in main's layout, whatever the window lists. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - const mainClose = await closeMainOwnedPaneByHandle( - { - runtime: this, - livePty: () => this.getLivePtyForHandle(handle)?.pty ?? null, - liveLeaf: () => this.getLiveLeafForHandle(handle).leaf, - remoteSessionIdOf: (worktreeId, tabId) => - this.getWorkspaceSessionForWorktree(worktreeId)?.remoteSessionIdsByTabId?.[tabId], - applyLayoutCommand: (command) => this.applyLayoutCommand(command), - stop: (ptyIds, addressed) => this.stopExplicitlyClosedTabPtys(ptyIds, addressed), - describe: (...args) => this.describeTerminalClose(...args) - }, - handle, - true - ) - if (mainClose) { - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return mainClose - } - } - const pty = this.getLivePtyForHandle(handle) - if (pty) { - const closeAuthority: RuntimePtyTabCloseAuthority = { - handle, - ptyId: pty.pty.ptyId, - incarnationId: pty.pty.incarnationId, - worktreeId: pty.pty.worktreeId - } - const tabId = - this.resolvePtyTabCloseSurfaceAuthority(closeAuthority)?.surface.tab.parentTabId ?? - pty.pty.tabId - if (!tabId) { - return this.closeTerminal(handle) - } - // Why: a handle-addressed CLI/automation close is an explicit intent, so - // it must stay destructive under the non-user close adjudication gate. - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { - reason: 'user', - expectedPtyCloseAuthority: closeAuthority - }) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return { handle, tabId, closeMode: 'tab', ptyKilled: false } - } - this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) - const hostClose = await this.closeOnOwningPairedHost(handle, leaf, 'terminal.closeTab', true) - if (hostClose && !hostClose.stillBound) { - return { handle, tabId: leaf.tabId, ...hostClose.close, closeMode: 'tab' } - } - await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) - return { handle, tabId: leaf.tabId, ptyKilled: false, ...hostClose?.close, closeMode: 'tab' } - } } diff --git a/src/main/runtime/session-tabs/explicit-terminal-tab-close.ts b/src/main/runtime/session-tabs/explicit-terminal-tab-close.ts new file mode 100644 index 000000000000..3de2e2a0eeff --- /dev/null +++ b/src/main/runtime/session-tabs/explicit-terminal-tab-close.ts @@ -0,0 +1,90 @@ +import { OrcaRuntimeWithExplicitCloseTabPtyStops } from './explicit-terminal-close-pty-stops' +export { NO_STOP } from './explicit-terminal-close-pty-stops' +import type { RuntimeTerminalClose } from '../../../shared/runtime-types' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import type { RuntimePtyTabCloseAuthority } from '../runtime-terminal-state-records' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { closeMainOwnedPaneByHandle } from './main-handle-close' +import { + closePairedHostLeafFirst, + isTerminalTabPinnedInSession +} from './paired-host-terminal-close' + +export abstract class OrcaRuntimeWithExplicitTerminalTabClose extends OrcaRuntimeWithExplicitCloseTabPtyStops { + abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + /** null when this machine owns the PTY; see `closePairedHostLeafFirst` otherwise. */ + protected closeOnOwningPairedHost( + handle: string, + leaf: { worktreeId: string; tabId: string; leafId: string; ptyId: string | null }, + method: 'terminal.close' | 'terminal.closeTab', + closesTab: boolean + ): ReturnType { + return closePairedHostLeafFirst(leaf, method, { + call: (environmentId, hostMethod, params, timeoutMs) => + this.callOrchestrationWorkerServer(environmentId, hostMethod, params, timeoutMs), + isTabPinned: () => + closesTab && + this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId).some((hostId) => + isTerminalTabPinnedInSession(this.store?.getWorkspaceSession?.(hostId), leaf) + ), + readBoundLeaf: () => this.getLiveLeafForHandle(handle).leaf + }) + } + + async closeTerminalTab(handle: string): Promise { + // Switch point: a pane main owns closes in main's layout, whatever the window lists. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const mainClose = await closeMainOwnedPaneByHandle( + { + runtime: this, + livePty: () => this.getLivePtyForHandle(handle)?.pty ?? null, + liveLeaf: () => this.getLiveLeafForHandle(handle).leaf, + remoteSessionIdOf: (worktreeId, tabId) => + this.getWorkspaceSessionForWorktree(worktreeId)?.remoteSessionIdsByTabId?.[tabId], + applyLayoutCommand: (command) => this.applyLayoutCommand(command), + stop: (ptyIds, addressed) => this.stopExplicitlyClosedTabPtys(ptyIds, addressed), + describe: (...args) => this.describeTerminalClose(...args) + }, + handle, + true + ) + if (mainClose) { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return mainClose + } + } + const pty = this.getLivePtyForHandle(handle) + if (pty) { + const closeAuthority: RuntimePtyTabCloseAuthority = { + handle, + ptyId: pty.pty.ptyId, + incarnationId: pty.pty.incarnationId, + worktreeId: pty.pty.worktreeId + } + const tabId = + this.resolvePtyTabCloseSurfaceAuthority(closeAuthority)?.surface.tab.parentTabId ?? + pty.pty.tabId + if (!tabId) { + return this.closeTerminal(handle) + } + // Why: a handle-addressed CLI/automation close is an explicit intent, so + // it must stay destructive under the non-user close adjudication gate. + await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + reason: 'user', + expectedPtyCloseAuthority: closeAuthority + }) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId, closeMode: 'tab', ptyKilled: false } + } + this.assertGraphReady() + const { leaf } = this.getLiveLeafForHandle(handle) + const hostClose = await this.closeOnOwningPairedHost(handle, leaf, 'terminal.closeTab', true) + if (hostClose && !hostClose.stillBound) { + return { handle, tabId: leaf.tabId, ...hostClose.close, closeMode: 'tab' } + } + await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + return { handle, tabId: leaf.tabId, ptyKilled: false, ...hostClose?.close, closeMode: 'tab' } + } +} diff --git a/src/main/runtime/session-tabs/paired-host-terminal-close.ts b/src/main/runtime/session-tabs/paired-host-terminal-close.ts index 6cf1d7b63797..a942ad82b7b0 100644 --- a/src/main/runtime/session-tabs/paired-host-terminal-close.ts +++ b/src/main/runtime/session-tabs/paired-host-terminal-close.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseCapture } from '../../../shared/terminal-tab-close' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import type { RuntimeTerminalClose } from '../../../shared/runtime-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' @@ -10,7 +11,11 @@ export type PairedHostTerminal = { environmentId: string; handle: string } export type PairedHostTerminalCall = ( environmentId: string, method: 'terminal.close' | 'terminal.closeTab', - params: { terminal: string }, + params: { + terminal: string + expectedIncarnationId?: string + originalPresentation?: TerminalSurfaceCloseCapture + }, timeoutMs: number ) => Promise @@ -58,19 +63,23 @@ function errorCode(error: unknown): string | null { export async function closeTerminalOnPairedHost( call: PairedHostTerminalCall, terminal: PairedHostTerminal, - method: 'terminal.close' | 'terminal.closeTab' + method: 'terminal.close' | 'terminal.closeTab', + original?: { expectedIncarnationId?: string; originalPresentation?: TerminalSurfaceCloseCapture } ): Promise> { let result: unknown try { result = await call( terminal.environmentId, method, - { terminal: terminal.handle }, + { terminal: terminal.handle, ...original }, PAIRED_HOST_TERMINAL_CLOSE_TIMEOUT_MS ) } catch (error) { const code = errorCode(error) - if (code === 'terminal_not_found') { + if (original?.originalPresentation) { + throw error + } + if (code === 'terminal_not_found' && !original?.originalPresentation) { // The owner has no such terminal, so nothing runs there to stop. return { ptyKilled: false } } @@ -86,6 +95,9 @@ export async function closeTerminalOnPairedHost( } const close = readHostClose(result) if (!close) { + if (original?.originalPresentation) { + throw new Error('terminal_close_unverifiable') + } // Why unverifiable: the owner answered, but not with a receipt this build can read. return { ptyKilled: false, @@ -93,6 +105,9 @@ export async function closeTerminalOnPairedHost( ptyStopReason: 'its owning host returned no close receipt' } } + if (original?.originalPresentation && !close.ptyKilled) { + throw new Error('terminal_close_unverifiable') + } return close } diff --git a/src/main/runtime/session-tabs/terminal-surface-close-commit.ts b/src/main/runtime/session-tabs/terminal-surface-close-commit.ts new file mode 100644 index 000000000000..23beb61800f2 --- /dev/null +++ b/src/main/runtime/session-tabs/terminal-surface-close-commit.ts @@ -0,0 +1,153 @@ +import type { RuntimeMobileSessionTerminalTab } from '../../../shared/runtime-types' +import { resolveTerminalCloseTarget, type PaneCloseResolution } from '../terminal-surface-close' +import { + isRetainedTerminalSurfaceIdentity, + type TerminalSurfaceCloseIdentity +} from '../../../shared/terminal-tab-close' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WindowLayoutCommand, LayoutCommandRunOptions } from '../runtime-layout-apply' +import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './terminal-surface-retirements' +import { captureAcknowledgedTerminalTabRetirement } from '../workspace-session-terminal-tab-retirement-identity' +import { closeLeafOrTab } from '../../persistence/terminal-topology/terminal-topology-commit' +import { + captureTerminalCloseRequest, + type TerminalSurfaceCloseOptions +} from '../terminal-surface-close' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' + +export abstract class OrcaRuntimeWithTerminalSurfaceCloseCommit extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { + abstract applyLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise + protected originalTerminalExecutionMatches(source: TerminalSurfaceCloseIdentity): boolean { + const current = source.ptyId ? this.ptysById.get(source.ptyId) : null + const replacing = source.ptyId ? this.pendingPtyHandleReplacementFences.get(source.ptyId) : null + const leaf = this.leaves.get(this.getLeafKey(source.tabId, source.leafId)) + return ( + (!current || current.incarnationId === source.incarnationId) && + (!leaf || + (leaf.worktreeId === source.worktreeId && (!leaf.ptyId || leaf.ptyId === source.ptyId))) && + isRetainedTerminalSurfaceIdentity(source, { + ...source, + worktreeId: current?.worktreeId ?? source.worktreeId, + ptyId: current?.ptyId ?? null, + incarnationId: replacing?.incarnationId ?? current?.incarnationId ?? null + }) + ) + } + + /** Resolves a close main started against the copy of the tab's panes its layout owner holds. */ + protected resolveTerminalCloseTarget( + worktreeId: string, + target: TerminalSurfaceCloseTarget + ): PaneCloseResolution | 'tab' { + const graphLeafIds: string[] = [] + for (const leaf of this.leaves.values()) { + if (leaf.tabId === target.tabId) { + graphLeafIds.push(leaf.leafId) + } + } + return resolveTerminalCloseTarget(target, { + rendererListsTab: this.tabs.has(target.tabId), + snapshotRows: (this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs ?? []).filter( + (row): row is RuntimeMobileSessionTerminalTab => + row.type === 'terminal' && row.parentTabId === target.tabId + ), + graphLeafIds, + sessionLayout: + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId?.[target.tabId] + }) + } + + protected captureTerminalTabRetirement(worktreeId: string, tabId: string) { + const originalHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) + return captureAcknowledgedTerminalTabRetirement(worktreeId, tabId, () => { + const resolvedHostId = this.getWorkspaceSessionHostIdForTab(worktreeId, tabId) + const resolvedSession = this.store?.getWorkspaceSession?.(resolvedHostId) + // Emptying the last tab may reroute the worktree to its catalog host. + const hostId = resolvedSession?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ? resolvedHostId + : originalHostId + return { + hostId, + session: this.store?.getWorkspaceSession?.(hostId) ?? null, + snapshot: this.mobileSessionTabsByWorktree.get(worktreeId), + incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId + } + }) + } + + /** + * The one close transaction every explicit terminal close reaches: durably commits the membership + * removal in the owning host's partition. Callers publish and kill afterwards. + */ + protected async closeTerminalSurface( + worktreeId: string, + target: TerminalSurfaceCloseTarget, + options: TerminalSurfaceCloseOptions = {} + ): Promise { + const store = this.store + if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.runDurableMutation) { + throw new Error('workspace_session_unavailable') + } + // Why: a tab its layout owner already removed has no newer owner; refusing would only strand it. + const acknowledgeTabRetirement = + target.kind === 'tab' && !options.closedByLayoutOwner + ? this.captureTerminalTabRetirement(worktreeId, target.tabId) + : null + let ptyIdsToKill: string[] = [] + let refusal: Error | undefined + const original = options.originalPresentation + const hostIds = () => + original + ? [ + ...new Set([ + ...original.holders.map((holder) => holder.hostId), + ...this.getWorkspaceSessionHostIdsForTab(worktreeId, target.tabId).filter((id) => + this.store + ?.getWorkspaceSession?.(id) + ?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === target.tabId) + ) + ]) + ] + : this.getWorkspaceSessionHostIdsForTab(worktreeId, target.tabId) + const fenced = acknowledgeTabRetirement !== null && !original + try { + refusal = await store.runDurableMutation( + closeLeafOrTab({ + worktreeId, + target, + options, + ...captureTerminalCloseRequest( + worktreeId, + target, + hostIds(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The existing guard requires this getter; preserve late-bound store access. + store as Parameters[3], + fenced + ), + ownerMatches: () => + original + ? this.originalTerminalExecutionMatches(original.identity) + : !acknowledgeTabRetirement || acknowledgeTabRetirement().matches, + hostIds, + getSession: (hostId) => store.getWorkspaceSession!(hostId), + setSession: (session, hostId) => store.setWorkspaceSession!(session, hostId), + onClosed: (closedPtyIds) => { + ptyIdsToKill = closedPtyIds + } + }) + ) + } catch (error) { + console.error('[runtime] failed to persist terminal close:', error) + if (original) { + throw error + } + } + if (refusal) { + throw refusal + } + return ptyIdsToKill + } +} diff --git a/src/main/runtime/terminal-intentional-stops.ts b/src/main/runtime/terminal-intentional-stops.ts index 836743fde67f..2799d48d0cd1 100644 --- a/src/main/runtime/terminal-intentional-stops.ts +++ b/src/main/runtime/terminal-intentional-stops.ts @@ -1,12 +1,13 @@ import { SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS } from '../ipc/pty/delivery/visibility-state' /** - * Why main stopped a PTY on purpose. Both kinds keep the pane's binding through the exit: + * Why main stopped a PTY on purpose. These kinds keep the pane's binding through the exit: * - `reversible`: sleep or hibernation; the binding is the wake hint. + * - `closing`: physical teardown is externally owned; explicit presentation removal follows. * - `replaced`: a restart stops the old process, or a reattach finds it gone; a new one takes over, * at once or (after a daemon restart) when the pane next connects. */ -export type TerminalIntentionalStopKind = 'reversible' | 'replaced' +export type TerminalIntentionalStopKind = 'reversible' | 'replaced' | 'closing' type IntentionalStopOwners = { inFlight: number; stopped: boolean } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index b0c3e1f9337d..9b9a0bc54f87 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -1,3 +1,8 @@ +import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' +import { + matchesTerminalSurfaceCapture, + type TerminalSurfaceCloseCapture +} from '../../shared/terminal-tab-close' import type { RuntimeSessionTabCloseReason } from '../../shared/runtime-session-contracts' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { @@ -172,6 +177,8 @@ export type TerminalSurfaceCloseOptions = { /** The desktop renderer's own close: its layout owner already removed the tab. Goes away with * D1, once main owns the terminal layout. */ closedByLayoutOwner?: boolean + originalPresentation?: TerminalSurfaceCloseCapture + onClosed?: (closedTab: boolean) => void } /** The desktop renderer's close intent as its IPC delivers it; main alone passes 'pty-exit'. */ @@ -231,7 +238,25 @@ export function terminalSurfaceCloseMutation( ] : undefined return () => { - if (!commit.ownerMatches()) { + const original = commit.options.originalPresentation + const hostIds = original + ? [ + ...new Set([ + ...original.holders.map((holder) => holder.hostId), + ...commit + .hostIds() + .filter((id) => + commit + .getSession(id) + ?.tabsByWorktree[commit.worktreeId]?.some((row) => row.id === target.tabId) + ) + ]) + ] + : commit.hostIds() + if ( + !commit.ownerMatches() || + (original && !matchesTerminalSurfaceCapture(original, hostIds, commit.getSession)) + ) { return { value: new Error('terminal_pane_owner_changed'), persist: false } } const results: { @@ -239,16 +264,31 @@ export function terminalSurfaceCloseMutation( session: WorkspaceSessionState result: TerminalSurfaceCloseResult }[] = [] - for (const hostId of commit.hostIds()) { + for (const hostId of hostIds) { const session = commit.getSession(hostId) if (!session) { return { value: new Error('workspace_session_unavailable'), persist: false } } - const result = closeTerminalSurfaceInWorkspaceSession(session, commit.worktreeId, target, { - force: commit.options.force, - paneIncarnationId, - reason: commit.options.reason ?? 'user' - }) + const resolvedTarget = + commit.options.originalPresentation && + target.kind === 'pane' && + (resolvePaneClose( + collectTerminalLayoutLeafIds(session.terminalLayoutsByTabId[target.tabId]?.root), + target.leafId + ) === 'last-pane' || + !session.tabsByWorktree[commit.worktreeId]?.some((row) => row.id === target.tabId)) + ? { kind: 'tab' as const, tabId: target.tabId } + : target + const result = closeTerminalSurfaceInWorkspaceSession( + session, + commit.worktreeId, + resolvedTarget, + { + force: commit.options.force, + paneIncarnationId: original?.identity.incarnationId ?? paneIncarnationId, + reason: commit.options.reason ?? 'user' + } + ) if (result.pinned) { return { value: new Error('terminal_tab_pinned'), persist: false } } @@ -257,6 +297,7 @@ export function terminalSurfaceCloseMutation( const { requestedHolders } = commit const changedCopy = requestedHolders !== undefined && + !commit.options.originalPresentation && results .slice(1) .some( @@ -271,6 +312,7 @@ export function terminalSurfaceCloseMutation( return { value: new Error('terminal_pane_owner_changed'), persist: false } } const closed = results.filter(({ result }) => result.closed) + commit.options.onClosed?.(results[0]?.result.resolution === 'tab') if (closed.length === 0) { const routed = results[0] if (!routed) { @@ -281,7 +323,7 @@ export function terminalSurfaceCloseMutation( // spawn is refused; an existing record (an echo of that close) needs no second write. if ( commit.options.allowMissing && - target.kind === 'tab' && + (target.kind === 'tab' || (original && routed.result.resolution === 'tab')) && !hasClosedTerminalTabRecord(routed.session.closedTerminalTabTombstonesByTabId, target.tabId) ) { commit.setSession(routed.result.session, routed.hostId) @@ -296,6 +338,9 @@ export function terminalSurfaceCloseMutation( for (const { hostId, result } of closed) { commit.setSession(result.session, hostId) } + if (original) { + markAgentLaunchesClosedByUser(commit.worktreeId, target) + } commit.onClosed([...new Set(closed.flatMap(({ result }) => result.ptyIdsToKill))]) // Why no rollback: bookkeeping must not undo a user's close or skip its kill; a failed write // keeps the removal dirty in memory, so the next write persists it. diff --git a/src/main/runtime/terminal/identity/terminal-handle-bindings.ts b/src/main/runtime/terminal/identity/terminal-handle-bindings.ts index c86acb5cc8fa..67432f155e66 100644 --- a/src/main/runtime/terminal/identity/terminal-handle-bindings.ts +++ b/src/main/runtime/terminal/identity/terminal-handle-bindings.ts @@ -60,6 +60,15 @@ export abstract class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntim for (const [ptyId, retained] of this.handleByPtyIncarnation) { const pty = this.ptysById.get(ptyId) const leaves = this.getLeavesForPty(ptyId) + const retainedLeaf = this.leaves.get(retained.leafKey) + if ( + (!pty || (!pty.connected && pty.incarnationId === retained.incarnationId)) && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' && + retainedLeaf && + (!retainedLeaf.ptyId || retainedLeaf.ptyId === ptyId) + ) { + continue + } // Why: a handle issued before the host reported the incarnation is un-fenced, so // learning it is not a replacement; only a known-to-different incarnation is. const incarnationReplaced = @@ -155,12 +164,20 @@ export abstract class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntim } } - protected invalidateLeafHandle(leafKey: string): void { + protected invalidateLeafHandle(leafKey: string, bindingCleared = false): void { const handle = this.handleByLeafKey.get(leafKey) if (!handle) { return } const record = this.handles.get(handle) + // A cleared binding after certified exit still addresses that readable ended process. + if ( + bindingCleared && + record?.ptyId && + this.getPtyLivenessVerdict(record.ptyId)?.status === 'exited' + ) { + return + } if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) { this.handleByPtyIncarnation.delete(record.ptyId) } diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index 2c9e72eedd0f..7c9b14e17ece 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -1,3 +1,5 @@ +import { rekeyMovedPaneOwners } from '../../moved-pane-owner-rekey' +import { applyWindowLayoutCommand } from '../../runtime-layout-apply' // Layout-owner build: main's starts publish their status on the pane, and a start main could not // make leaves its pane showing why, whether it failed before the pane was written or after. import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -43,8 +45,32 @@ export async function startKeepingFailedPane( export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCreateTerminal { protected abstract markPaneLiveTitlesDirty(): void + protected abstract inResumeRecordOrder(write: () => Promise): Promise abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + protected async applyOwnedWindowLayoutCommand( + command: WindowLayoutCommand, + options?: LayoutCommandRunOptions + ): Promise { + // A workspace's first surface comes after the live terminals main is placing. + const placing = this.livePlacementSettledFor(command) + const apply = (): Promise => + applyWindowLayoutCommand( + this.requireStore(), + (worktreeId, tabId) => + tabId !== null + ? this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId) + : [this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ?? []].flat(), + command, + (move) => rekeyMovedPaneOwners(this, move), + { ...this.layoutCommandRun(command), ...options } + ) + // A window's sleep or wake lands after the hook statuses main accepted before it, as in the window. + return await (ORCA_LAYOUT_OWNER_IS_MAIN && ['sleep', 'wake'].includes(command.type) + ? this.inResumeRecordOrder(apply) + : (placing?.then(apply) ?? apply())) + } + /** Switch point: main's starts into panes it already holds, published until each binds. */ protected readonly paneStartStatuses = ORCA_LAYOUT_OWNER_IS_MAIN ? new PaneStartStatuses(() => this.markPaneLiveTitlesDirty()) diff --git a/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts index d753d69f73c5..4bb0ce64da42 100644 --- a/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts +++ b/src/main/runtime/terminal/lifecycle/floating-session-liveness.ts @@ -133,7 +133,12 @@ export abstract class OrcaRuntimeWithRefreshFloatingWorkspacePtyLiveness extends this.terminalSpawnCommandsByPtyId.delete(ptyId) this.terminalRunFacts.delete(ptyId) this.disposePtyTitleTracker(ptyId) - this.invalidatePtyIncarnationHandle(ptyId) + const retained = this.handleByPtyIncarnation.get(ptyId) + const retainedLeaf = retained ? this.leaves.get(retained.leafKey) : null + // A certified exit may lose its PTY record while its readable pane still owns the handle. + if (this.getPtyLivenessVerdict(ptyId)?.status !== 'exited' || !retainedLeaf) { + this.invalidatePtyIncarnationHandle(ptyId) + } this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) diff --git a/src/main/runtime/terminal/output/provider-buffer-capture.ts b/src/main/runtime/terminal/output/provider-buffer-capture.ts index 8e4e5e02ad51..7d7d4c5a2bec 100644 --- a/src/main/runtime/terminal/output/provider-buffer-capture.ts +++ b/src/main/runtime/terminal/output/provider-buffer-capture.ts @@ -38,6 +38,27 @@ export abstract class OrcaRuntimeWithCaptureProviderTerminalBuffer extends OrcaR rows: number }): Promise + protected async readRenderedScreen( + ptyId: string, + read: RuntimeTerminalRead, + opts: { limit?: number } = {} + ): Promise { + const visibleState = await this.readVisibleTerminalState(ptyId) + const projection = + visibleState ?? + (await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true })) + if (projection.lines.length === 0) { + return { ...read, source: 'screen-unavailable' } + } + return buildVisibleSnapshotReadFallback( + read, + projection.lines, + opts.limit, + projection.draft, + projection.tailRows + ) + } + protected async captureProviderTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number }, diff --git a/src/main/runtime/terminal/query/pty-terminal-summary.ts b/src/main/runtime/terminal/query/pty-terminal-summary.ts index e2eb06034576..aff3bd523982 100644 --- a/src/main/runtime/terminal/query/pty-terminal-summary.ts +++ b/src/main/runtime/terminal/query/pty-terminal-summary.ts @@ -213,6 +213,15 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime const existingHandle = this.handleByLeafKey.get(leafKey) if (existingHandle) { const existingRecord = this.handles.get(existingHandle) + if ( + existingRecord && + existingRecord.worktreeId === leaf.worktreeId && + existingRecord.ptyId && + !leaf.ptyId && + this.getPtyLivenessVerdict(existingRecord.ptyId)?.status === 'exited' + ) { + return existingHandle + } if ( existingRecord && existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && diff --git a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts index 48470f21048c..d375367a3475 100644 --- a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts +++ b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts @@ -1,3 +1,7 @@ +import { + captureTerminalSurfaceCloseIdentity, + captureAddressedTerminalPresentation +} from '../../workspace-session-terminal-tab-retirement-identity' import { terminalLaunchFacts } from '../../../../shared/agent-session-resume' import type { OrcaRuntimeWithCreateTerminal } from '../launch/terminal-create' import { OrcaRuntimeWithRestoredHandlePaneStart } from '../surface/restored-handle-pane-start' @@ -20,11 +24,7 @@ import type { } from '../../runtime-terminal-state-records' import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' import { parsePaneKey } from '../../../../shared/stable-pane-id' -import { - buildVisibleSnapshotReadFallback, - labelTerminalReadSource, - readTerminalTail -} from '../../terminal-tail-read' +import { labelTerminalReadSource, readTerminalTail } from '../../terminal-tail-read' import { getTerminalState } from '../../terminal-wait-results' import { readStructuredWorkerTerminal } from '../../structured-worker-terminal-read' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -83,14 +83,54 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith // Why: pane coordinates restored by a paired client must not cross workspace ownership. throw new Error('terminal_not_found') } + const holders = + worktreeId && parsed + ? this.getWorkspaceSessionHostIdsForTab(worktreeId, parsed.tabId).map((hostId) => ({ + hostId, + identity: captureTerminalSurfaceCloseIdentity( + this.store?.getWorkspaceSession?.(hostId), + worktreeId, + parsed.tabId, + parsed.leafId + ) + })) + : [] + const routedHostId = + worktreeId && parsed ? this.getWorkspaceSessionHostIdForTab(worktreeId, parsed.tabId) : null + const rowIdentity = holders.find((holder) => holder.hostId === routedHostId)?.identity + const executionPtyId = pty?.ptyId ?? record?.ptyId ?? leaf?.ptyId ?? null + const retained = executionPtyId ? this.handleByPtyIncarnation.get(executionPtyId) : null + const execution = this.getPtyExecutionHostMetadata(executionPtyId) + if ( + !execution.incarnationId && + retained?.handle === handle && + executionPtyId && + this.getPtyLivenessVerdict(executionPtyId)?.status === 'exited' + ) { + execution.incarnationId = retained.incarnationId ?? undefined + } + const identity = + parsed && worktreeId + ? captureAddressedTerminalPresentation({ + runtimeId: this.runtimeId, + record, + currentPty: pty, + leaf, + row: rowIdentity, + address: { worktreeId, ...parsed }, + ptyId: executionPtyId, + incarnationId: execution.incarnationId + }) + : null return { + ...(identity ? { presentation: { identity, holders } } : {}), handle, tabId: parsed?.tabId ?? record?.tabId ?? '', leafId: parsed?.leafId ?? record?.leafId ?? '', - ptyId: record?.ptyId ?? null, + ptyId: executionPtyId, connected: pty?.connected === true, ...(worktreeId ? { worktreeId } : {}), - ...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null) + ...execution } } @@ -276,25 +316,4 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) return labelTerminalReadSource(visibleRead) } - - protected async readRenderedScreen( - ptyId: string, - read: RuntimeTerminalRead, - opts: { limit?: number } = {} - ): Promise { - const visibleState = await this.readVisibleTerminalState(ptyId) - const projection = - visibleState ?? - (await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true })) - if (projection.lines.length === 0) { - return { ...read, source: 'screen-unavailable' } - } - return buildVisibleSnapshotReadFallback( - read, - projection.lines, - opts.limit, - projection.draft, - projection.tailRows - ) - } } diff --git a/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts index c0ffec68fba6..79262a599e3c 100644 --- a/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts +++ b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts @@ -1,3 +1,6 @@ +import type { TerminalHandleRecord } from './runtime-terminal-contracts' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection' @@ -156,3 +159,64 @@ export function captureAcknowledgedTerminalTabRetirement( return { matches, hasPersistedTab: remaining !== null } } } + +export { captureTerminalSurfaceIdentity as captureTerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' + +/** A captured source narrows the existing authenticated handle; it cannot address another owner. */ +export function isOriginalTerminalHandle(args: { + runtimeId: string + record: TerminalHandleRecord | undefined + currentPty: RuntimePtyWorktreeRecord | null | undefined + leaf: RuntimeLeafRecord | undefined + source: TerminalSurfaceCloseIdentity + incarnation: string | undefined +}): boolean { + const { runtimeId, record, currentPty, leaf, source, incarnation } = args + return ( + !!incarnation && + !!source.ptyId && + source.incarnationId === incarnation && + !!record && + record.runtimeId === runtimeId && + record.worktreeId === source.worktreeId && + record.ptyId === source.ptyId && + (!currentPty || + (currentPty.incarnationId === incarnation && currentPty.worktreeId === source.worktreeId)) && + (!leaf || + (leaf.worktreeId === source.worktreeId && (!leaf.ptyId || leaf.ptyId === source.ptyId))) && + ((record.tabId === source.tabId && record.leafId === source.leafId) || + currentPty?.paneKey === `${source.tabId}:${source.leafId}`) + ) +} + +/** Derives presentation from the authenticated addressed owner, retaining absent-row evidence. */ +export function captureAddressedTerminalPresentation(args: { + runtimeId: string + record: TerminalHandleRecord | undefined + currentPty: RuntimePtyWorktreeRecord | null | undefined + leaf: RuntimeLeafRecord | null | undefined + row: TerminalSurfaceCloseIdentity | null | undefined + address: { worktreeId: string; tabId: string; leafId: string } + ptyId: string | null + incarnationId: string | null | undefined +}): TerminalSurfaceCloseIdentity | null { + const { row, address, leaf, ptyId, incarnationId } = args + const source: TerminalSurfaceCloseIdentity = { + ...address, + createdAt: row?.createdAt ?? null, + generation: row?.generation ?? 0, + hasLeaf: !!leaf || row?.hasLeaf === true, + ptyId, + incarnationId: incarnationId ?? null + } + return (!row?.ptyId || row.ptyId === ptyId) && + (!row?.incarnationId || row.incarnationId === incarnationId) && + isOriginalTerminalHandle({ + ...args, + source, + leaf: leaf ?? undefined, + incarnation: incarnationId ?? undefined + }) + ? source + : null +} diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts index d70f6d2525d2..575f11aa6852 100644 --- a/src/main/window/terminal-tab-close-request-relay.ts +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' @@ -12,7 +13,11 @@ const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000 export async function requestTerminalTabCloseFromRenderer( mainWindow: BrowserWindow, tabId: string, - options: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } = {} + options: { + localPtyTeardownOwnedExternally?: boolean + force?: boolean + originalSurface?: TerminalSurfaceCloseIdentity + } = {} ): Promise { if (mainWindow.isDestroyed()) { throw new Error('renderer_unavailable') diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts index ab0001e8b571..09c5aceada2a 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-exit-return.test-fixture.ts @@ -12,7 +12,7 @@ import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { StructuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' -import type { CliChatResumeTestMocks } from './cli-chat-resume-run.test' +import type { CliChatResumeTestMocks } from './cli-chat-resume-mocks.test-fixture' import { resolveCliChatResumeSource } from './cli-chat-resume-source' import { canPaneOfferResumeInNewChat } from './tab-session-history-switch' import { collectAgentPaneConversations } from '@/lib/ai-vault-tab-title-requests' @@ -25,7 +25,11 @@ import type { ConnectPanePtySession } from '../terminal-pane/pty-connection/conn import { LEAF_1, LEAF_2 } from '../terminal-pane/pty-connection-test-pane-fixtures' import { createExitReturnTransport } from './cli-chat-resume-transport.test-fixture' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal' +import { installExitedPaneRetirement } from './cli-chat-resume-parked-retirement.test-fixture' +import { + capturedPanesByTabId, + parkedWatchersByTabId +} from '../terminal-pane/terminal-parked-watcher-registry' export async function verifyExitedPaneHistoryReturn( mocks: CliChatResumeTestMocks, @@ -232,30 +236,7 @@ export async function verifyExitedPaneHistoryReturn( ? ready.promise : { ok: true, value: { sessionId: params.envelope.sessionId, fence: 1 } } ) - const closed = vi.fn((event: Event) => { - if (!(event instanceof CustomEvent)) { - return - } - const detail: CloseTerminalPaneDetail = event.detail - expect(detail).toMatchObject({ - tabId: 'shell', - leafId: LEAF_1, - expectedPtyId: 'pty-1', - retireSurface: true - }) - useAppStore.getState().retireAgentPaneAuthority(paneKey) - useAppStore.setState({ - terminalLayoutsByTabId: { - shell: { - root: { type: 'leaf', leafId: LEAF_2 }, - activeLeafId: LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_2]: 'pty-2' } - } - } - }) - }) - window.addEventListener(CLOSE_TERMINAL_PANE_EVENT, closed) + const { closed, siblingWatcher } = installExitedPaneRetirement(mocks) try { expect(switchAiVaultSessionToNativeChat(history)).toBe(true) await vi.waitFor(() => @@ -283,8 +264,12 @@ export async function verifyExitedPaneHistoryReturn( collectAgentPaneConversations(useAppStore.getState(), adoptsCliConversations) ).toMatchObject([{ paneKey: sibling }]) expect(useAppStore.getState().agentConversationsByPaneKey[paneKey]).toBeUndefined() + expect(siblingWatcher).not.toHaveBeenCalled() + expect(capturedPanesByTabId.get('shell')?.panes.map((pane) => pane.leafId)).toEqual([LEAF_2]) + expect([...parkedWatchersByTabId.get('shell')!.paneIdByPtyId.keys()]).toEqual(['pty-2']) } finally { - window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, closed) + capturedPanesByTabId.delete('shell') + parkedWatchersByTabId.delete('shell') connection.paneForegroundAgentTracker.dispose() connection.commandLifecycle.dispose() manager.destroy() diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts index 13a300b1e921..eacdd6e75646 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-feedback.ts @@ -131,7 +131,7 @@ export async function startCliChatReturn( return true } -function toastCliChatResumeFailure(source: CliChatResumeSource, error: unknown): void { +export function toastCliChatResumeFailure(source: CliChatResumeSource, error: unknown): void { const refusal = structuredLaunchFailure(error) if ( refusal?.code === 'agent_session_conflict' || diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-mocks.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-mocks.test-fixture.ts new file mode 100644 index 000000000000..10e91f3775e3 --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-mocks.test-fixture.ts @@ -0,0 +1,18 @@ +import type { Mock } from 'vitest' +import type { CliChatResumeSource } from './cli-chat-resume-source' + +export type CliChatResumeTestMocks = { + source: CliChatResumeSource | null + realSource: boolean + sourceCapability: Mock + createCapability: Mock + call: Mock + toast: Mock + success: Mock + activate: Mock<() => Promise> + activateVerified: Mock<() => Promise> + switchCapability: Mock<() => Promise> + exitWatchers: Map void> + rpc: Mock<(...args: unknown[]) => Promise> + inspect: Mock<(ptyId: string) => Promise<{ foregroundProcessEvidence?: unknown }>> +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts new file mode 100644 index 000000000000..f9a35d6c45cd --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts @@ -0,0 +1,66 @@ +import { vi } from 'vitest' +import { useAppStore } from '@/store' +import type { CliChatResumeTestMocks } from './cli-chat-resume-mocks.test-fixture' +import { LEAF_1, LEAF_2 } from '../terminal-pane/pty-connection-test-pane-fixtures' +import { + captureTerminalSurfaceIdentity, + type TerminalSurfaceCloseCapture +} from '../../../../shared/terminal-tab-close' +import { retireTerminalPresentation } from '../terminal-pane/terminal-presentation-retirement' +import { + capturedPanesByTabId, + parkedWatchersByTabId +} from '../terminal-pane/terminal-parked-watcher-registry' + +export function installExitedPaneRetirement(mocks: CliChatResumeTestMocks) { + const closed = vi.fn() + const siblingWatcher = vi.fn() + capturedPanesByTabId.set('shell', { + worktreeId: 'wt-1', + panes: [ + { paneId: 1, leafId: LEAF_1, ptyId: 'pty-1', drivesTabTitle: false }, + { paneId: 2, leafId: LEAF_2, ptyId: 'pty-2', drivesTabTitle: true } + ] + }) + parkedWatchersByTabId.set('shell', { + worktreeId: 'wt-1', + tabPtyId: 'pty-2', + paneIdByPtyId: new Map([ + ['pty-1', 1], + ['pty-2', 2] + ]), + disposersByPtyId: new Map([ + ['pty-1', closed], + ['pty-2', siblingWatcher] + ]) + }) + mocks.rpc.mockImplementation( + async (_target, method, params: { originalPresentation: TerminalSurfaceCloseCapture }) => { + if (method === 'terminal.resolvePane') { + const identity = captureTerminalSurfaceIdentity( + useAppStore.getState(), + 'wt-1', + 'shell', + LEAF_1 + ) + if (!identity) { + throw new Error('Missing original terminal') + } + return { + terminal: { + handle: 'handle-1', + ptyId: 'pty-1', + incarnationId: 'i1', + presentation: { identity: { ...identity, incarnationId: 'i1' }, holders: [] } + } + } + } + if (method !== 'terminal.close') { + throw new Error('Unexpected RPC') + } + retireTerminalPresentation(params.originalPresentation.identity) + return { close: { ptyKilled: true } } + } + ) + return { closed, siblingWatcher } +} diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 239eda206dfe..c2e4ded20c00 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -1,26 +1,12 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { CliChatResumeSource } from './cli-chat-resume-source' import type * as ResumeSourceModule from './cli-chat-resume-source' import type * as RuntimeRpcModule from '@/runtime/runtime-rpc-client' import type { AgentSessionWireRefusal } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' -export type CliChatResumeTestMocks = { - source: CliChatResumeSource | null - realSource: boolean - sourceCapability: Mock - createCapability: Mock - call: Mock - toast: Mock - success: Mock - activate: Mock<() => Promise> - activateVerified: Mock<() => Promise> - switchCapability: Mock<() => Promise> - exitWatchers: Map void> - rpc: Mock<(...args: unknown[]) => Promise> - inspect: Mock<(ptyId: string) => Promise<{ foregroundProcessEvidence?: unknown }>> -} +import type { CliChatResumeTestMocks } from './cli-chat-resume-mocks.test-fixture' const mocks: CliChatResumeTestMocks = vi.hoisted(() => { const state: { source: CliChatResumeSource | null } = { source: null } @@ -211,7 +197,26 @@ beforeEach(async () => { mocks.switchCapability.mockResolvedValue(false) mocks.rpc.mockImplementation(async (_target, method) => method === 'terminal.resolvePane' - ? { terminal: { handle: 'handle-1', ptyId: 'pty-1', incarnationId: 'i1' } } + ? { + terminal: { + handle: 'handle-1', + ptyId: 'pty-1', + incarnationId: 'i1', + presentation: { + identity: { + worktreeId: 'wt-1', + tabId: mocks.source?.tabId ?? 'tab-1', + leafId: mocks.source?.paneKey.split(':')[1] ?? 'leaf-1', + createdAt: 1, + generation: 0, + hasLeaf: true, + ptyId: 'pty-1', + incarnationId: 'i1' + }, + holders: [] + } + } + } : { close: { ptyKilled: true } } ) mocks.sourceCapability.mockResolvedValue(true) @@ -427,7 +432,26 @@ describe('CLI identity resume through the existing launch owner', () => { mocks.switchCapability.mockResolvedValue(false) mocks.rpc.mockImplementation(async (_target, method) => method === 'terminal.resolvePane' - ? { terminal: { handle: 'handle-1', ptyId: 'pty-1', incarnationId: 'i1' } } + ? { + terminal: { + handle: 'handle-1', + ptyId: 'pty-1', + incarnationId: 'i1', + presentation: { + identity: { + worktreeId: 'wt-1', + tabId: mocks.source?.tabId ?? 'tab-1', + leafId: mocks.source?.paneKey.split(':')[1] ?? 'leaf-1', + createdAt: 1, + generation: 0, + hasLeaf: true, + ptyId: 'pty-1', + incarnationId: 'i1' + }, + holders: [] + } + } + } : { close: { ptyKilled: true } } ) capability.resolve(true) @@ -531,46 +555,37 @@ describe('CLI identity resume through the existing launch owner', () => { expect(closed).not.toHaveBeenCalled() switched = { ok: true, sessionId: 'owner-chat' } - // The source PTY exits during the chat's readiness: a reused id is another execution, so it stays. - mocks.activateVerified.mockImplementationOnce(async () => { - mocks.exitWatchers.get('pty-1')?.() - return 'activated' - }) - await runCliChatResume(mocks.source) - expect(closed).not.toHaveBeenCalled() + // A bookkeeping refusal after READY settles once and leaves the existing chat usable. + mocks.rpc + .mockImplementationOnce(async () => ({ + terminal: { + handle: 'handle-1', + ptyId: 'pty-1', + incarnationId: 'i1', + presentation: { + identity: { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId, + createdAt: 1, + generation: 0, + hasLeaf: true, + ptyId: 'pty-1', + incarnationId: 'i1' + }, + holders: [] + } + } + })) + .mockRejectedValueOnce(new Error('required write failed')) await runCliChatResume(mocks.source) - window.removeEventListener('orca-close-terminal-pane', closed) - const switches = mocks.call.mock.calls.filter( - ([, method]) => method === 'agentSession.switchToChat' - ) - expect(switches).toHaveLength(3) + expect(mocks.rpc.mock.calls.filter(([, method]) => method === 'terminal.close')).toHaveLength(1) + expect(mocks.toast).toHaveBeenCalledTimes(2) expect(mocks.activateVerified).toHaveBeenCalledWith({ structuredSession: owner }, undefined, { kind: 'local' }) - expect(closed).toHaveBeenCalledOnce() - expect(closed.mock.calls[0]?.[0].detail).toEqual({ - tabId: 'tab-1', - leafId, - expectedPtyId: 'pty-1', - preservePty: true, - retireSurface: true - }) - expect(tabs()).toEqual([]) - - // A paired source is closed by its host, fenced on the incarnation it started with, so a - // replacement under the same handle stays open; the renderer never closes it by tab. - const remote = 'remote:env@@handle-1' - mocks.source = { ...mocks.source, ptyId: remote } - mocks.inspect.mockResolvedValueOnce({ foregroundProcessEvidence: { ptyIncarnationId: 'i1' } }) - window.addEventListener('orca-close-terminal-pane', closed) - await runCliChatResume(mocks.source) + expect(closed).not.toHaveBeenCalled() window.removeEventListener('orca-close-terminal-pane', closed) - expect(closed).toHaveBeenCalledOnce() - expect(mocks.rpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'env' }, - 'terminal.close', - { terminal: 'handle-1', expectedIncarnationId: 'i1' } - ) }) it('keeps the eager History joiner failed and retryable after the hidden starter is refused', async () => { diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index a3ddd7601e54..3517c0ac8068 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -7,16 +7,16 @@ import { supportsInPlaceSwitch, supportsStructuredAgentSessionCliSource } from '@/runtime/structured-agent-session-client' -import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal' -import { parsePaneKey } from '../../../../shared/stable-pane-id' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { waitForPromiseWithSignal } from '../../../../shared/abort-signal-reason' import { CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured-agent-cli-conversation-adoption' import { resolveCliChatResumeSource, type CliChatResumeSource } from './cli-chat-resume-source' -import { reportCliChatResumeFailure, startCliChatReturn } from './cli-chat-resume-feedback' +import { + reportCliChatResumeFailure, + startCliChatReturn, + toastCliChatResumeFailure +} from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' -import { panePtyId } from '@/lib/ai-vault-tab-title-requests' -import { subscribeToPtyExit } from '../terminal-pane/pty-dispatcher' import type { RuntimeTerminalResolvePane } from '../../../../shared/runtime-types' import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -38,11 +38,13 @@ async function captureSourceTerminal(source: CliChatResumeSource) { worktreeId: source.workspaceId } ) - return terminal.incarnationId && (owner || terminal.ptyId === source.ptyId) + return terminal.incarnationId && + (owner ? terminal.handle === owner.terminal : terminal.ptyId === source.ptyId) ? { target, handle: owner?.terminal ?? terminal.handle, - incarnationId: terminal.incarnationId + incarnationId: terminal.incarnationId, + presentation: terminal.presentation } : null } catch { @@ -52,42 +54,17 @@ async function captureSourceTerminal(source: CliChatResumeSource) { /** The execution host closes only the captured shell; refused or unknown closes leave it alone. */ async function retireSwitchedTerminalPane( - source: CliChatResumeSource, - sourceExited: () => boolean, expected: ReturnType ): Promise { const captured = await expected - if (!captured || sourceExited()) { - return - } - try { - const { close } = await callRuntimeRpc<{ close: { ptyKilled: boolean } }>( - captured.target, - 'terminal.close', - { - terminal: captured.handle, - expectedIncarnationId: captured.incarnationId - } - ) - const pane = parsePaneKey(source.paneKey) - if ( - captured.target.kind === 'local' && - close.ptyKilled && - pane && - panePtyId(useAppStore.getState(), source.paneKey) === source.ptyId - ) { - const detail: CloseTerminalPaneDetail = { - tabId: pane.tabId, - leafId: pane.leafId, - expectedPtyId: source.ptyId, - preservePty: true, - retireSurface: true - } - window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) - } - } catch { - /* The source stays readable when its close is unconfirmed. */ + if (!captured?.presentation) { + throw new Error('terminal_close_unverifiable') } + await callRuntimeRpc(captured.target, 'terminal.close', { + terminal: captured.handle, + expectedIncarnationId: captured.incarnationId, + originalPresentation: captured.presentation + }) } const pending = new Map() @@ -152,14 +129,7 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise { - sourceExited = true - }) - : () => {} const stopSource = useAppStore.subscribe(() => { if (!isCurrent()) { controller.abort() @@ -185,94 +155,97 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise - g.tabOrder.includes(source.tabId) - ) - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan, - hooks: { beforeCreate: handoff }, - afterTabId: source.tabId, - target: group - ? { - surfaceAddress: { - worktreeId: source.workspaceId, - targetGroupId: group.id, - precedingTabIds: group.tabOrder.slice(0, group.tabOrder.indexOf(source.tabId) + 1) + inPlace = await waitForPromiseWithSignal( + supportsInPlaceSwitch(target, source.agent), + controller.signal + ) + if (target.kind !== 'local' && !inPlace) { + return + } + const cliSourceSupported = await waitForPromiseWithSignal( + supportsStructuredAgentSessionCliSource(target), + controller.signal + ) + if (!isCurrent()) { + return + } + const agent = await waitForPromiseWithSignal( + adoptingAgentOnHost(source.agent, source.workspaceId), + controller.signal + ) + if (!isCurrent()) { + return + } + const plan = adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + requestId: newAgentLaunchRequestId(), + agent, + worktreeId: source.workspaceId, + executionHostId: source.executionHostId, + resumeFrom: { + providerSessionId: source.providerSessionId, + ...(cliSourceSupported + ? { cliSource: { workspaceId: source.workspaceId, paneKey: source.paneKey } } + : {}) + } + }) + const state = useAppStore.getState() + const group = state.groupsByWorktree[source.workspaceId]?.find((g) => + g.tabOrder.includes(source.tabId) + ) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan, + hooks: { beforeCreate: handoff }, + afterTabId: source.tabId, + target: group + ? { + surfaceAddress: { + worktreeId: source.workspaceId, + targetGroupId: group.id, + precedingTabIds: group.tabOrder.slice(0, group.tabOrder.indexOf(source.tabId) + 1) + } } - } - : undefined, - deferPublication: true - }) - if (!launch) { - return - } - const result = await waitForPromiseWithSignal(launch.settlement, controller.signal) - if (result.kind === 'structured' && inPlace) { - if (await startCliChatReturn(source, result.sessionId, target)) { - await retireSwitchedTerminalPane(source, () => sourceExited, sourceIncarnation) + : undefined, + deferPublication: true + }) + if (!launch) { + return + } + const result = await waitForPromiseWithSignal(launch.settlement, controller.signal) + if (result.kind === 'structured' && inPlace) { + retireSource = await startCliChatReturn(source, result.sessionId, target) + } else if ( + result.kind === 'failed' && + !result.notified && + pending.get(key) === controller && + (await reportCliChatResumeFailure(source, result.error, target)) + ) { + retireSource = inPlace } - } else if ( - result.kind === 'failed' && - !result.notified && - pending.get(key) === controller && - (await reportCliChatResumeFailure(source, result.error, target)) - ) { - if (inPlace) { - await retireSwitchedTerminalPane(source, () => sourceExited, sourceIncarnation) + } catch (error) { + if ( + pending.get(key) === controller && + (await reportCliChatResumeFailure(source, error, target)) + ) { + retireSource = inPlace } } - } catch (error) { - if ( - pending.get(key) === controller && - (await reportCliChatResumeFailure(source, error, target)) - ) { - if (inPlace) { - await retireSwitchedTerminalPane(source, () => sourceExited, sourceIncarnation) + if (retireSource) { + try { + await retireSwitchedTerminalPane(sourceIncarnation) + } catch (error) { + toastCliChatResumeFailure(source, error) } } } finally { clearTimeout(timer) stopSource() - stopExitWatch() release() } } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index d285fc6c84ea..dffa713c3a39 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -43,9 +43,10 @@ export function resolveCliChatResumeSource( ) const conversation = args.paneKey ? paneConversation(args.paneKey) - : (collectAgentPaneConversations(state, adoptsCliConversations).find( - (candidate) => candidate.tabId === args.tab.id - ) ?? (activeLeafId ? paneConversation(makePaneKey(args.tab.id, activeLeafId)) : null)) + : (collectAgentPaneConversations(state, adoptsCliConversations, { + tab: { worktreeId: args.tab.worktreeId, tabId: args.tab.id } + }).find((candidate) => candidate.tabId === args.tab.id) ?? + (activeLeafId ? paneConversation(makePaneKey(args.tab.id, activeLeafId)) : null)) if ( !conversation || conversation.tabId !== args.tab.id || diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 4ae523f2c338..2a96998de6c8 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -4,7 +4,11 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { makeRepo, makeTerminalTab, makeWorktree } from '../worktree-jump-palette-test-fixtures' import { makeAgentStatusEntry } from '@/runtime/sync-runtime-graph-test-harness' -import { collectAiVaultTitleRequests } from '@/lib/ai-vault-tab-title-requests' +import { + collectAgentPaneConversations, + resolvePaneLastAgentConversation, + collectAiVaultTitleRequests +} from '@/lib/ai-vault-tab-title-requests' import { resolveCliChatResumeSource } from './cli-chat-resume-source' import { applyProviderSessionOnlyUpdate } from '@/hooks/ipc-events/agent-status-partial-updates' import type { AiVaultListArgs, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -474,7 +478,7 @@ describe('one pane of a terminal tab', () => { const exited = useAppStore.getState() expect(paneSubject(exited, claudePane)).toBeNull() // The pane's switch to native chat stays offered after the agent exits: that is when it works. - expect(paneGate(exited, claudePane)).toBe(kind === 'retained') + expect(paneGate(exited, claudePane)).toBe(true) expect( resolveTabSessionHistorySubject(exited, { tab: { id: 'term-1', worktreeId: WORKTREE_ID } }) ).toBeNull() @@ -800,3 +804,47 @@ it.each(['local', 'runtime:paired-host'] as const)( mocks.inPlaceSwitch = false } ) + +it('uses one same-pane evidence order in History, the pane action and its feasibility gate', () => { + makeState() + const paneKey = 'term-1:11111111-1111-4111-8111-111111111111' + const sleeping = exitedAgentRecords.sleeping.sleepingAgentSessionsByPaneKey!['term-1:leaf-1'] + const retained = exitedAgentRecords.retained.retainedAgentsByPaneKey!['term-1:leaf-1'] + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [paneKey]: { ...sleeping, paneKey, providerSession: { key: 'session_id', id: 'C' } } + }, + retainedAgentsByPaneKey: { + [paneKey]: { + ...retained, + entry: { ...retained.entry, paneKey, providerSession: { key: 'session_id', id: 'X' } } + } + } + }) + const isClaude = (agent: unknown): agent is 'claude' => agent === 'claude' + const state = useAppStore.getState() + const pane = { worktreeId: WORKTREE_ID, paneKey } + const collected = collectAgentPaneConversations(state, isClaude, { allPanes: true }) + expect(collected).toHaveLength(1) + expect(collected[0].providerSession.id).toBe('C') + const layouts = { ...state.terminalLayoutsByTabId } + Object.defineProperty(layouts, 'unrelated-tab', { + get: () => { + throw new Error('Exact-pane lookup scanned an unrelated layout') + } + }) + const addressedState = { + ...state, + terminalLayoutsByTabId: layouts, + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: [ + ...state.tabsByWorktree[WORKTREE_ID], + { ...state.tabsByWorktree[WORKTREE_ID][0], id: 'unrelated-tab' } + ] + } + } + expect(resolvePaneLastAgentConversation(addressedState, pane, isClaude)).toEqual(collected[0]) + expect(canPaneOfferResumeInNewChat(state, pane)).toBe(true) + expect(collectAgentPaneConversations(state, isClaude, { runningAgentsOnly: true })).toEqual([]) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts index 01c4b8c89862..4700454b88f5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-close.ts @@ -39,9 +39,13 @@ export function applyTerminalPaneCloseRequest(args: { } if (args.detail.preservePty) { if (args.detail.retireSurface) { - args.manager.retirePanePreservingPty(paneRuntimeId) + if (!args.manager.retirePanePreservingPty(paneRuntimeId)) { + return 'ignored' + } } else { - args.manager.detachPaneForExternalMove(paneRuntimeId) + if (!args.manager.detachPaneForExternalMove(paneRuntimeId)) { + return 'ignored' + } } } else { args.manager.closePane(paneRuntimeId) @@ -78,13 +82,14 @@ export function retireMountedTerminalPaneSurface(args: { ) => void clearExitedPanePtyLayoutBindingForLeaf?: (leafId: string, exitedPtyId: string) => void clearTabPtyId: (tabId: string, ptyId: string) => void + permanent?: boolean transport?: { detach?: (options?: { preserveExitObserver?: boolean }) => void destroy?: () => void } }): void { args.retireAgentPaneAuthority(args.paneKey, { - preserveSleepingAgentSession: true + preserveSleepingAgentSession: !args.permanent }) if (args.ptyId) { if (args.clearExitedPanePtyLayoutBindingForLeaf) { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts index ce0f06c681f8..c323a6730caa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-cleanup.ts @@ -24,6 +24,7 @@ export function cleanupTerminalPaneMount(args: { expandedStyleSnapshots: Map unregisterRuntimeTab: () => void cancelResize: () => void + permanent?: boolean }): void { const { manager, @@ -42,13 +43,13 @@ export function cleanupTerminalPaneMount(args: { const tabRemainsInWorktree = Boolean( currentWorktreeTabs?.some((candidate) => candidate.id === tabId) ) - if (!tabRemainsInWorktree) { + if (args.permanent || !tabRemainsInWorktree) { cancelQueuedTerminalPaneSplitRequests(tabId, worktreeId) } // Handoff retention is deliberately broader: a tab move removes the old // worktree bucket before the replacement surface mounts, so use the shared // global ownership check to let an ID-less deferred split survive a rehome. - const tabStillExists = isTerminalTabPresent(currentStore, tabId) + const tabStillExists = !args.permanent && isTerminalTabPresent(currentStore, tabId) unregisterRuntimeTab() cancelResize() restoreExpandedLayoutFrom(expandedStyleSnapshots) @@ -71,24 +72,27 @@ export function cleanupTerminalPaneMount(args: { disposeAll(refs.imeCompositionDisposablesRef.current) disposeAll(refs.imeNativeTextForwarderDisposablesRef.current) - captureParkedTerminalPaneCandidates( - tabId, - worktreeId, - manager.getPanes().map((pane) => { - const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - const binding = panePtyBindingsRef.current.get(pane.id) as - | { isUntouchedFreshSpawnPty?: (candidate: string) => boolean } - | undefined - return { - ptyId, - workspaceOwner: captureNotificationTransportOwner(paneTransportsRef.current.get(pane.id)), - paneId: pane.id, - leafId: pane.leafId, - drivesTabTitle: manager.getActivePane()?.id === pane.id, - untouchedFreshSpawn: ptyId !== null && (binding?.isUntouchedFreshSpawnPty?.(ptyId) ?? false) - } - }) - ) + if (tabStillExists) { + captureParkedTerminalPaneCandidates( + tabId, + worktreeId, + manager.getPanes().map((pane) => { + const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + const binding = panePtyBindingsRef.current.get(pane.id) as + | { isUntouchedFreshSpawnPty?: (candidate: string) => boolean } + | undefined + return { + ptyId, + workspaceOwner: captureNotificationTransportOwner(paneTransportsRef.current.get(pane.id)), + paneId: pane.id, + leafId: pane.leafId, + drivesTabTitle: manager.getActivePane()?.id === pane.id, + untouchedFreshSpawn: + ptyId !== null && (binding?.isUntouchedFreshSpawnPty?.(ptyId) ?? false) + } + }) + ) + } for (const [paneId, transport] of paneTransportsRef.current) { const ptyId = transport.getPtyId() const deferredSplitHandoff = deferredSplitHandoffs.get(paneId) @@ -103,6 +107,7 @@ export function cleanupTerminalPaneMount(args: { deferredSplitHandoffs.delete(paneId) } if ( + args.permanent || shouldDetachPaneTransportOnUnmount({ tabStillExists, tabId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts index d9baf8cfdd73..de7b89b5ff9d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-context.ts @@ -13,6 +13,7 @@ import type { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-so import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state' export type TerminalPaneMountContext = { + getOwnedManager?: () => PaneManager | null deps: UseTerminalPaneLifecycleDeps refs: TerminalPaneLifecycleRefs ptyDeps: PtyConnectionDeps @@ -54,5 +55,5 @@ export type PaneCreatedHandlerContext = TerminalPaneManagerOptionsContext export type PaneClosedHandlerContext = TerminalPaneMountContext & { paneId: number - closedPane?: { leafId: string; reason?: 'close' | 'detach' | 'retire' } + closedPane?: { leafId: string; reason?: 'close' | 'detach' | 'retire' | 'remove' } } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts index bb4c8b3c40d3..19bb3c1de5d1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts @@ -1,3 +1,4 @@ +import { registerTerminalPaneMount } from './terminal-pane-mount-registration' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' @@ -24,7 +25,6 @@ import type { PtyConnectionDeps } from './pty-connection-types' import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types' import type { TerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-refs' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { registerRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' import { normalizeTerminalLayoutSnapshot } from './layout-serialization' import { applyTerminalAppearance } from './terminal-appearance' import { terminalLinkClickBehaviorFor } from './terminal-link-click-behavior' @@ -51,6 +51,8 @@ export type TerminalPaneMountPreparation = { ptyDeps: PtyConnectionDeps deferredSplitHandoffs: Map unregisterRuntimeTab: () => void + setOwnedManager: (manager: PaneManager) => void + getOwnedManager: () => PaneManager | null fileOpenLinkHint: string getPaneLinkCwd: (paneId: number) => string getUrlOpenLinkHint: (paneId: number) => string @@ -74,7 +76,8 @@ export type TerminalPaneMountPreparation = { export function prepareTerminalPaneMount( deps: UseTerminalPaneLifecycleDeps, refs: TerminalPaneLifecycleRefs, - mountFollowsTerminalPark: boolean + mountFollowsTerminalPark: boolean, + onEmpty: (manager: PaneManager) => void ): TerminalPaneMountPreparation | null { const { containerRef, expandedStyleSnapshotRef, paneTransportsRef, panePtyBindingsRef } = deps const container = containerRef.current @@ -232,14 +235,10 @@ export function prepareTerminalPaneMount( restoredPtyIdByLeafId: deps.initialLayoutRef.current.ptyIdsByLeafId ?? {}, deferredSplitHandoffs }) - const unregisterRuntimeTab = registerRuntimeTerminalTab({ - tabId: deps.tabId, - worktreeId: deps.worktreeId, - getManager: () => deps.managerRef.current, - getContainer: () => deps.containerRef.current, - getPtyIdForPane: (paneId) => paneTransports.get(paneId)?.getPtyId() ?? null, - getTabWideAgentHintLeafId: deps.getTabWideAgentHintLeafId - }) + const { unregisterRuntimeTab, setOwnedManager, getOwnedManager } = registerTerminalPaneMount( + { deps, refs, deferredSplitHandoffs }, + onEmpty + ) const fileOpenLinkHint = getTerminalFileOpenHint() const getUrlOpenLinkHint = (paneId: number): string => getTerminalUrlOpenHint({ @@ -263,6 +262,8 @@ export function prepareTerminalPaneMount( ptyDeps, deferredSplitHandoffs, unregisterRuntimeTab, + setOwnedManager, + getOwnedManager, fileOpenLinkHint, getPaneLinkCwd, getUrlOpenLinkHint, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts new file mode 100644 index 000000000000..f064a499bde8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts @@ -0,0 +1,59 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { registerRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' +import { removeTerminalPresentation } from './terminal-presentation-retirement' +import type { PaneClosedHandlerContext } from './terminal-pane-mount-context' + +/** The mount retains its actual manager even while the rendering ref is temporarily cleared. */ +export function registerTerminalPaneMount( + context: Pick, + onEmpty: (manager: PaneManager) => void +) { + const { deps } = context + let ownedManager: PaneManager | null = null + const unregisterRuntimeTab = registerRuntimeTerminalTab({ + retirePresentation: (original) => { + const manager = ownedManager + const pane = manager?.getPanes().find((candidate) => candidate.leafId === original.leafId) + const transport = pane ? deps.paneTransportsRef.current.get(pane.id) : null + if (transport?.getPtyId() && transport.getPtyId() !== original.ptyId) { + throw new Error('terminal_pane_owner_changed') + } + if ( + !pane && + [...deps.paneTransportsRef.current.values()].some( + (candidate) => !manager || candidate.getPtyId() === original.ptyId + ) + ) { + throw new Error('terminal_pane_owner_unavailable') + } + removeTerminalPresentation(original, () => { + if (pane && manager) { + if (!manager.retirePanePreservingPty(pane.id, true)) { + throw new Error('terminal_pane_close_failed') + } + if (manager.getPanes().length === 0) { + onEmpty(manager) + if (deps.managerRef.current === manager) { + deps.managerRef.current = null + } + ownedManager = null + unregisterRuntimeTab() + } + } + }) + }, + tabId: deps.tabId, + worktreeId: deps.worktreeId, + getManager: () => deps.managerRef.current, + getContainer: () => deps.containerRef.current, + getPtyIdForPane: (paneId) => deps.paneTransportsRef.current.get(paneId)?.getPtyId() ?? null, + getTabWideAgentHintLeafId: deps.getTabWideAgentHintLeafId + }) + return { + unregisterRuntimeTab, + getOwnedManager: () => ownedManager, + setOwnedManager: (manager: PaneManager) => { + ownedManager = manager + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts index a70545c173c7..e1954ce0cc0b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-closed.ts @@ -17,7 +17,10 @@ import { import type { PaneClosedHandlerContext } from './terminal-pane-mount-context' export function createTerminalPaneClosedHandler( - context: Omit + context: Pick< + PaneClosedHandlerContext, + 'deps' | 'refs' | 'deferredSplitHandoffs' | 'getOwnedManager' + > ): (paneId: number, closedPane?: PaneClosedHandlerContext['closedPane']) => void { return (paneId, closedPane) => { const { deps, refs } = context @@ -47,7 +50,8 @@ export function createTerminalPaneClosedHandler( } = deps onPtyRecoveryStateRef?.current?.(paneId, null) const isDetachedToTab = closedPane?.reason === 'detach' - const isRetiredSurface = closedPane?.reason === 'retire' + const permanent = closedPane?.reason === 'remove' + const isRetiredSurface = closedPane?.reason === 'retire' || permanent disposeMapEntry(refs.linkProviderDisposablesRef.current, paneId) disposeMapEntry(refs.terminalHandleLinkDisposablesRef.current, paneId) disposeMapEntry(refs.linkifierClickPrimingDisposablesRef.current, paneId) @@ -108,6 +112,7 @@ export function createTerminalPaneClosedHandler( syncPanePtyLayoutBindingForLeaf, clearExitedPanePtyLayoutBindingForLeaf, clearTabPtyId, + permanent, ...(transport ? { transport } : {}) }) } else if (leafId && !isDetachedToTab) { @@ -133,6 +138,8 @@ export function createTerminalPaneClosedHandler( } transport.destroy?.() } + } + if (!isRetiredSurface || permanent) { paneTransportsRef.current.delete(paneId) } clearRuntimePaneTitle(tabId, paneId) @@ -153,8 +160,9 @@ export function createTerminalPaneClosedHandler( paneTitlesRef.current = next } setRenamingPaneId((current) => (current === paneId ? null : current)) - setPaneCount(managerRef.current?.getPanes().length ?? 0) - const activePane = managerRef.current?.getActivePane() + const manager = context.getOwnedManager?.() ?? managerRef.current + setPaneCount(manager?.getPanes().length ?? 0) + const activePane = manager?.getActivePane() if (activePane) { reportActiveRendererPtyForPane(paneTransportsRef.current, activePane.id) const paneTitles = useAppStore.getState().runtimePaneTitlesByTabId[tabId] ?? {} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index c6f6e33eac58..682068f3f246 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -236,3 +236,52 @@ export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet } } } + +/** Permanent removal consumes one leaf's sidecars, including ended watcher slots. */ +export function retireParkedTerminalLeaf( + original: { worktreeId: string; tabId: string; leafId: string; ptyId: string | null }, + retainedPtyIds: ReadonlySet = new Set() +): number[] { + const { worktreeId, tabId, leafId, ptyId } = original + const capture = capturedPanesByTabId.get(tabId) + const entry = parkedWatchersByTabId.get(tabId) + if ( + (capture && capture.worktreeId !== worktreeId) || + (entry && entry.worktreeId !== worktreeId) + ) { + throw new Error('terminal_pane_owner_changed') + } + const panes = capture?.panes.filter((pane) => pane.leafId === leafId) ?? [] + if (panes.some((pane) => pane.ptyId !== null && pane.ptyId !== ptyId)) { + throw new Error('terminal_pane_owner_changed') + } + const ids = new Set(panes.flatMap((pane) => (pane.ptyId ? [pane.ptyId] : []))) + if (ptyId) { + ids.add(ptyId) + } + const paneIds = new Set(panes.map((pane) => pane.paneId)) + for (const id of ids) { + if (retainedPtyIds.has(id)) { + continue + } + const paneId = entry?.paneIdByPtyId.get(id) + if (paneId !== undefined) { + paneIds.add(paneId) + } + entry?.disposersByPtyId.get(id)?.() + entry?.disposersByPtyId.delete(id) + entry?.paneIdByPtyId.delete(id) + discardPreHandlerPtyState(id) + } + if (entry && entry.disposersByPtyId.size === 0 && entry.paneIdByPtyId.size === 0) { + parkedWatchersByTabId.delete(tabId) + } + if (capture) { + capture.panes = capture.panes.filter((pane) => pane.leafId !== leafId) + if (capture.panes.length === 0) { + capturedPanesByTabId.delete(tabId) + } + } + releaseTerminalScrollIntentKey(leafId) + return [...paneIds] +} diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts new file mode 100644 index 000000000000..d0b23c53352e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts @@ -0,0 +1,212 @@ +import { vi } from 'vitest' +import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { retireManagedPanePreservingPty } from '@/lib/pane-manager/pane-split-close' +import { createTerminalPaneClosedHandler } from './terminal-pane-pane-closed' +import { registerTerminalPaneMount } from './terminal-pane-mount-registration' +import { cleanupTerminalPaneMount } from './terminal-pane-mount-cleanup' +import { installTerminalPaneMountEvents } from './terminal-pane-mount-events' +import type { PaneClosedHandlerContext } from './terminal-pane-mount-context' +import { useAppStore } from '@/store' + +export const LEAF = '11111111-1111-4111-8111-111111111111' +export const SIBLING = '22222222-2222-4222-8222-222222222222' + +function pane(id: number, leafId: string): ManagedPaneInternal { + const terminal = { + dispose: vi.fn(), + clearSelection: vi.fn(), + focus: vi.fn(), + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } } + } + const addon = () => ({ dispose: vi.fn() }) + const container = document.createElement('div') + container.classList.add('pane') + container.dataset.leafId = leafId + // SAFETY: real disposal reads only the listed terminal/addon lifecycle and optional resources. + return { + id, + leafId, + stablePaneId: leafId, + terminal, + container, + searchAddon: addon(), + serializeAddon: addon(), + unicode11Addon: addon(), + webLinksAddon: addon(), + fitAddon: addon(), + webglAddon: null, + imageAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + paneDragCleanup: vi.fn(), + focusClassSyncCleanup: vi.fn() + } as unknown as ManagedPaneInternal +} + +/** Uses the production tree teardown, real pane disposer and real mount closure callback. */ +export function mountedOwner( + split: boolean, + address = { + tabId: 'terminal', + worktreeId: 'wt-1', + leafId: LEAF, + ptyId: 'pty-1' + } +) { + const source = pane(1, address.leafId) + const other = pane(2, SIBLING) + const panes = new Map([[1, source], ...(split ? [[2, other] as const] : [])]) + const sourceTransport = { getPtyId: () => address.ptyId, detach: vi.fn(), destroy: vi.fn() } + const transports = new Map([[1, sourceTransport]]) + const binding = { dispose: vi.fn() } + const bindings = new Map([[1, binding]]) + const ref = () => ({ current: new Map() }) + const refs = Object.fromEntries( + [ + 'linkProviderDisposablesRef', + 'terminalHandleLinkDisposablesRef', + 'linkifierClickPrimingDisposablesRef', + 'linkPointerGesturesRef', + 'fileLinkClickFallbackDisposablesRef', + 'httpLinkClickFallbackDisposablesRef', + 'selectionDisposablesRef', + 'nativeCopyDisposablesRef', + 'imeCompositionDisposablesRef', + 'imeNativeTextForwarderDisposablesRef', + 'selectionCaptureTimersRef', + 'osc52DisposablesRef', + 'osc7DisposablesRef', + 'mouseHideDisposablesRef', + 'restoredViewportBlankingPanesRef' + ].map((name) => [name, ref()]) + ) + const link = { dispose: vi.fn() } + refs.linkProviderDisposablesRef.current.set(1, link) + const state = useAppStore.getState() + let manager: PaneManager + const managerRef: { current: PaneManager | null } = { current: null } + const deps = { + tabId: address.tabId, + worktreeId: address.worktreeId, + managerRef, + containerRef: { current: null }, + paneTransportsRef: { current: transports }, + panePtyBindingsRef: { current: bindings }, + paneMode2031Ref: ref(), + paneKittyKeyboardModesRef: ref(), + paneLastThemeModeRef: ref(), + paneCwdRef: ref(), + paneFontSizesRef: ref(), + replayingPanesRef: ref(), + clearRuntimePaneTitle: state.clearRuntimePaneTitle, + syncPanePtyLayoutBinding: vi.fn(), + clearTabPtyId: state.clearTabPtyId, + setPaneTitles: vi.fn(), + paneTitlesRef: { current: {} }, + setRenamingPaneId: vi.fn(), + setPaneCount: vi.fn(), + setTabPaneExpanded: state.setTabPaneExpanded, + setTabCanExpandPane: state.setTabCanExpandPane, + updateTabTitle: state.updateTabTitle, + persistLayoutSnapshot: vi.fn(), + getTabWideAgentHintLeafId: () => null + } + // SAFETY: this fixture exercises only registration and the concrete closure handler, using their dependencies above. + const context = { + deps, + refs, + deferredSplitHandoffs: new Map(), + getOwnedManager: () => manager + } as unknown as Pick< + PaneClosedHandlerContext, + 'deps' | 'refs' | 'deferredSplitHandoffs' | 'getOwnedManager' + > + const onPaneClosed = createTerminalPaneClosedHandler(context) + const onLayoutChanged = vi.fn() + // SAFETY: the mount's manager adapter delegates its permanent removal to the production mutation/disposer. + manager = { + getPanes: () => [...panes.values()], + getActivePane: () => panes.get(2) ?? panes.get(1) ?? null, + retirePanePreservingPty: (id: number, permanent: boolean) => + retireManagedPanePreservingPty( + { + paneId: id, + activePaneId: 1, + panes, + root: document.createElement('div'), + styleOptions: {}, + managerOptions: { linkOpenHint: () => '', onPaneClosed, onLayoutChanged }, + getDragCallbacks: () => ({ + getPanes: () => panes, + getRoot: () => source.container, + getStyleOptions: () => ({}), + isDestroyed: () => false, + safeFit: vi.fn(), + applyPaneOpacity: vi.fn(), + applyDividerStyles: vi.fn(), + refitPanesUnder: vi.fn() + }), + releasePaneIdentity: vi.fn(), + setActivePaneId: vi.fn() + }, + permanent + ), + destroy: vi.fn() + } as unknown as PaneManager + managerRef.current = manager + const cancelResize = vi.fn() + const expandedElement = document.createElement('div') + expandedElement.style.display = 'none' + const expandedStyleSnapshots = new Map([[expandedElement, { display: 'flex', flex: '1' }]]) + const removeMountEvents = installTerminalPaneMountEvents({ + manager, + deps: { + ...deps, + isActive: false, + showPaneTitle: vi.fn(), + syncCanExpandState: vi.fn(), + queueResizeAll: vi.fn() + }, + // SAFETY: event installation only captures these dependencies; no split command is issued. + ptyDeps: {} as Parameters[0]['ptyDeps'] + }) + const empty = vi.fn(() => { + removeMountEvents() + cleanupTerminalPaneMount({ + manager, + deps: context.deps, + refs: context.refs, + deferredSplitHandoffs: context.deferredSplitHandoffs, + expandedStyleSnapshots, + unregisterRuntimeTab: registration.unregisterRuntimeTab, + cancelResize, + permanent: true + }) + }) + const registration = registerTerminalPaneMount(context, empty) + registration.setOwnedManager(manager) + // A null rendering ref does not release the actual mounted owner. + managerRef.current = null + return { + source, + other, + panes, + sourceTransport, + transports, + binding, + bindings, + link, + onLayoutChanged, + manager, + empty, + cancelResize, + expandedElement, + setPaneCount: deps.setPaneCount, + unregister: () => { + removeMountEvents() + registration.unregisterRuntimeTab() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts new file mode 100644 index 000000000000..f12fd3f5dea7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts @@ -0,0 +1,243 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makeTerminalTab } from '@/store/slices/worktrees-slice-test-fixtures' +import { captureTerminalSurfaceIdentity } from '../../../../shared/terminal-tab-close' +import { retireTerminalPresentation } from './terminal-presentation-retirement' +import { capturedPanesByTabId, parkedWatchersByTabId } from './terminal-parked-watcher-registry' +import { LEAF, SIBLING, mountedOwner } from './terminal-presentation-retirement.test-fixture' +import { + createHarness, + WORKTREE_ID, + TAB_ID, + LEAF_ID, + PTY_ID, + INCARNATION_ID +} from '../../../../main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' +import { hasRegisteredRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) + +function prepare(split: boolean) { + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab({ id: 'terminal', worktreeId: 'wt-1' })] }, + terminalLayoutsByTabId: { + terminal: { + root: split + ? { + type: 'split', + direction: 'horizontal', + ratio: 0.7, + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: SIBLING } + } + : { type: 'leaf', leafId: LEAF }, + activeLeafId: split ? SIBLING : LEAF, + expandedLeafId: split ? SIBLING : null, + chatLeafId: LEAF, + ptyIdsByLeafId: { [LEAF]: 'pty-1', ...(split ? { [SIBLING]: 'pty-2' } : {}) } + } + } + }) + useAppStore + .getState() + .createUnifiedTab('wt-1', 'terminal', { id: 'terminal', entityId: 'terminal' }) + const identity = captureTerminalSurfaceIdentity(useAppStore.getState(), 'wt-1', 'terminal', LEAF) + if (!identity) { + throw new Error('Expected source identity') + } + return identity +} + +beforeEach(() => { + localStorage.clear() + useAppStore.setState(useAppStore.getInitialState(), true) + capturedPanesByTabId.clear() + parkedWatchersByTabId.clear() +}) + +describe('direct permanent presentation owner', () => { + it.each([true, false])( + 'disposes mounted resources and canonical membership (split=%s)', + (split) => { + const identity = prepare(split) + const owner = mountedOwner(split) + capturedPanesByTabId.set('terminal', { + worktreeId: 'wt-1', + panes: [{ paneId: 1, leafId: LEAF, ptyId: 'pty-1', drivesTabTitle: true }] + }) + try { + retireTerminalPresentation(identity) + expect(owner.source.terminal.dispose).toHaveBeenCalledOnce() + expect(owner.source.searchAddon.dispose).toHaveBeenCalledOnce() + expect(owner.sourceTransport.detach).toHaveBeenCalledExactlyOnceWith({ + preserveExitObserver: false + }) + expect(owner.sourceTransport.destroy).not.toHaveBeenCalled() + expect(owner.transports.has(1)).toBe(false) + expect(owner.bindings.has(1)).toBe(false) + expect(capturedPanesByTabId.has('terminal')).toBe(false) + expect(owner.binding.dispose).toHaveBeenCalledOnce() + expect(owner.link.dispose).toHaveBeenCalledOnce() + expect(owner.panes.has(1)).toBe(false) + expect(owner.setPaneCount).toHaveBeenLastCalledWith(split ? 1 : 0) + const state = useAppStore.getState() + if (split) { + expect(owner.other.terminal.dispose).not.toHaveBeenCalled() + expect(state.terminalLayoutsByTabId.terminal).toMatchObject({ + root: { type: 'leaf', leafId: SIBLING }, + activeLeafId: SIBLING, + expandedLeafId: SIBLING, + ptyIdsByLeafId: { [SIBLING]: 'pty-2' } + }) + expect(state.terminalLayoutsByTabId.terminal.chatLeafId).toBeUndefined() + } else { + expect(owner.manager.destroy).toHaveBeenCalledOnce() + expect(owner.cancelResize).toHaveBeenCalledOnce() + expect(owner.expandedElement.style.display).toBe('flex') + expect(capturedPanesByTabId.has('terminal')).toBe(false) + expect(owner.onLayoutChanged).not.toHaveBeenCalled() + expect(hasRegisteredRuntimeTerminalTab('terminal', 'wt-1')).toBe(false) + expect(state.tabsByWorktree['wt-1']).toEqual([]) + expect(state.terminalLayoutsByTabId.terminal).toBeUndefined() + } + } finally { + owner.unregister() + } + } + ) + + it.each([true, false])( + 'disposes exact parked captures, including empty watcher entries (split=%s)', + (split) => { + const identity = prepare(split) + const dispose = vi.fn() + const siblingDispose = vi.fn() + capturedPanesByTabId.set('terminal', { + worktreeId: 'wt-1', + panes: [ + { paneId: 1, leafId: LEAF, ptyId: 'pty-1', drivesTabTitle: true }, + ...(split ? [{ paneId: 2, leafId: SIBLING, ptyId: 'pty-2', drivesTabTitle: false }] : []) + ] + }) + parkedWatchersByTabId.set('terminal', { + worktreeId: 'wt-1', + tabPtyId: 'pty-1', + paneIdByPtyId: new Map([['pty-1', 1], ...(split ? [['pty-2', 2] as const] : [])]), + disposersByPtyId: new Map( + split + ? [ + ['pty-1', dispose], + ['pty-2', siblingDispose] + ] + : [] + ) + }) + retireTerminalPresentation(identity) + expect(siblingDispose).not.toHaveBeenCalled() + if (split) { + expect(dispose).toHaveBeenCalledOnce() + expect(capturedPanesByTabId.get('terminal')?.panes.map((pane) => pane.leafId)).toEqual([ + SIBLING + ]) + expect([...parkedWatchersByTabId.get('terminal')!.paneIdByPtyId.keys()]).toEqual(['pty-2']) + } else { + expect(capturedPanesByTabId.has('terminal')).toBe(false) + expect(parkedWatchersByTabId.has('terminal')).toBe(false) + } + } + ) + + it('completes host stop, direct mounted disposal and explicit durable intent together', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const session = harness.getSession() + useAppStore.setState({ + tabsByWorktree: session.tabsByWorktree, + terminalLayoutsByTabId: session.terminalLayoutsByTabId + }) + useAppStore + .getState() + .createUnifiedTab(WORKTREE_ID, 'terminal', { id: TAB_ID, entityId: TAB_ID }) + const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (!source.presentation) { + throw new Error('Expected original presentation') + } + const owner = mountedOwner(false, { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + ptyId: PTY_ID + }) + harness.setVerifiedStopResult(true) + harness.setStopAndWaitAction(async () => { + await harness.runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + expect(owner.source.terminal.dispose).not.toHaveBeenCalled() + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + harness.setCloseTerminalTabAction(() => + retireTerminalPresentation(source.presentation!.identity) + ) + try { + await expect( + harness.runtime.closeTerminal(source.handle, source.incarnationId, source.presentation) + ).resolves.toMatchObject({ ptyKilled: true }) + expect(owner.source.terminal.dispose).toHaveBeenCalledOnce() + expect(owner.transports.size).toBe(0) + expect(owner.bindings.size).toBe(0) + expect(owner.sourceTransport.destroy).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(harness.getSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeDefined() + expect(harness.flushOrThrow).toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalled() + } finally { + owner.unregister() + } + }) + + it('refuses a replacement or a newly pinned final tab before disposal', () => { + const identity = prepare(false) + const owner = mountedOwner(false) + try { + useAppStore.setState({ + terminalLayoutsByTabId: { + terminal: { + ...useAppStore.getState().terminalLayoutsByTabId.terminal, + ptyIdsByLeafId: { [LEAF]: 'replacement' } + } + } + }) + expect(() => retireTerminalPresentation(identity)).toThrow('terminal_pane_owner_changed') + expect(owner.source.terminal.dispose).not.toHaveBeenCalled() + useAppStore.setState({ + terminalLayoutsByTabId: { + terminal: { + ...useAppStore.getState().terminalLayoutsByTabId.terminal, + ptyIdsByLeafId: { [LEAF]: 'pty-1' } + } + }, + tabsByWorktree: { + 'wt-1': [{ ...useAppStore.getState().tabsByWorktree['wt-1'][0], isPinned: true }] + } + }) + expect(() => retireTerminalPresentation(identity)).toThrow('terminal_tab_pinned') + expect(owner.source.terminal.dispose).not.toHaveBeenCalled() + } finally { + owner.unregister() + } + }) + + it('reports a real disposer failure and retains source membership', () => { + const identity = prepare(false) + const owner = mountedOwner(false) + owner.source.paneDragCleanup = () => { + throw new Error('disposal failed') + } + try { + expect(() => retireTerminalPresentation(identity)).toThrow('disposal failed') + expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) + expect(owner.sourceTransport.destroy).not.toHaveBeenCalled() + } finally { + owner.unregister() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts new file mode 100644 index 000000000000..9702f0ee9c2d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts @@ -0,0 +1,79 @@ +import { useAppStore } from '@/store' +import { findRegisteredTerminalTab } from '@/runtime/mounted-terminal-tab-registry' +import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard' +import { + captureTerminalSurfaceIdentity, + isRetainedTerminalSurfaceIdentity, + type TerminalSurfaceCloseIdentity +} from '../../../../shared/terminal-tab-close' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { retireParkedTerminalLeaf } from './terminal-parked-watcher-registry' +import { collapseParkedTerminalLeaf } from './terminal-parked-pty-watcher' + +/** Admission and disposal share one synchronous owner action. */ +export function removeTerminalPresentation( + original: TerminalSurfaceCloseIdentity, + dispose: () => void +): void { + const state = useAppStore.getState() + const current = captureTerminalSurfaceIdentity( + state, + original.worktreeId, + original.tabId, + original.leafId + ) + if (!isRetainedTerminalSurfaceIdentity(original, current)) { + throw new Error('terminal_pane_owner_changed') + } + const layout = state.terminalLayoutsByTabId[original.tabId] + const split = detachTerminalLayoutLeaf(layout, original.leafId) + if ( + current?.hasLeaf && + !split && + (isUnifiedTabPinned(state, original.worktreeId, original.tabId) || + state.tabsByWorktree[original.worktreeId]?.some( + (tab) => tab.id === original.tabId && tab.isPinned + )) + ) { + throw new Error('terminal_tab_pinned') + } + const retainedPtyIds = new Set( + Object.entries(layout?.ptyIdsByLeafId ?? {}) + .filter(([leafId]) => leafId !== original.leafId) + .map(([, ptyId]) => ptyId) + ) + const paneIds = retireParkedTerminalLeaf(original, retainedPtyIds) + for (const paneId of paneIds) { + state.clearRuntimePaneTitle(original.tabId, paneId) + } + dispose() + if (!current?.hasLeaf) { + return + } + if (split) { + useAppStore.getState().setTabLayout(original.tabId, split.sourceLayout) + } else { + useAppStore.getState().closeTab(original.tabId, { + reason: 'user', + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true, + remoteCloseOwnedByHost: true + }) + } +} + +export function retireTerminalPresentation(original: TerminalSurfaceCloseIdentity): void { + const mounted = findRegisteredTerminalTab(original.tabId, original.worktreeId)?.tab + if (mounted) { + if (!mounted.retirePresentation) { + throw new Error('runtime_unavailable') + } + mounted.retirePresentation(original) + return + } + removeTerminalPresentation(original, () => { + const state = useAppStore.getState() + state.retireAgentPaneAuthority(`${original.tabId}:${original.leafId}`) + collapseParkedTerminalLeaf(original.tabId, original.leafId, original.ptyId ?? undefined) + }) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts index 6adfab7015a6..78ad00f842ad 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts @@ -4,7 +4,10 @@ import { configureTerminalOutputBacklogCap } from '@/lib/pane-manager/pane-termi import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { e2eConfig } from '@/lib/e2e-config' import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent' -import { prepareTerminalPaneMount } from './terminal-pane-mount-preparation' +import { + prepareTerminalPaneMount, + type TerminalPaneMountPreparation +} from './terminal-pane-mount-preparation' import { createTerminalPaneManagerOptions } from './terminal-pane-manager-options' import { restoreTerminalPaneLayout } from './terminal-pane-layout-restore' import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap' @@ -24,7 +27,31 @@ export function useTerminalPaneMountLifecycle( configureTerminalOutputBacklogCap(deps.settings?.terminalScrollbackRows) useEffect(() => { - const preparation = prepareTerminalPaneMount(deps, refs, mountFollowsTerminalPark) + let removeMountEvents = (): void => {} + const releaseWebviewDragPassthrough: { current: (() => void) | null } = { current: null } + const preparation: TerminalPaneMountPreparation | null = prepareTerminalPaneMount( + deps, + refs, + mountFollowsTerminalPark, + (manager) => { + if (!preparation) { + throw new Error('terminal_pane_owner_unavailable') + } + removeMountEvents() + cleanupTerminalPaneMount({ + manager, + deps, + refs, + deferredSplitHandoffs: preparation.deferredSplitHandoffs, + expandedStyleSnapshots: preparation.expandedStyleSnapshots, + unregisterRuntimeTab: preparation.unregisterRuntimeTab, + cancelResize: preparation.cancelResizeAll, + permanent: true + }) + releaseWebviewDragPassthrough.current?.() + releaseWebviewDragPassthrough.current = null + } + ) if (!preparation) { return } @@ -36,11 +63,9 @@ export function useTerminalPaneMountLifecycle( unregisterRuntimeTab } = preparation let shouldPersistLayout = false - const releaseWebviewDragPassthrough: { current: (() => void) | null } = { - current: null - } const queueResizeAll = preparation.queueResizeAll const managerContext = { + getOwnedManager: preparation.getOwnedManager, deps, refs, ptyDeps, @@ -74,6 +99,7 @@ export function useTerminalPaneMountLifecycle( } const manager = new PaneManager(container, createTerminalPaneManagerOptions(managerContext)) deps.managerRef.current = manager + preparation.setOwnedManager(manager) if (e2eConfig.exposeStore) { window.__paneManagers = window.__paneManagers ?? new Map() window.__paneManagers.set(deps.tabId, manager) @@ -110,7 +136,7 @@ export function useTerminalPaneMountLifecycle( queueResizeAll(deps.isActive) deps.persistLayoutSnapshot() scheduleRuntimeGraphSync() - const removeMountEvents = installTerminalPaneMountEvents({ + removeMountEvents = installTerminalPaneMountEvents({ manager, deps: { tabId: deps.tabId, @@ -126,15 +152,17 @@ export function useTerminalPaneMountLifecycle( }) return () => { removeMountEvents() - cleanupTerminalPaneMount({ - manager, - deps, - refs, - deferredSplitHandoffs, - expandedStyleSnapshots, - unregisterRuntimeTab, - cancelResize: preparation.cancelResizeAll - }) + if (preparation.getOwnedManager() === manager) { + cleanupTerminalPaneMount({ + manager, + deps, + refs, + deferredSplitHandoffs, + expandedStyleSnapshots, + unregisterRuntimeTab, + cancelResize: preparation.cancelResizeAll + }) + } releaseWebviewDragPassthrough.current?.() releaseWebviewDragPassthrough.current = null } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index 24ed31b9ae16..d15236c0d9c2 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -1,3 +1,4 @@ +import { retireTerminalPresentation } from '@/components/terminal-pane/terminal-presentation-retirement' import { createRuntimeFilePositionQueue } from './runtime-file-position' import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' @@ -167,7 +168,7 @@ export function registerMobileAndTerminalCloseIpcBridge( if (window.api.ui.onTerminalTabCloseRequest) { unsubs.push( window.api.ui.onTerminalTabCloseRequest( - ({ requestId, tabId, localPtyTeardownOwnedExternally, force }) => { + ({ requestId, tabId, localPtyTeardownOwnedExternally, force, originalSurface }) => { let responded = false const respond = (error?: string): void => { if (responded) { @@ -176,6 +177,24 @@ export function registerMobileAndTerminalCloseIpcBridge( responded = true window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) }) } + if (originalSurface) { + void (async () => { + if (originalSurface.tabId !== tabId || !localPtyTeardownOwnedExternally) { + throw new Error('terminal_pane_owner_changed') + } + retireTerminalPresentation(originalSurface) + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + respond() + })().catch((error: unknown) => + respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') + ) + return + } closeTerminalTab(tabId, { rejectPinned: true, ...(force ? { force: true } : {}), diff --git a/src/renderer/src/lib/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/ai-vault-tab-title-requests.ts index 466939780214..55909304c56b 100644 --- a/src/renderer/src/lib/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/ai-vault-tab-title-requests.ts @@ -8,7 +8,9 @@ import type { TerminalTab } from '../../../shared/terminal-tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' import type { AgentPaneConversationIdentity } from '@/store/slices/agent-pane-conversation' -import { resolvePaneKey } from './agent-status-pane-ownership' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { getIndexedRepoMap, getIndexedWorktreeMap } from '@/store/worktree-repo-index' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' /** The conversation a tab's pane runs, for an agent `isAgent` accepts. */ export type AgentPaneConversation = { @@ -26,6 +28,8 @@ export type AiVaultTitleRequest = Omit< 'paneKey' > +type ConversationTab = TerminalTab & { worktreeId: string; leafIds?: ReadonlySet } + type RequestCandidate = AgentPaneConversation & { priority: number } @@ -41,7 +45,7 @@ function activePaneKey(state: AppState, tabId: string): string | null { function registerCandidate( state: AppState, - tabsById: ReadonlyMap, + tabsById: ReadonlyMap, candidates: Map>, isAgent: (agent: AgentType | null | undefined) => agent is Agent, args: { @@ -60,8 +64,15 @@ function registerCandidate( } const tabId = tabIdFromPaneKey(args.paneKey, args.tabId) const tab = tabId ? tabsById.get(tabId) : undefined - const worktreeId = args.worktreeId ?? tab?.worktreeId - if (!tabId || !tab || !worktreeId) { + const worktreeId = tab?.worktreeId + const pane = parsePaneKey(args.paneKey) + if ( + !tabId || + !tab || + !worktreeId || + (pane && (pane.tabId !== tabId || (tab.leafIds && !tab.leafIds.has(pane.leafId)))) || + (args.worktreeId !== undefined && args.worktreeId !== worktreeId) + ) { return } const priority = args.priority + (activePaneKey(state, tabId) === args.paneKey ? 100 : 0) @@ -96,20 +107,30 @@ function liveCandidate(entry: AgentStatusEntry): Parameters ): Parameters[4] | null { - const owner = resolvePaneKey(state, identity.paneKey) + const pane = parsePaneKey(identity.paneKey) + const tab = pane ? tabsById.get(pane.tabId) : undefined + const worktree = getIndexedWorktreeMap(selectWorktreesByRepo(state)).get(identity.worktreeId) + const repo = worktree ? getIndexedRepoMap(state.repos).get(worktree.repoId) : undefined if ( - !owner.exists || - owner.owningWorktreeId !== identity.worktreeId || + !pane || + tab?.worktreeId !== identity.worktreeId || + (tab.leafIds && !tab.leafIds.has(pane.leafId)) || identity.executionHostId !== getExecutionHostIdForWorktree(state, identity.worktreeId) || - (owner.repoConnectionResolved && identity.connectionId !== owner.repoConnectionId) + (repo !== undefined && identity.connectionId !== (repo.connectionId ?? null)) ) { return null } return { ...identity, priority: 0, refresh: false } } +function conversationRecords(records: Record | undefined, paneKey?: string): T[] { + const addressed = paneKey ? records?.[paneKey] : undefined + return paneKey ? (addressed ? [addressed] : []) : Object.values(records ?? {}) +} + export function collectAiVaultTitleRequests(state: AppState): AiVaultTitleRequest[] { return collectAgentPaneConversations(state, isAiVaultTitleAgent).map( ({ paneKey: _paneKey, ...request }) => request @@ -125,24 +146,60 @@ export function collectAgentPaneConversations( runningAgentsOnly?: boolean /** History addresses a conversation in any pane, including an inactive split. */ allPanes?: boolean + pane?: { worktreeId: string; paneKey: string } + tab?: { worktreeId: string; tabId: string } } = {} ): AgentPaneConversation[] { - const tabsById = new Map( - Object.values(state.tabsByWorktree) - .flat() - .map((tab) => [tab.id, tab] as const) - ) + const addressed = options.pane + ? { + worktreeId: options.pane.worktreeId, + tabId: tabIdFromPaneKey( + options.pane.paneKey, + state.agentStatusByPaneKey[options.pane.paneKey]?.tabId ?? + state.sleepingAgentSessionsByPaneKey[options.pane.paneKey]?.tabId ?? + state.retainedAgentsByPaneKey[options.pane.paneKey]?.entry.tabId + ) + } + : options.tab + const partitions = addressed + ? [ + [ + addressed.worktreeId, + (state.tabsByWorktree[addressed.worktreeId] ?? []).filter( + (tab) => tab.id === addressed.tabId + ) + ] as const + ] + : Object.entries(state.tabsByWorktree) + const tabsById = new Map() + for (const [worktreeId, tabs] of partitions) { + for (const tab of tabs) { + const root = state.terminalLayoutsByTabId[tab.id]?.root + tabsById.set(tab.id, { + ...tab, + worktreeId, + ...(root ? { leafIds: new Set(collectLeafIdsInOrder(root)) } : {}) + }) + } + } + const records = (record: Record | undefined) => + conversationRecords(record, options.pane?.paneKey) const candidates = new Map>() - const retained = options.runningAgentsOnly ? [] : Object.values(state.retainedAgentsByPaneKey) - const sleeping = options.runningAgentsOnly - ? [] - : Object.values(state.sleepingAgentSessionsByPaneKey) + const retained = options.runningAgentsOnly ? [] : records(state.retainedAgentsByPaneKey) + const sleeping = options.runningAgentsOnly ? [] : records(state.sleepingAgentSessionsByPaneKey) if (!options.runningAgentsOnly) { - for (const identity of Object.values(state.agentConversationsByPaneKey ?? {})) { - const candidate = lastReportedCandidate(state, identity) + for (const identity of records(state.agentConversationsByPaneKey)) { + const candidate = lastReportedCandidate(state, identity, tabsById) if (candidate) { - registerCandidate(state, tabsById, candidates, isAgent, candidate, options.allPanes) + registerCandidate( + state, + tabsById, + candidates, + isAgent, + candidate, + options.allPanes || !!options.pane + ) } } } @@ -162,7 +219,7 @@ export function collectAgentPaneConversations( tabId: entry.entry.tabId, worktreeId: entry.worktreeId }, - options.allPanes + options.allPanes || !!options.pane ) } for (const record of sleeping) { @@ -180,14 +237,23 @@ export function collectAgentPaneConversations( tabId: record.tabId, worktreeId: record.worktreeId }, - options.allPanes + options.allPanes || !!options.pane ) } - for (const entry of Object.values(state.agentStatusByPaneKey)) { - registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(entry), options.allPanes) + for (const entry of records(state.agentStatusByPaneKey)) { + registerCandidate( + state, + tabsById, + candidates, + isAgent, + liveCandidate(entry), + options.allPanes || !!options.pane + ) } - return [...candidates.values()].map(({ priority: _priority, ...request }) => request) + return [...candidates.values()] + .filter((candidate) => !options.pane || candidate.paneKey === options.pane.paneKey) + .map(({ priority: _priority, ...request }) => request) } /** One pane's running agent, mapped as above, so a pane's action finds that pane's own conversation. */ @@ -196,21 +262,7 @@ export function resolvePaneAgentConversation( pane: { worktreeId: string; paneKey: string }, isAgent: (agent: AgentType | null | undefined) => agent is Agent ): AgentPaneConversation | null { - const live = state.agentStatusByPaneKey[pane.paneKey] - if (live?.paneKey !== pane.paneKey) { - return null - } - const tabsById = new Map( - (state.tabsByWorktree[pane.worktreeId] ?? []).map((tab) => [tab.id, tab] as const) - ) - const candidates = new Map>() - registerCandidate(state, tabsById, candidates, isAgent, liveCandidate(live)) - const [candidate] = candidates.values() - if (!candidate) { - return null - } - const { priority: _priority, ...request } = candidate - return request + return collectAgentPaneConversations(state, isAgent, { pane, runningAgentsOnly: true })[0] ?? null } /** The PTY a pane is bound to now. */ @@ -225,45 +277,5 @@ export function resolvePaneLastAgentConversation( pane: { worktreeId: string; paneKey: string }, isAgent: (agent: AgentType | null | undefined) => agent is Agent ): AgentPaneConversation | null { - const live = resolvePaneAgentConversation(state, pane, isAgent) - const retained = state.retainedAgentsByPaneKey[pane.paneKey] - const saved = state.sleepingAgentSessionsByPaneKey[pane.paneKey] - const tab = (state.tabsByWorktree[pane.worktreeId] ?? []).find( - (candidate) => candidate.id === parsePaneKey(pane.paneKey)?.tabId - ) - const source = - retained?.entry.paneKey === pane.paneKey && retained.worktreeId === pane.worktreeId - ? retained - : tab && saved?.worktreeId === pane.worktreeId - ? { tab, agentType: saved.agent, entry: saved } - : null - if (live) { - return live - } - const candidates = new Map>() - const tabsById = new Map(source ? [[source.tab.id, source.tab]] : tab ? [[tab.id, tab]] : []) - if (source) { - registerCandidate(state, tabsById, candidates, isAgent, { - agent: source.agentType, - paneKey: pane.paneKey, - priority: 10, - providerSession: source.entry.providerSession, - refresh: false, - tabId: source.tab.id, - worktreeId: pane.worktreeId - }) - } - const identity = state.agentConversationsByPaneKey?.[pane.paneKey] - if (identity?.worktreeId === pane.worktreeId && identity.paneKey === pane.paneKey) { - const candidate = lastReportedCandidate(state, identity) - if (candidate) { - registerCandidate(state, tabsById, candidates, isAgent, candidate) - } - } - const [candidate] = candidates.values() - if (!candidate) { - return null - } - const { priority: _priority, ...request } = candidate - return request + return collectAgentPaneConversations(state, isAgent, { pane })[0] ?? null } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts index b3758730d596..52878604f87e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts @@ -94,18 +94,25 @@ export function detachPaneForExternalMoveOnManager(host: PaneManagerHost, paneId }) } -export function retirePanePreservingPtyOnManager(host: PaneManagerHost, paneId: number): boolean { - return retireManagedPanePreservingPty({ - paneId, - activePaneId: host.getActivePaneId(), - panes: host.panes, - root: host.root, - styleOptions: host.getStyleOptions(), - managerOptions: host.options, - getDragCallbacks: () => host.getDragCallbacks(), - releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId), - setActivePaneId: (id) => { - host.setActivePaneId(id) - } - }) +export function retirePanePreservingPtyOnManager( + host: PaneManagerHost, + paneId: number, + permanent = false +): boolean { + return retireManagedPanePreservingPty( + { + paneId, + activePaneId: host.getActivePaneId(), + panes: host.panes, + root: host.root, + styleOptions: host.getStyleOptions(), + managerOptions: host.options, + getDragCallbacks: () => host.getDragCallbacks(), + releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId), + setActivePaneId: (id) => { + host.setActivePaneId(id) + } + }, + permanent + ) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index dc0357403194..8d7ee20cd0a1 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -38,7 +38,7 @@ export type PaneSplitOptions = Omit & { export type ClosedPaneInfo = { paneId: number leafId: TerminalLeafId - reason?: 'close' | 'detach' | 'retire' + reason?: 'close' | 'detach' | 'retire' | 'remove' } export type PaneExternalDropTarget = { diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index e1febd2d0df3..a303a7674b22 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -155,8 +155,8 @@ export class PaneManager { return detachPaneForExternalMoveOnManager(this.host, paneId) } - retirePanePreservingPty(paneId: number): boolean { - return retirePanePreservingPtyOnManager(this.host, paneId) + retirePanePreservingPty(paneId: number, permanent = false): boolean { + return retirePanePreservingPtyOnManager(this.host, paneId, permanent) } getPanes(limit = Number.POSITIVE_INFINITY): ManagedPane[] { diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 6cf53fe9a2df..32731cb76a91 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -185,7 +185,7 @@ type CloseManagedPaneArgs = { function teardownManagedPane( args: CloseManagedPaneArgs, - reason: 'close' | 'detach' | 'retire' + reason: 'close' | 'detach' | 'retire' | 'remove' ): void { const pane = args.panes.get(args.paneId) if (!pane) { @@ -194,7 +194,7 @@ function teardownManagedPane( const closedLeafId = pane.leafId args.releasePaneIdentity(args.paneId) removePaneContainer(args, pane) - if (reason === 'close') { + if (reason === 'close' || reason === 'remove') { // Leaf ids are minted UUIDs and never reused, so a closed leaf's scroll // intent is unreachable. Detach/retire hand the leaf to a new host, which // must still be able to restore it. @@ -211,7 +211,9 @@ function teardownManagedPane( leafId: closedLeafId, reason }) - args.managerOptions.onLayoutChanged?.() + if (reason !== 'remove') { + args.managerOptions.onLayoutChanged?.() + } } export function closeManagedPane(args: CloseManagedPaneArgs): void { @@ -230,11 +232,14 @@ export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): bo return true } -export function retireManagedPanePreservingPty(args: CloseManagedPaneArgs): boolean { - if (!args.panes.has(args.paneId) || args.panes.size <= 1) { +export function retireManagedPanePreservingPty( + args: CloseManagedPaneArgs, + permanent = false +): boolean { + if (!args.panes.has(args.paneId) || (!permanent && args.panes.size <= 1)) { return false } - teardownManagedPane(args, 'retire') + teardownManagedPane(args, permanent ? 'remove' : 'retire') return true } diff --git a/src/renderer/src/runtime/mounted-terminal-tab-registry.ts b/src/renderer/src/runtime/mounted-terminal-tab-registry.ts index 6f35bc24f292..30775c98bb8b 100644 --- a/src/renderer/src/runtime/mounted-terminal-tab-registry.ts +++ b/src/renderer/src/runtime/mounted-terminal-tab-registry.ts @@ -5,7 +5,10 @@ import { import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' +import type { TerminalSurfaceCloseIdentity } from '../../../shared/terminal-tab-close' + export type RegisteredTerminalTab = { + retirePresentation?: (original: TerminalSurfaceCloseIdentity) => void tabId: string worktreeId: string getManager: () => PaneManager | null diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d73d78f7eb48..6bf0b47a89ce 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -582,6 +582,7 @@ import { import { AgentTeamsPrepareLaunch, AgentTeamsTmuxCompat, + TerminalClose, TerminalCloseAll, TerminalCreateParams, TerminalFocus, @@ -1352,7 +1353,7 @@ export const RPC_PARAMS_BY_METHOD = { 'terminal.adoptOrphans': TerminalAdoptOrphans, 'terminal.agentStatus': TerminalHandle, 'terminal.clearBuffer': TerminalHandle, - 'terminal.close': TerminalHandle, + 'terminal.close': TerminalClose, 'terminal.closeAll': TerminalCloseAll, 'terminal.closeTab': TerminalHandle, 'terminal.create': TerminalCreateParams, diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index b0c1cfc3ab0e..49b1ba6aaa45 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -22,12 +22,38 @@ export const WindowsShellOverrideParam = z // spawn path exact-matches `.exe` spellings and must never see `cmd` or `Git-Bash`. .transform((shell) => canonicalizeWindowsShellOverride(shell) ?? shell) +const TerminalSurfaceCloseIdentity = z.object({ + worktreeId: requiredString('Missing worktree id'), + tabId: requiredString('Missing tab id'), + leafId: requiredString('Missing leaf id'), + createdAt: z.number().finite().nullable(), + generation: z.number().int().nonnegative(), + hasLeaf: z.boolean(), + ptyId: z.string().nullable(), + incarnationId: z.string().nullable() +}) +const TerminalSurfaceCloseCapture = z.object({ + identity: TerminalSurfaceCloseIdentity, + holders: z + .array( + z.object({ + hostId: OptionalExecutionHostId.unwrap(), + identity: TerminalSurfaceCloseIdentity.nullable() + }) + ) + .max(64) +}) + export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle'), // Additive fence understood by newer hosts; legacy hosts safely ignore it. expectedIncarnationId: requiredString('Missing PTY incarnation').optional() }) +export const TerminalClose = TerminalHandle.extend({ + originalPresentation: TerminalSurfaceCloseCapture.optional() +}) + export const TerminalReadKittyFile = TerminalHandle.extend({ path: z .string() diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 4c6cf8d8bccb..fb0864b9d808 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { TerminalSurfaceCloseCapture } from './terminal-tab-close' import type { TerminalLaunchResult } from './agent-session-resume' import type * as AgentSessionResume from './agent-session-resume' import type { StartupCommandDelivery } from './codex-startup-delivery' @@ -285,6 +286,8 @@ export type RuntimeTerminalSplit = { } export type RuntimeTerminalResolvePane = TerminalLaunchResult & { + presentation?: TerminalSurfaceCloseCapture + handle: string /** Host-owned PTY incarnation used to fence remote identity observations. */ incarnationId?: string | null diff --git a/src/shared/terminal-tab-close.ts b/src/shared/terminal-tab-close.ts index 75a77fbfc2bd..61f33b35ffe6 100644 --- a/src/shared/terminal-tab-close.ts +++ b/src/shared/terminal-tab-close.ts @@ -1,11 +1,119 @@ +import type { ExecutionHostId } from './execution-host' +import type { WorkspaceSessionState } from './workspace-session-state-types' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' + export type TerminalTabCloseRequest = { requestId: string tabId: string localPtyTeardownOwnedExternally?: boolean force?: boolean + originalSurface?: TerminalSurfaceCloseIdentity } export type TerminalTabCloseResponse = { requestId: string error?: string } + +/** Captured presentation facts only narrow the authenticated terminal close. */ +export type TerminalSurfaceCloseIdentity = { + worktreeId: string + tabId: string + leafId: string + createdAt: number | null + generation: number + hasLeaf: boolean + ptyId: string | null + incarnationId: string | null +} + +export type TerminalSurfaceCloseCapture = { + identity: TerminalSurfaceCloseIdentity + holders: { hostId: ExecutionHostId; identity: TerminalSurfaceCloseIdentity | null }[] +} + +export function isRetainedTerminalSurfaceIdentity( + original: TerminalSurfaceCloseIdentity, + current: TerminalSurfaceCloseIdentity | null +): boolean { + return ( + current === null || + (current.worktreeId === original.worktreeId && + current.tabId === original.tabId && + current.leafId === original.leafId && + current.createdAt === original.createdAt && + current.generation === original.generation && + (!current.hasLeaf || original.hasLeaf) && + (current.ptyId === null || current.ptyId === original.ptyId) && + (current.incarnationId === null || current.incarnationId === original.incarnationId)) + ) +} + +export function terminalTreeHasLeaf( + node: TerminalPaneLayoutNode | null | undefined, + leafId: string +): boolean { + return ( + !!node && + (node.type === 'leaf' + ? node.leafId === leafId + : terminalTreeHasLeaf(node.first, leafId) || terminalTreeHasLeaf(node.second, leafId)) + ) +} + +export function captureTerminalSurfaceIdentity( + state: + | (Pick & + Partial>) + | null + | undefined, + worktreeId: string, + tabId: string, + leafId: string +): TerminalSurfaceCloseIdentity | null { + const row = state?.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) + if (!row || !state) { + return null + } + const layout = state.terminalLayoutsByTabId[tabId] + return { + worktreeId, + tabId, + leafId, + createdAt: row.createdAt, + generation: row.generation ?? 0, + hasLeaf: terminalTreeHasLeaf(layout?.root, leafId), + ptyId: layout?.ptyIdsByLeafId?.[leafId] ?? null, + incarnationId: state.terminalPtyIncarnationsByPaneKey?.[`${tabId}:${leafId}`] ?? null + } +} + +export function matchesTerminalSurfaceCapture( + original: TerminalSurfaceCloseCapture, + hostIds: readonly ExecutionHostId[], + read: (hostId: ExecutionHostId) => Parameters[0] +): boolean { + return ( + original.identity.hasLeaf && + original.holders.length > 0 && + original.holders.every((holder) => + isRetainedTerminalSurfaceIdentity(original.identity, holder.identity) + ) && + [...new Set([...original.holders.map((holder) => holder.hostId), ...hostIds])].every( + (hostId) => { + const holder = original.holders.find((entry) => entry.hostId === hostId) + const next = captureTerminalSurfaceIdentity( + read(hostId), + original.identity.worktreeId, + original.identity.tabId, + original.identity.leafId + ) + return holder + ? holder.identity + ? isRetainedTerminalSurfaceIdentity(holder.identity, next) + : next === null + : next === null + } + ) + ) +} From e044d311ce84318f9996f5a2add3e998ea77dea7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:10:23 -0700 Subject: [PATCH 045/207] refactor: remove redundant terminal switch close logic --- src/main/ipc/pty/ipc/renderer-kill.ts | 13 ++----------- src/main/ipc/pty/runtime/controller-deps.ts | 13 ++----------- src/main/ipc/pty/session.ts | 13 ++----------- .../session-tabs/paired-host-terminal-close.ts | 2 +- src/main/runtime/terminal-surface-close.ts | 17 ++--------------- .../tab-bar/cli-chat-resume-source.ts | 7 ++----- 6 files changed, 11 insertions(+), 54 deletions(-) diff --git a/src/main/ipc/pty/ipc/renderer-kill.ts b/src/main/ipc/pty/ipc/renderer-kill.ts index 464c80e7af2d..60407de974de 100644 --- a/src/main/ipc/pty/ipc/renderer-kill.ts +++ b/src/main/ipc/pty/ipc/renderer-kill.ts @@ -3,7 +3,7 @@ import { getPtyIpc } from '../../pty-host-bindings' import type { Store } from '../../../persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { TerminalIntentionalStopKind } from '../../../runtime/terminal-intentional-stops' -import type { IPtyProvider } from '../../../providers/pty-provider-contract' +import type { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' import { ptyIncarnationById } from '../provider/ownership-state' import { @@ -21,16 +21,7 @@ export type PtyKillIpcDeps = { store?: Store runtime?: OrcaRuntimeService getLocalPtyProviderStartupPromise: (hostId?: ResolvedPtyHost) => Promise | undefined - shutdownProviderAndDetectExit: ( - provider: IPtyProvider, - id: string, - opts: { - immediate?: boolean - keepHistory?: boolean - deadlineMs?: number - expectedIncarnationId?: string - } - ) => Promise + shutdownProviderAndDetectExit: typeof shutdownProviderAndDetectExit rememberSyntheticKillExit: (id: string, incarnationId?: string) => void sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void } diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 44f39f37ab22..547fe37e1c17 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -1,7 +1,7 @@ import type { PtyRendererDelivery } from '../session' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { Store } from '../../../persistence' -import type { IPtyProvider } from '../../../providers/pty-provider-contract' +import type { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' import type { @@ -42,16 +42,7 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & { lastTitle?: string kittyKeyboardFlags?: number } | null> - shutdownProviderAndDetectExit: ( - provider: IPtyProvider, - id: string, - opts: { - immediate?: boolean - keepHistory?: boolean - deadlineMs?: number - expectedIncarnationId?: string - } - ) => Promise + shutdownProviderAndDetectExit: typeof shutdownProviderAndDetectExit rememberSyntheticKillExit: (id: string, incarnationId?: string) => void rememberRetiredRejectedPty: (id: string) => void sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts index 287c72f599c7..f9acd09864b7 100644 --- a/src/main/ipc/pty/session.ts +++ b/src/main/ipc/pty/session.ts @@ -2,7 +2,7 @@ import type { BrowserWindow, WebContents } from 'electron' import type { OrcaRuntimeService } from '../../runtime/orca-runtime' import type { Store } from '../../persistence' import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { IPtyProvider } from '../../providers/pty-provider-contract' +import type { shutdownProviderAndDetectExit } from './provider/shutdown-detect' import type { PtyModelRestoreReason } from '../../../shared/pty-model-restore-marker' import type { PtyDeliveryWriteOff, @@ -144,16 +144,7 @@ export type PtyIpcSession = { ptyId: string, opts?: { scrollbackRows?: number } ) => Promise - shutdownProviderAndDetectExit: ( - provider: IPtyProvider, - id: string, - opts: { - immediate?: boolean - keepHistory?: boolean - deadlineMs?: number - expectedIncarnationId?: string - } - ) => Promise + shutdownProviderAndDetectExit: typeof shutdownProviderAndDetectExit rememberSyntheticKillExit: (id: string, incarnationId?: string) => void rememberRetiredRejectedPty: (id: string) => void consumeSyntheticKillExit: (id: string, incarnationId?: string) => boolean diff --git a/src/main/runtime/session-tabs/paired-host-terminal-close.ts b/src/main/runtime/session-tabs/paired-host-terminal-close.ts index a942ad82b7b0..7f3fa10ea799 100644 --- a/src/main/runtime/session-tabs/paired-host-terminal-close.ts +++ b/src/main/runtime/session-tabs/paired-host-terminal-close.ts @@ -79,7 +79,7 @@ export async function closeTerminalOnPairedHost( if (original?.originalPresentation) { throw error } - if (code === 'terminal_not_found' && !original?.originalPresentation) { + if (code === 'terminal_not_found') { // The owner has no such terminal, so nothing runs there to stop. return { ptyKilled: false } } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 9b9a0bc54f87..8d75e65f86e1 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -197,7 +197,7 @@ export type TerminalSurfaceCloseCommit = { requestedSession: WorkspaceSessionState | null | undefined /** The tab's owner identity still matches the one the close was asked against. */ ownerMatches: () => boolean - /** The partitions holding the tab, routed partition first; empty when unroutable. */ + /** The tab's partitions, routed first; original-aware calls return captured and current holders. */ hostIds: () => readonly ExecutionHostId[] /** Owner-fenced closes only: each holder's copy of the tab when the close was asked. A second * copy that is new or rebound since refuses the close, since `ownerMatches` fences only the @@ -239,20 +239,7 @@ export function terminalSurfaceCloseMutation( : undefined return () => { const original = commit.options.originalPresentation - const hostIds = original - ? [ - ...new Set([ - ...original.holders.map((holder) => holder.hostId), - ...commit - .hostIds() - .filter((id) => - commit - .getSession(id) - ?.tabsByWorktree[commit.worktreeId]?.some((row) => row.id === target.tabId) - ) - ]) - ] - : commit.hostIds() + const hostIds = commit.hostIds() if ( !commit.ownerMatches() || (original && !matchesTerminalSurfaceCapture(original, hostIds, commit.getSession)) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts index dffa713c3a39..7495c55f4598 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-source.ts @@ -11,7 +11,6 @@ import { resolvePaneLastAgentConversation } from '@/lib/ai-vault-tab-title-requests' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { adoptsCliConversations, type CliConversationRowAgent @@ -34,7 +33,6 @@ export function resolveCliChatResumeSource( state: AppState, args: { tab: { id: string; worktreeId: string }; paneKey?: string } ): CliChatResumeSource | null { - const activeLeafId = state.terminalLayoutsByTabId[args.tab.id]?.activeLeafId const paneConversation = (paneKey: string) => resolvePaneLastAgentConversation( state, @@ -43,10 +41,9 @@ export function resolveCliChatResumeSource( ) const conversation = args.paneKey ? paneConversation(args.paneKey) - : (collectAgentPaneConversations(state, adoptsCliConversations, { + : collectAgentPaneConversations(state, adoptsCliConversations, { tab: { worktreeId: args.tab.worktreeId, tabId: args.tab.id } - }).find((candidate) => candidate.tabId === args.tab.id) ?? - (activeLeafId ? paneConversation(makePaneKey(args.tab.id, activeLeafId)) : null)) + }).find((candidate) => candidate.tabId === args.tab.id) if ( !conversation || conversation.tabId !== args.tab.id || From 658a6c9db8db6fa70a439b68ff8375a0595a8a37 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:40:38 -0700 Subject: [PATCH 046/207] Fix switch capability ownership and required terminal close failures --- src/main/ipc/pty/spawn-commit-assembly.ts | 3 +- .../layout-command-persistence.ts | 12 +- .../mobile-session-tab-close-outcome.ts | 2 +- ...ca-runtime-original-terminal-close.test.ts | 4 +- .../terminal-surface-close-commit.ts | 3 +- .../terminal-tab-close-request-relay.ts | 2 +- .../ai-vault-history-native-return.test.tsx | 48 +++- ...t-resume-parked-retirement.test-fixture.ts | 58 ++--- .../tab-bar/cli-chat-resume-run.test.ts | 44 ++-- .../components/tab-bar/cli-chat-resume-run.ts | 34 +-- .../cli-chat-resume-terminal-capture.ts | 89 ++++++++ .../terminal-pane-mount-registration.ts | 37 +++- ...al-presentation-retirement.test-fixture.ts | 7 +- .../terminal-presentation-retirement.test.ts | 206 +++++++++++++----- .../terminal-presentation-retirement.ts | 36 +-- .../mobile-terminal-close-ipc-bridge.ts | 29 ++- .../src/lib/in-place-switch-support.test.tsx | 121 ++++++++-- .../src/lib/in-place-switch-support.ts | 71 +++--- .../lib/pane-manager/pane-inline-images.ts | 6 +- .../src/lib/pane-manager/pane-lifecycle.ts | 51 ++--- .../pane-manager-tree-mutations.ts | 4 +- .../src/lib/pane-manager/pane-manager.ts | 4 +- .../src/lib/pane-manager/pane-split-close.ts | 3 +- .../lib/pane-manager/pane-webgl-renderer.ts | 6 +- ...-chat-resume-terminal-capture.unit.test.ts | 165 ++++++++++++++ ...minal-presentation-retirement.unit.test.ts | 144 ++++++++++++ 26 files changed, 901 insertions(+), 288 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts create mode 100644 tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts create mode 100644 tests/e2e/terminal-presentation-retirement.unit.test.ts diff --git a/src/main/ipc/pty/spawn-commit-assembly.ts b/src/main/ipc/pty/spawn-commit-assembly.ts index 6d35c4e5d090..67da85e04fdb 100644 --- a/src/main/ipc/pty/spawn-commit-assembly.ts +++ b/src/main/ipc/pty/spawn-commit-assembly.ts @@ -51,10 +51,9 @@ export type PtySpawnCommitState = { | 'tabId' | 'launchAgent' | 'resumeProviderSession' - | 'resumeOrigin' | 'onResumeOutcome' | 'telemetry' - > + > & { resumeOrigin?: unknown } result: PtySpawnResult provider: IPtyProvider cwd: string | undefined diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index 0724015a0e73..dceff378cc13 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -1,5 +1,4 @@ import type { TerminalSurfaceCloseCapture } from '../../../shared/terminal-tab-close' -import { closeLeafOrTab } from './terminal-topology-commit' import type { CommandOf, LayoutApplyOutcome, @@ -18,6 +17,7 @@ import type { } from '../loading-store/store-runtime-state' import type { ExecutionHostId } from '../../../shared/execution-host' import { + closeLeafOrTab, applyLayout, applyEditorFileRenamesInPartitions, topologyCommitContext @@ -134,14 +134,14 @@ export class LayoutCommandPersistence { command.tabIds[0] === source.tabId), hostIds, getSession: (id) => context.sessions.getWorkspaceSession(id), - setSession: (session, id) => - context.runtime.dirtyProfileStateDomains.add( - assignWorkspaceSessionPartition(context.runtime.state, id, session) - ), + setSession: (session, id) => { + const domain = assignWorkspaceSessionPartition(context.runtime.state, id, session) + context.runtime.dirtyProfileStateDomains?.add(domain) + }, onClosed: () => {} })() if (mutation.value) { - return mutation + return { ...mutation, value: mutation.value } } return { ...mutation, diff --git a/src/main/runtime/mobile-session-tab-close-outcome.ts b/src/main/runtime/mobile-session-tab-close-outcome.ts index 4da177919330..ec1588342c80 100644 --- a/src/main/runtime/mobile-session-tab-close-outcome.ts +++ b/src/main/runtime/mobile-session-tab-close-outcome.ts @@ -1,10 +1,10 @@ import type { TerminalSurfaceCloseCapture } from '../../shared/terminal-tab-close' import type { + RuntimeMobileSessionTabCloseResult, RuntimeMobileSessionRetiredTerminalSurface, RuntimeSessionTabCloseReason } from '../../shared/runtime-types' import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records' -import type { RuntimeMobileSessionTabCloseResult } from '../../shared/runtime-types' import type { ClientSessionTabSelectionStore } from './client-session-tab-selection' // Why: a selection tombstone (ClientSessionTabSelectionStore.forgetTabs) is not a diff --git a/src/main/runtime/orca-runtime-original-terminal-close.test.ts b/src/main/runtime/orca-runtime-original-terminal-close.test.ts index a643d32fd704..3e4dee48a584 100644 --- a/src/main/runtime/orca-runtime-original-terminal-close.test.ts +++ b/src/main/runtime/orca-runtime-original-terminal-close.test.ts @@ -16,10 +16,10 @@ function captured(harness: ReturnType) { if (!source.presentation || !source.incarnationId) { throw new Error('Missing original source') } + const { incarnationId, presentation } = source return { source, - close: () => - harness.runtime.closeTerminal(source.handle, source.incarnationId, source.presentation) + close: () => harness.runtime.closeTerminal(source.handle, incarnationId, presentation) } } diff --git a/src/main/runtime/session-tabs/terminal-surface-close-commit.ts b/src/main/runtime/session-tabs/terminal-surface-close-commit.ts index 23beb61800f2..3e2309fc6f9c 100644 --- a/src/main/runtime/session-tabs/terminal-surface-close-commit.ts +++ b/src/main/runtime/session-tabs/terminal-surface-close-commit.ts @@ -1,5 +1,4 @@ import type { RuntimeMobileSessionTerminalTab } from '../../../shared/runtime-types' -import { resolveTerminalCloseTarget, type PaneCloseResolution } from '../terminal-surface-close' import { isRetainedTerminalSurfaceIdentity, type TerminalSurfaceCloseIdentity @@ -10,6 +9,8 @@ import { OrcaRuntimeWithPersistTerminalSurfaceRetirements } from './terminal-sur import { captureAcknowledgedTerminalTabRetirement } from '../workspace-session-terminal-tab-retirement-identity' import { closeLeafOrTab } from '../../persistence/terminal-topology/terminal-topology-commit' import { + resolveTerminalCloseTarget, + type PaneCloseResolution, captureTerminalCloseRequest, type TerminalSurfaceCloseOptions } from '../terminal-surface-close' diff --git a/src/main/window/terminal-tab-close-request-relay.ts b/src/main/window/terminal-tab-close-request-relay.ts index 575f11aa6852..ecd0c47e00b4 100644 --- a/src/main/window/terminal-tab-close-request-relay.ts +++ b/src/main/window/terminal-tab-close-request-relay.ts @@ -1,9 +1,9 @@ -import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { + TerminalSurfaceCloseIdentity, TerminalTabCloseRequest, TerminalTabCloseResponse } from '../../shared/terminal-tab-close' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx index 9444e2e6a035..3a6d5402b0f6 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-history-native-return.test.tsx @@ -60,6 +60,19 @@ vi.mock('@/lib/ai-vault-tab-title-requests', async (original) => ({ collectAgentPaneConversations: mocks.panes })) +const SWITCH = 'agent-session.structured.switch.v1:codex' +const paired = { + id: 'paired', + name: 'Paired', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: 'paired', + preferredEndpointId: 'endpoint', + endpoints: [{ id: 'endpoint', kind: 'websocket' as const, label: 'Host', endpoint: 'ws://host' }] +} + const row: AiVaultSession = { id: 'codex-history', agent: 'codex', @@ -151,12 +164,12 @@ beforeEach(async () => { api: { ...window.api, aiVault: {}, - runtime: { getStatus: async () => ({ capabilities: [HISTORY] }) } + runtime: { getStatus: async () => ({ capabilities: [HISTORY, SWITCH] }) } } }) useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) }) - setLocalRuntimeCapabilitiesForTests([HISTORY]) + setLocalRuntimeCapabilitiesForTests([HISTORY, SWITCH]) await refreshLocalRuntimeCapabilities() mocks.owner.mockReturnValue('local') mocks.probe.mockResolvedValue(true) @@ -170,7 +183,7 @@ function pairedHistoryStatus() { if (!response.ok) { throw new Error('Expected status fixture') } - return { status: { ...response.result, capabilities: [HISTORY] }, checkedAt: 1 } + return { status: { ...response.result, capabilities: [HISTORY, SWITCH] }, checkedAt: 1 } } it.each(['local', 'runtime:paired'] as const)( @@ -178,21 +191,31 @@ it.each(['local', 'runtime:paired'] as const)( async (host) => { mocks.owner.mockReturnValue(host) useAppStore.setState({ + runtimeEnvironments: [paired], runtimeStatusByEnvironmentId: new Map([['paired', pairedHistoryStatus()]]) }) - let answer = (_value: boolean) => {} - mocks.probe.mockImplementationOnce( - () => + setLocalRuntimeCapabilitiesForTests(null) + useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }) + let answer = (_value: { capabilities: string[] }) => {} + Object.assign(window.api.runtime, { + getStatus: () => new Promise((resolve) => { answer = resolve }) - ) + }) const session = { ...row, executionHostId: host } render() - const button = screen.getByRole('button', { name: 'Switch to native chat' }) - expect(button.hasAttribute('disabled')).toBe(true) - await act(async () => answer(true)) - await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false)) + expect(screen.queryByRole('button', { name: 'Switch to native chat' })).toBeNull() + await act(async () => { + answer({ capabilities: [HISTORY, SWITCH] }) + if (host !== 'local') { + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([['paired', pairedHistoryStatus()]]) + }) + } + }) + const button = await screen.findByRole('button', { name: 'Switch to native chat' }) + expect(button.hasAttribute('disabled')).toBe(false) await userEvent.setup().click(button) expect(mocks.startOwner).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: 'workspace' }), @@ -215,6 +238,7 @@ it.each(['local', 'runtime:paired'] as const)( it('routes a capable paired unowned row with a matching terminal through the same return', async () => { mocks.owner.mockReturnValue('runtime:paired') useAppStore.setState({ + runtimeEnvironments: [paired], runtimeStatusByEnvironmentId: new Map([['paired', pairedHistoryStatus()]]) }) const session = { @@ -265,6 +289,7 @@ it.each([ }, { executionHostId: 'local' as const, agent: 'omp' as const, filePath: '/session.jsonl' } ])('keeps ordinary restrictions for an unsupported owned row: %s', async (overrides) => { + setLocalRuntimeCapabilitiesForTests([HISTORY]) mocks.probe.mockResolvedValue(false) mocks.owner.mockReturnValue( overrides.executionHostId.startsWith('ssh:') ? null : overrides.executionHostId @@ -280,6 +305,7 @@ it.each([ it.each(['codex', 'claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'] as const)( 'keeps ordinary local History resume and owned fork usable for %s', async (agent) => { + setLocalRuntimeCapabilitiesForTests([HISTORY]) mocks.probe.mockResolvedValue(false) const session = { ...row, agent, structuredSession: undefined } const view = render() diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts index f9a35d6c45cd..43b0a7540029 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-parked-retirement.test-fixture.ts @@ -2,10 +2,8 @@ import { vi } from 'vitest' import { useAppStore } from '@/store' import type { CliChatResumeTestMocks } from './cli-chat-resume-mocks.test-fixture' import { LEAF_1, LEAF_2 } from '../terminal-pane/pty-connection-test-pane-fixtures' -import { - captureTerminalSurfaceIdentity, - type TerminalSurfaceCloseCapture -} from '../../../../shared/terminal-tab-close' +import { captureTerminalSurfaceIdentity } from '../../../../shared/terminal-tab-close' +import { TerminalClose } from '../../../../shared/rpc-contract/terminal-unary-params' import { retireTerminalPresentation } from '../terminal-pane/terminal-presentation-retirement' import { capturedPanesByTabId, @@ -34,33 +32,35 @@ export function installExitedPaneRetirement(mocks: CliChatResumeTestMocks) { ['pty-2', siblingWatcher] ]) }) - mocks.rpc.mockImplementation( - async (_target, method, params: { originalPresentation: TerminalSurfaceCloseCapture }) => { - if (method === 'terminal.resolvePane') { - const identity = captureTerminalSurfaceIdentity( - useAppStore.getState(), - 'wt-1', - 'shell', - LEAF_1 - ) - if (!identity) { - throw new Error('Missing original terminal') - } - return { - terminal: { - handle: 'handle-1', - ptyId: 'pty-1', - incarnationId: 'i1', - presentation: { identity: { ...identity, incarnationId: 'i1' }, holders: [] } - } - } + mocks.rpc.mockImplementation(async (_target, method, params: unknown) => { + if (method === 'terminal.resolvePane') { + const identity = captureTerminalSurfaceIdentity( + useAppStore.getState(), + 'wt-1', + 'shell', + LEAF_1 + ) + if (!identity) { + throw new Error('Missing original terminal') } - if (method !== 'terminal.close') { - throw new Error('Unexpected RPC') + return { + terminal: { + handle: 'handle-1', + ptyId: 'pty-1', + incarnationId: 'i1', + presentation: { identity: { ...identity, incarnationId: 'i1' }, holders: [] } + } } - retireTerminalPresentation(params.originalPresentation.identity) - return { close: { ptyKilled: true } } } - ) + if (method !== 'terminal.close') { + throw new Error('Unexpected RPC') + } + const close = TerminalClose.parse(params) + if (!close.originalPresentation) { + throw new Error('Missing original presentation') + } + retireTerminalPresentation(close.originalPresentation.identity) + return { close: { ptyKilled: true } } + }) return { closed, siblingWatcher } } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index c2e4ded20c00..9777705afdeb 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -57,7 +57,8 @@ vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ ...(await importOriginal>()), readLocalRuntimeCapabilitiesOrUnknown: () => [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + 'agent-session.structured.switch.v1:codex' ] })) vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ @@ -124,7 +125,7 @@ import { resetLocalStructuredSessionVersionForTests } from '@/runtime/local-stru import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync/tracking-lifecycle' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { buildHydratedTabState } from '@/store/slices/tabs-hydration' -import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { makeTerminalTab, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { useAppStore } from '@/store' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -228,6 +229,19 @@ beforeEach(async () => { } ) main = await drawTabsFromMain(useAppStore) + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [makeTerminalTab({ id: 'tab-1', worktreeId: 'wt-1', createdAt: 1 })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: 'leaf' }, + activeLeafId: 'leaf', + expandedLeafId: null, + ptyIdsByLeafId: { leaf: 'pty-1' } + } + } + }) }) function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult { @@ -430,30 +444,6 @@ describe('CLI identity resume through the existing launch owner', () => { await running mocks.source = SOURCE mocks.switchCapability.mockResolvedValue(false) - mocks.rpc.mockImplementation(async (_target, method) => - method === 'terminal.resolvePane' - ? { - terminal: { - handle: 'handle-1', - ptyId: 'pty-1', - incarnationId: 'i1', - presentation: { - identity: { - worktreeId: 'wt-1', - tabId: mocks.source?.tabId ?? 'tab-1', - leafId: mocks.source?.paneKey.split(':')[1] ?? 'leaf-1', - createdAt: 1, - generation: 0, - hasLeaf: true, - ptyId: 'pty-1', - incarnationId: 'i1' - }, - holders: [] - } - } - } - : { close: { ptyKilled: true } } - ) capability.resolve(true) await vi.waitFor(() => expect([...structuredLaunchStates()]).toHaveLength(0)) expect(mocks.call).not.toHaveBeenCalled() @@ -534,7 +524,7 @@ describe('CLI identity resume through the existing launch owner', () => { useAppStore.setState({ terminalLayoutsByTabId: { 'tab-1': { - root: null, + root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null, ptyIdsByLeafId: { [leafId]: 'pty-1' } diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 3517c0ac8068..3d6788499a2d 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -17,40 +17,8 @@ import { toastCliChatResumeFailure } from './cli-chat-resume-feedback' import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' -import type { RuntimeTerminalResolvePane } from '../../../../shared/runtime-types' -import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' - -/** Capture the existing host handle and incarnation before starting the destination. */ -async function captureSourceTerminal(source: CliChatResumeSource) { - const owner = source.ptyId ? getRemoteRuntimePtyOwner(source.ptyId) : null - if (isRemoteRuntimePtyId(source.ptyId) && !owner) { - return null - } - const target = owner?.target ?? { kind: 'local' as const } - try { - const { terminal } = await callRuntimeRpc<{ terminal: RuntimeTerminalResolvePane }>( - target, - 'terminal.resolvePane', - { - paneKey: source.paneKey, - worktreeId: source.workspaceId - } - ) - return terminal.incarnationId && - (owner ? terminal.handle === owner.terminal : terminal.ptyId === source.ptyId) - ? { - target, - handle: owner?.terminal ?? terminal.handle, - incarnationId: terminal.incarnationId, - presentation: terminal.presentation - } - : null - } catch { - return null - } -} +import { captureSourceTerminal } from './cli-chat-resume-terminal-capture' /** The execution host closes only the captured shell; refused or unknown closes leave it alone. */ async function retireSwitchedTerminalPane( diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts new file mode 100644 index 000000000000..797537a565dd --- /dev/null +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts @@ -0,0 +1,89 @@ +import { useAppStore } from '@/store' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import type { CliChatResumeSource } from './cli-chat-resume-source' +import type { RuntimeTerminalResolvePane } from '../../../../shared/runtime-types' +import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' + +import { parseHostAuthorityKey } from '../../../../shared/host-authority' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { + captureTerminalSurfaceIdentity, + isRetainedTerminalSurfaceIdentity +} from '../../../../shared/terminal-tab-close' + +/** Capture the existing host handle and incarnation before starting the destination. */ +export async function captureSourceTerminal(source: CliChatResumeSource) { + const owner = source.ptyId ? getRemoteRuntimePtyOwner(source.ptyId) : null + if (isRemoteRuntimePtyId(source.ptyId) && !owner) { + return null + } + if (!owner && source.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return null + } + const leafId = source.paneKey.slice(source.tabId.length + 1) + if (source.paneKey !== `${source.tabId}:${leafId}`) { + return null + } + const renderer = captureTerminalSurfaceIdentity( + useAppStore.getState(), + source.workspaceId, + source.tabId, + leafId + ) + const sourceTarget = structuredAgentSessionTargetForHost(source.executionHostId) + if ( + owner && + (sourceTarget?.kind !== 'environment' || + sourceTarget.environmentId !== owner.target.environmentId) + ) { + return null + } + const target = owner?.target ?? { kind: 'local' as const } + try { + const { terminal } = await callRuntimeRpc<{ terminal: RuntimeTerminalResolvePane }>( + target, + 'terminal.resolvePane', + { + paneKey: source.paneKey, + worktreeId: source.workspaceId + } + ) + const original = terminal.presentation?.identity + const authority = parseHostAuthorityKey(source.executionHostId) + if ( + !renderer || + !renderer.hasLeaf || + !original || + !terminal.incarnationId || + (terminal.executionHostId != null && terminal.executionHostId !== authority?.at) || + original.ptyId !== terminal.ptyId || + original.incarnationId !== terminal.incarnationId + ) { + return null + } + const matches = owner + ? terminal.handle === owner.terminal && + renderer.ptyId === source.ptyId && + renderer.worktreeId === original.worktreeId && + renderer.tabId === original.tabId && + renderer.leafId === original.leafId && + renderer.createdAt === original.createdAt && + renderer.generation === original.generation && + original.hasLeaf && + (renderer.incarnationId === null || renderer.incarnationId === original.incarnationId) + : (source.ptyId === undefined || terminal.ptyId === source.ptyId) && + isRetainedTerminalSurfaceIdentity(original, renderer) + return matches + ? { + target, + handle: owner?.terminal ?? terminal.handle, + incarnationId: terminal.incarnationId, + presentation: terminal.presentation + } + : null + } catch { + return null + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts index f064a499bde8..8b2b183ad184 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-registration.ts @@ -26,18 +26,35 @@ export function registerTerminalPaneMount( ) { throw new Error('terminal_pane_owner_unavailable') } - removeTerminalPresentation(original, () => { + removeTerminalPresentation(original, (failures) => { if (pane && manager) { - if (!manager.retirePanePreservingPty(pane.id, true)) { - throw new Error('terminal_pane_close_failed') - } - if (manager.getPanes().length === 0) { - onEmpty(manager) - if (deps.managerRef.current === manager) { - deps.managerRef.current = null + const structuralFailures: unknown[] = [] + try { + if (!manager.retirePanePreservingPty(pane.id, true, failures)) { + throw new Error('terminal_pane_close_failed') + } + } catch (error) { + structuralFailures.push(error) + } finally { + if (manager.getPanes().length === 0) { + try { + onEmpty(manager) + } catch (error) { + structuralFailures.push(error) + } finally { + if (deps.managerRef.current === manager) { + deps.managerRef.current = null + } + ownedManager = null + unregisterRuntimeTab() + } } - ownedManager = null - unregisterRuntimeTab() + } + if (structuralFailures.length === 1) { + throw structuralFailures[0] + } + if (structuralFailures.length > 1) { + throw new AggregateError(structuralFailures, 'terminal_tab_close_failed') } } }) diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts index d0b23c53352e..74a15a5d52c6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts @@ -35,8 +35,8 @@ function pane(id: number, leafId: string): ManagedPaneInternal { unicode11Addon: addon(), webLinksAddon: addon(), fitAddon: addon(), - webglAddon: null, - imageAddon: null, + webglAddon: addon(), + imageAddon: addon(), ligaturesAddon: null, fitResizeObserver: null, pendingObservedFitRafId: null, @@ -129,10 +129,11 @@ export function mountedOwner( manager = { getPanes: () => [...panes.values()], getActivePane: () => panes.get(2) ?? panes.get(1) ?? null, - retirePanePreservingPty: (id: number, permanent: boolean) => + retirePanePreservingPty: (id: number, permanent: boolean, failures?: unknown[]) => retireManagedPanePreservingPty( { paneId: id, + failures, activePaneId: 1, panes, root: document.createElement('div'), diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts index f12fd3f5dea7..2559a7ceef3d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test.ts @@ -3,17 +3,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { makeTerminalTab } from '@/store/slices/worktrees-slice-test-fixtures' import { captureTerminalSurfaceIdentity } from '../../../../shared/terminal-tab-close' -import { retireTerminalPresentation } from './terminal-presentation-retirement' +import { + removeTerminalPresentation, + retireTerminalPresentation +} from './terminal-presentation-retirement' import { capturedPanesByTabId, parkedWatchersByTabId } from './terminal-parked-watcher-registry' import { LEAF, SIBLING, mountedOwner } from './terminal-presentation-retirement.test-fixture' -import { - createHarness, - WORKTREE_ID, - TAB_ID, - LEAF_ID, - PTY_ID, - INCARNATION_ID -} from '../../../../main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) @@ -148,52 +143,6 @@ describe('direct permanent presentation owner', () => { } ) - it('completes host stop, direct mounted disposal and explicit durable intent together', async () => { - const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) - const session = harness.getSession() - useAppStore.setState({ - tabsByWorktree: session.tabsByWorktree, - terminalLayoutsByTabId: session.terminalLayoutsByTabId - }) - useAppStore - .getState() - .createUnifiedTab(WORKTREE_ID, 'terminal', { id: TAB_ID, entityId: TAB_ID }) - const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) - if (!source.presentation) { - throw new Error('Expected original presentation') - } - const owner = mountedOwner(false, { - tabId: TAB_ID, - worktreeId: WORKTREE_ID, - leafId: LEAF_ID, - ptyId: PTY_ID - }) - harness.setVerifiedStopResult(true) - harness.setStopAndWaitAction(async () => { - await harness.runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) - expect(owner.source.terminal.dispose).not.toHaveBeenCalled() - expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) - }) - harness.setCloseTerminalTabAction(() => - retireTerminalPresentation(source.presentation!.identity) - ) - try { - await expect( - harness.runtime.closeTerminal(source.handle, source.incarnationId, source.presentation) - ).resolves.toMatchObject({ ptyKilled: true }) - expect(owner.source.terminal.dispose).toHaveBeenCalledOnce() - expect(owner.transports.size).toBe(0) - expect(owner.bindings.size).toBe(0) - expect(owner.sourceTransport.destroy).not.toHaveBeenCalled() - expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) - expect(harness.getSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeDefined() - expect(harness.flushOrThrow).toHaveBeenCalled() - expect(harness.kill).not.toHaveBeenCalled() - } finally { - owner.unregister() - } - }) - it('refuses a replacement or a newly pinned final tab before disposal', () => { const identity = prepare(false) const owner = mountedOwner(false) @@ -240,4 +189,151 @@ describe('direct permanent presentation owner', () => { owner.unregister() } }) + it('reports nested actual addon/terminal failures after closure and layout, while ordinary callers remain best effort', () => { + const identity = prepare(false) + const owner = mountedOwner(false) + const image = owner.source.imageAddon + const webgl = owner.source.webglAddon + if (!image || !webgl) { + throw new Error('Expected actual addon owners') + } + vi.spyOn(image, 'dispose').mockImplementation(() => { + throw undefined + }) + vi.spyOn(webgl, 'dispose').mockImplementation(() => { + throw false + }) + vi.spyOn(owner.source.terminal, 'dispose').mockImplementation(() => { + throw new Error('terminal failed') + }) + try { + let failure: unknown + try { + retireTerminalPresentation(identity) + } catch (error) { + failure = error + } + expect(failure).toBeInstanceOf(AggregateError) + if (!(failure instanceof AggregateError)) { + throw new Error('Expected failure') + } + expect(failure.errors).toEqual([undefined, false, new Error('terminal failed')]) + expect(owner.source.searchAddon.dispose).toHaveBeenCalledOnce() + expect(owner.binding.dispose).toHaveBeenCalledOnce() + expect(owner.sourceTransport.detach).toHaveBeenCalledOnce() + expect(owner.manager.destroy).toHaveBeenCalledOnce() + expect(useAppStore.getState().terminalLayoutsByTabId.terminal).toBeUndefined() + expect(hasRegisteredRuntimeTerminalTab('terminal', 'wt-1')).toBe(false) + } finally { + owner.unregister() + } + + prepare(false) + const ordinary = mountedOwner(false) + vi.spyOn(ordinary.source.searchAddon, 'dispose').mockImplementation(() => { + throw new Error('advisory') + }) + try { + expect(() => ordinary.manager.retirePanePreservingPty(1, true)).not.toThrow() + expect(ordinary.source.terminal.dispose).toHaveBeenCalledOnce() + } finally { + ordinary.unregister() + } + }) + + it.each(['DOM', 'onPaneClosed'] as const)( + 'retains caught and genuine %s and final-owner failures without claiming structural completion', + (stage) => { + const identity = prepare(false) + const owner = mountedOwner(false) + const caught = new Error('addon failed') + const structural = new Error(stage) + const final = new Error('onEmpty failed') + vi.spyOn(owner.source.searchAddon, 'dispose').mockImplementation(() => { + throw caught + }) + if (stage === 'DOM') { + document.body.append(owner.source.container) + vi.spyOn(owner.source.container, 'remove').mockImplementation(() => { + throw structural + }) + } else { + owner.link.dispose.mockImplementation(() => { + throw structural + }) + } + owner.empty.mockImplementation(() => { + throw final + }) + try { + let failure: unknown + try { + retireTerminalPresentation(identity) + } catch (error) { + failure = error + } + if (!(failure instanceof AggregateError)) { + throw new Error('Expected aggregate') + } + expect(failure.errors[0]).toBe(caught) + expect(failure.errors[1]).toBeInstanceOf(AggregateError) + expect(failure.errors[1].errors).toEqual([structural, final]) + expect(owner.empty).toHaveBeenCalledOnce() + expect(owner.panes.size).toBe(0) + expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1) + expect(hasRegisteredRuntimeTerminalTab('terminal', 'wt-1')).toBe(false) + } finally { + owner.unregister() + document.body.replaceChildren() + } + } + ) + + it('preserves an ordinary previously unswallowed owner throw', () => { + prepare(false) + const owner = mountedOwner(false) + owner.source.paneDragCleanup = () => { + throw new Error('ordinary owner failed') + } + try { + expect(() => owner.manager.retirePanePreservingPty(1, true)).toThrow('ordinary owner failed') + expect(owner.panes.size).toBe(1) + } finally { + owner.unregister() + } + }) + + it('retains caught failures when layout throws, and rejects them even after an already-absent leaf', () => { + const identity = prepare(true) + const caught = new Error('caught disposal') + const layoutFailure = new Error('layout failed') + const originalSet = useAppStore.getState().setTabLayout + useAppStore.setState({ + setTabLayout: () => { + throw layoutFailure + } + }) + try { + let failure: unknown + try { + removeTerminalPresentation(identity, (failures) => { + failures.push(caught) + }) + } catch (error) { + failure = error + } + if (!(failure instanceof AggregateError)) { + throw new Error('Expected aggregate') + } + expect(failure.errors).toEqual([caught, layoutFailure]) + } finally { + useAppStore.setState({ setTabLayout: originalSet }) + } + useAppStore.setState({ tabsByWorktree: {}, terminalLayoutsByTabId: {} }) + expect(() => + removeTerminalPresentation(identity, (failures) => { + failures.push(caught) + }) + ).toThrow(AggregateError) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts index 9702f0ee9c2d..8c024ae1aa9b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.ts @@ -13,7 +13,7 @@ import { collapseParkedTerminalLeaf } from './terminal-parked-pty-watcher' /** Admission and disposal share one synchronous owner action. */ export function removeTerminalPresentation( original: TerminalSurfaceCloseIdentity, - dispose: () => void + dispose: (failures: unknown[]) => void ): void { const state = useAppStore.getState() const current = captureTerminalSurfaceIdentity( @@ -46,19 +46,29 @@ export function removeTerminalPresentation( for (const paneId of paneIds) { state.clearRuntimePaneTitle(original.tabId, paneId) } - dispose() - if (!current?.hasLeaf) { - return + const failures: unknown[] = [] + try { + dispose(failures) + if (current?.hasLeaf) { + if (split) { + useAppStore.getState().setTabLayout(original.tabId, split.sourceLayout) + } else { + useAppStore.getState().closeTab(original.tabId, { + reason: 'user', + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true, + remoteCloseOwnedByHost: true + }) + } + } + } catch (error) { + if (failures.length === 0) { + throw error + } + failures.push(error) } - if (split) { - useAppStore.getState().setTabLayout(original.tabId, split.sourceLayout) - } else { - useAppStore.getState().closeTab(original.tabId, { - reason: 'user', - captureRecentlyClosed: false, - localPtyTeardownOwnedExternally: true, - remoteCloseOwnedByHost: true - }) + if (failures.length > 0) { + throw new AggregateError(failures, 'terminal_tab_close_failed') } } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index d15236c0d9c2..39c317d566e8 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -182,13 +182,28 @@ export function registerMobileAndTerminalCloseIpcBridge( if (originalSurface.tabId !== tabId || !localPtyTeardownOwnedExternally) { throw new Error('terminal_pane_owner_changed') } - retireTerminalPresentation(originalSurface) - const state = useAppStore.getState() - await persistWorkspaceSessionByHost( - window.api.session, - buildWorkspaceSessionPayload(state), - state - ) + const failures: unknown[] = [] + try { + retireTerminalPresentation(originalSurface) + } catch (error) { + failures.push(error) + } + try { + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + } catch (error) { + failures.push(error) + } + if (failures.length === 1) { + throw failures[0] + } + if (failures.length > 1) { + throw new AggregateError(failures, 'terminal_tab_close_failed') + } respond() })().catch((error: unknown) => respond(error instanceof Error ? error.message : 'terminal_tab_close_failed') diff --git a/src/renderer/src/lib/in-place-switch-support.test.tsx b/src/renderer/src/lib/in-place-switch-support.test.tsx index 40385c61b136..81f235ad18f7 100644 --- a/src/renderer/src/lib/in-place-switch-support.test.tsx +++ b/src/renderer/src/lib/in-place-switch-support.test.tsx @@ -1,42 +1,123 @@ // @vitest-environment happy-dom -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { refreshLocalRuntimeCapabilities, setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' -import { useInPlaceSwitchCapability } from './in-place-switch-support' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' +import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status' +import { inPlaceSwitchCapabilityNow, useInPlaceSwitchCapability } from './in-place-switch-support' -const { probe } = vi.hoisted(() => ({ probe: vi.fn<() => Promise>() })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ supportsInPlaceSwitch: probe })) - -function Switch() { - const supported = useInPlaceSwitchCapability('local', 'codex') - return +const SWITCH = 'agent-session.structured.switch.v1:codex' +const environment = { + id: 'paired', + name: 'Host', + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: 'paired', + preferredEndpointId: 'endpoint', + endpoints: [{ id: 'endpoint', kind: 'websocket' as const, label: 'Host', endpoint: 'ws://host' }] } -afterEach(cleanup) - -it('keeps the switch disabled while loading and re-reads capability changes without a pane update', async () => { +const getStatus = vi.fn() +function Switch({ host = 'local' }: { host?: string }) { + const supported = useInPlaceSwitchCapability(host, 'codex') + return +} +function snapshot( + sequence: number, + patch: Partial = {} +): RuntimeHostStatusSnapshot { + const response = createCompatibleRuntimeStatusResponse('paired') + if (!response.ok) { + throw new Error('Expected status') + } + return { + environmentId: 'paired', + pairingRevision: 1, + sequence, + checkedAt: sequence, + transport: 'ready', + verification: 'verified', + status: { ...response.result, capabilities: [SWITCH] }, + ...patch + } +} +beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) setLocalRuntimeCapabilitiesForTests(null) - let answer = (_supported: boolean): void => {} - probe.mockImplementationOnce( + getStatus.mockReset() + Object.assign(window, { api: { ...window.api, runtime: { getStatus } } }) +}) +afterEach(cleanup) + +it('activates once after failed boot, observes local publication, and never probes from reads or notifications', async () => { + getStatus.mockRejectedValueOnce(new Error('boot unavailable')) + await refreshLocalRuntimeCapabilities() + expect(inPlaceSwitchCapabilityNow('local', 'codex')).toBeUndefined() + let answer = (_value: { capabilities: string[] }) => {} + getStatus.mockImplementationOnce( () => new Promise((resolve) => { answer = resolve }) ) render() - expect(screen.getByRole('button', { name: 'Switch to CLI' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true) + expect(getStatus).toHaveBeenCalledTimes(2) await act(async () => { - answer(true) + answer({ capabilities: [SWITCH] }) }) - await waitFor(() => expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false)) - probe.mockResolvedValue(false) - Object.assign(window, { api: { runtime: { getStatus: async () => ({ capabilities: [] }) } } }) + expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false) + for (let index = 0; index < 4; index++) { + expect(inPlaceSwitchCapabilityNow('local', 'codex')).toBe(true) + act(() => useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })) + } + expect(getStatus).toHaveBeenCalledTimes(2) + getStatus.mockResolvedValueOnce({ capabilities: [] }) await act(async () => { await refreshLocalRuntimeCapabilities() }) - await waitFor(() => expect(screen.getByRole('button').hasAttribute('disabled')).toBe(true)) + expect(screen.getByRole('button').textContent).toBe('false') +}) + +it('derives only the addressed catalog owner across publication, lost contact, revocation, replacement and legacy verification', async () => { + setLocalRuntimeCapabilitiesForTests([]) + useAppStore.getState().setRuntimeEnvironments([environment]) + render() + expect(screen.getByRole('button').textContent).toBe('undefined') + act(() => useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot(1))) + expect(screen.getByRole('button').hasAttribute('disabled')).toBe(false) + const learned = snapshot(2, { verification: 'unavailable', transport: 'disconnected' }) + act(() => useAppStore.getState().applyRuntimeHostStatusSnapshot(learned)) + expect(inPlaceSwitchCapabilityNow('runtime:paired', 'codex')).toBe(true) + expect(inPlaceSwitchCapabilityNow('runtime:other', 'codex')).toBeUndefined() + act(() => + useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot(3, { verification: 'blocked' })) + ) + expect(screen.getByRole('button').textContent).toBe('undefined') + act(() => useAppStore.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }])) + act(() => useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot(10))) + expect(inPlaceSwitchCapabilityNow('runtime:paired', 'codex')).toBeUndefined() + const legacy = snapshot(11, { pairingRevision: 2 }) + if (!legacy.status) { + throw new Error('Expected status') + } + delete legacy.status.capabilities + act(() => useAppStore.getState().applyRuntimeHostStatusSnapshot(legacy)) + expect(screen.getByRole('button').textContent).toBe('false') + act(() => + useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot(12, { pairingRevision: 2 })) + ) + expect(screen.getByRole('button').textContent).toBe('true') + act(() => + useAppStore + .getState() + .applyRuntimeHostStatusSnapshot(snapshot(13, { pairingRevision: 2, retired: true })) + ) + expect(screen.getByRole('button').textContent).toBe('undefined') + expect(getStatus).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/in-place-switch-support.ts b/src/renderer/src/lib/in-place-switch-support.ts index 7f30f8449543..ffea80b7b74b 100644 --- a/src/renderer/src/lib/in-place-switch-support.ts +++ b/src/renderer/src/lib/in-place-switch-support.ts @@ -1,42 +1,51 @@ import { useSyncExternalStore } from 'react' import { useAppStore } from '@/store' -import { subscribeLocalRuntimeCapabilityObservation } from '@/runtime/local-runtime-capabilities' -import { supportsInPlaceSwitch } from '@/runtime/structured-agent-session-client' +import { + ensureLocalRuntimeCapabilities, + readLocalRuntimeCapabilitiesOrUnknown, + subscribeLocalRuntimeCapabilityObservation +} from '@/runtime/local-runtime-capabilities' +import { + isRuntimeHostContactRevoked, + lastVerifiedRuntimeStatus +} from '../../../shared/runtime-host-status' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { onRuntimeEnvironmentRevisionsChanged, onRuntimeEnvironmentsRetired } from '@/runtime/runtime-environment-revision' -import { inPlaceSwitchEnabled } from '../../../shared/structured-agent-cli-conversation-adoption' +import { + inPlaceSwitchCapability, + inPlaceSwitchEnabled +} from '../../../shared/structured-agent-cli-conversation-adoption' import type { AiVaultAgent } from '../../../shared/ai-vault-types' -// The answer per host and agent; a reconnect or retirement asks again. -const known = new Map() -const asking = new Map>() const listeners = new Set<() => void>() let revision = 0 const changed = () => { revision += 1 listeners.forEach((listener) => listener()) } -const forget = () => { - known.clear() - asking.clear() - changed() -} -onRuntimeEnvironmentRevisionsChanged(forget) -onRuntimeEnvironmentsRetired(forget) -subscribeLocalRuntimeCapabilityObservation(forget) +onRuntimeEnvironmentRevisionsChanged(changed) +onRuntimeEnvironmentsRetired(changed) +subscribeLocalRuntimeCapabilityObservation(changed) let stopStatusSubscription: (() => void) | undefined const subscribe = (listener: () => void) => { - if (listeners.size === 0) { + const activate = listeners.size === 0 + if (activate) { stopStatusSubscription = useAppStore.subscribe((state, previous) => { - if (state.runtimeStatusByEnvironmentId !== previous.runtimeStatusByEnvironmentId) { - forget() + if ( + state.runtimeStatusByEnvironmentId !== previous.runtimeStatusByEnvironmentId || + state.runtimeEnvironments !== previous.runtimeEnvironments + ) { + changed() } }) } listeners.add(listener) + if (activate) { + void ensureLocalRuntimeCapabilities() + } return () => { listeners.delete(listener) if (listeners.size === 0) { @@ -54,7 +63,7 @@ export function useInPlaceSwitchRevision(): number { ) } -/** The cached answer, asking in the background when there is none yet. */ +/** The addressed runtime owner's learned answer; reading never starts a probe. */ export function inPlaceSwitchCapabilityNow( executionHostId: string | null | undefined, agent: string @@ -62,22 +71,20 @@ export function inPlaceSwitchCapabilityNow( if (!inPlaceSwitchEnabled(agent)) { return false } - const key = `${executionHostId ?? ''}\0${agent}` - const answer = known.get(key) const target = structuredAgentSessionTargetForHost(executionHostId) - if (answer === undefined && target && !asking.has(key)) { - const attempt = supportsInPlaceSwitch(target, agent) - asking.set(key, attempt) - void attempt.then((supported) => { - if (asking.get(key) !== attempt) { - return - } - asking.delete(key) - known.set(key, supported) - changed() - }) + if (!target) { + return undefined + } + if (target.kind === 'local') { + return readLocalRuntimeCapabilitiesOrUnknown()?.includes(inPlaceSwitchCapability(agent)) + } + const state = useAppStore.getState() + if (!state.runtimeEnvironments.some((environment) => environment.id === target.environmentId)) { + return undefined } - return answer + const entry = state.runtimeStatusByEnvironmentId.get(target.environmentId) + const status = isRuntimeHostContactRevoked(entry) ? null : lastVerifiedRuntimeStatus(entry) + return status ? (status.capabilities ?? []).includes(inPlaceSwitchCapability(agent)) : undefined } export function inPlaceSwitchSupportedNow(host: string | null | undefined, agent: string): boolean { diff --git a/src/renderer/src/lib/pane-manager/pane-inline-images.ts b/src/renderer/src/lib/pane-manager/pane-inline-images.ts index b0faf27631b2..cb16b37e0d30 100644 --- a/src/renderer/src/lib/pane-manager/pane-inline-images.ts +++ b/src/renderer/src/lib/pane-manager/pane-inline-images.ts @@ -87,7 +87,7 @@ export function attachInlineImages(pane: ManagedPaneInternal): void { } } -export function detachInlineImages(pane: ManagedPaneInternal): void { +export function detachInlineImages(pane: ManagedPaneInternal, failures?: unknown[]): void { panesAwaitingImageAddon.delete(pane) pane.imageAttachmentDeferred = false terminalsRenderingInlineImages.delete(pane.terminal) @@ -96,8 +96,8 @@ export function detachInlineImages(pane: ManagedPaneInternal): void { if (pane.imageAddon) { try { pane.imageAddon.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } pane.imageAddon = null } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index a301ab45ca14..9904fbf048c4 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -184,7 +184,8 @@ export function setLigaturesEnabled(pane: ManagedPaneInternal, enabled: boolean) export function disposePane( pane: ManagedPaneInternal, - panes: Map + panes: Map, + failures?: unknown[] ): void { cancelPendingTerminalViewportPresents(pane.terminal) if (pane.pendingInitialFitRafId != null) { @@ -220,8 +221,8 @@ export function disposePane( // Deregister the RTL shaping joiner: terminal.dispose() below does not. try { pane.arabicShapingJoinerCleanup?.() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } pane.arabicShapingJoinerCleanup = null if (pane.compositionHandler) { @@ -231,60 +232,60 @@ export function disposePane( } try { clearPendingSplitScrollRestore(pane) - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { // Why: fit retries own xterm markers and frame callbacks independently of // split restoration; both must be released before terminal disposal. cancelDeferredScrollRestore(pane.terminal) - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { pane.ligaturesAddon?.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } // Detach removes the pane from the deferred-attach set and disposes the addon // (canvas layers + parser handlers) before the terminal surface goes away. - detachInlineImages(pane) - disposeWebgl(pane) + detachInlineImages(pane, failures) + disposeWebgl(pane, { failures }) try { pane.searchAddon.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { pane.serializeAddon.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { pane.unicode11Addon.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { pane.webLinksAddon.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { pane.fitAddon.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { // Drop renderer selection state before a recovery remount replaces the surface. pane.terminal.clearSelection() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } try { pane.terminal.dispose() - } catch { - /* ignore */ + } catch (error) { + failures?.push(error) } panes.delete(pane.id) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts index 52878604f87e..ded990b41990 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts @@ -97,11 +97,13 @@ export function detachPaneForExternalMoveOnManager(host: PaneManagerHost, paneId export function retirePanePreservingPtyOnManager( host: PaneManagerHost, paneId: number, - permanent = false + permanent = false, + failures?: unknown[] ): boolean { return retireManagedPanePreservingPty( { paneId, + failures, activePaneId: host.getActivePaneId(), panes: host.panes, root: host.root, diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index a303a7674b22..32ed7fc60719 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -155,8 +155,8 @@ export class PaneManager { return detachPaneForExternalMoveOnManager(this.host, paneId) } - retirePanePreservingPty(paneId: number, permanent = false): boolean { - return retirePanePreservingPtyOnManager(this.host, paneId, permanent) + retirePanePreservingPty(paneId: number, permanent = false, failures?: unknown[]): boolean { + return retirePanePreservingPtyOnManager(this.host, paneId, permanent, failures) } getPanes(limit = Number.POSITIVE_INFINITY): ManagedPane[] { diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 32731cb76a91..bf7d94e27641 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -172,6 +172,7 @@ function openSplitPane( } type CloseManagedPaneArgs = { + failures?: unknown[] paneId: number activePaneId: number | null panes: Map @@ -246,7 +247,7 @@ export function retireManagedPanePreservingPty( function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void { const paneContainer = pane.container const parent = paneContainer.parentElement - disposePane(pane, args.panes) + disposePane(pane, args.panes, args.failures) if (!parent) { return } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 23cf486cb6d4..93f3a6f268f4 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -127,7 +127,7 @@ export function isPaneWebglContextLost(pane: ManagedPaneInternal): boolean { export function disposeWebgl( pane: ManagedPaneInternal, - options?: { refreshDimensions?: boolean } + options?: { refreshDimensions?: boolean; failures?: unknown[] } ): void { cancelPendingWebglRefresh(pane) panesAwaitingWebglAddon.delete(pane) @@ -137,8 +137,8 @@ export function disposeWebgl( releaseXtermWebglContext(pane.webglAddon) try { pane.webglAddon.dispose() - } catch { - /* ignore */ + } catch (error) { + options?.failures?.push(error) } pane.webglAddon = null if (options?.refreshDimensions) { diff --git a/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts new file mode 100644 index 000000000000..522e0509f9fb --- /dev/null +++ b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { captureSourceTerminal } from '@/components/tab-bar/cli-chat-resume-terminal-capture' +import { toRemoteRuntimePtyId } from '../../src/shared/remote-runtime-pty-id' +import { + createHarness, + WORKTREE_ID, + TAB_ID, + LEAF_ID, + PTY_ID, + INCARNATION_ID +} from '../../src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' + +const { rpc } = vi.hoisted(() => ({ rpc: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: rpc })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + rpc.mockReset() +}) +function source( + ptyId: string | undefined = PTY_ID, + executionHostId: 'local' | 'runtime:paired' = 'local' +) { + return { + workspaceId: WORKTREE_ID, + tabId: TAB_ID, + paneKey: `${TAB_ID}:${LEAF_ID}`, + agent: 'codex' as const, + providerSessionId: 'conversation', + executionHostId, + ptyId + } +} +function seed(harness: ReturnType) { + const session = harness.getSession() + useAppStore.setState({ + tabsByWorktree: session.tabsByWorktree, + terminalLayoutsByTabId: session.terminalLayoutsByTabId + }) +} + +it('captures the authenticated original after earned shell exit and cleared renderer binding, then closes without another stop', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const retained = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + const settle = harness.runtime.intentionalPtyStops.mark(PTY_ID, 'reversible', INCARNATION_ID) + await harness.runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + settle(true) + harness.runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Ended', + activeLeafId: LEAF_ID, + layout: { type: 'leaf', leafId: LEAF_ID } + } + ], + leaves: [ + { tabId: TAB_ID, worktreeId: WORKTREE_ID, leafId: LEAF_ID, paneRuntimeId: 7, ptyId: null } + ] + }) + harness.editSession((state) => ({ + ...state, + terminalLayoutsByTabId: { + ...state.terminalLayoutsByTabId, + [TAB_ID]: { ...state.terminalLayoutsByTabId[TAB_ID], ptyIdsByLeafId: {} } + } + })) + // SAFETY: exercise retained earned-exit authority after the actual archive owner releases its record. + const archive = harness.runtime as unknown as { dropDisconnectedPtyRecord(id: string): void } + archive.dropDisconnectedPtyRecord(PTY_ID) + seed(harness) + rpc.mockImplementation(async () => ({ + terminal: harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + })) + const captured = await captureSourceTerminal(source(undefined)) + expect(captured?.handle).toBe(retained.handle) + if (!captured?.presentation) { + throw new Error('Expected authenticated original') + } + harness.setCloseTerminalTabAction(() => {}) + await expect( + harness.runtime.closeTerminal(captured.handle, captured.incarnationId, captured.presentation) + ).resolves.toMatchObject({ ptyKilled: true }) + expect(harness.stopAndWait).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) +}) + +it.each(['row', 'incarnation', 'pty', 'workspace', 'leaf', 'host'] as const)( + 'refuses changed %s facts across resolve without recapturing', + async (change) => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + seed(harness) + rpc.mockImplementation(async () => { + if (change === 'row') { + harness.editSession((state) => ({ + ...state, + tabsByWorktree: { + ...state.tabsByWorktree, + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((row) => ({ + ...row, + generation: (row.generation ?? 0) + 1 + })) + } + })) + } + if (change === 'incarnation') { + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'successor' + }) + } + const terminal = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (change === 'host') { + terminal.executionHostId = 'ssh:successor' + } + if (change === 'pty') { + terminal.ptyId = 'successor' + } + if (change === 'workspace' && terminal.presentation) { + terminal.presentation.identity.worktreeId = 'other' + } + if (change === 'leaf' && terminal.presentation) { + terminal.presentation.identity.leafId = 'other' + } + return { terminal } + }) + useAppStore.setState({ + terminalPtyIncarnationsByPaneKey: { [`${TAB_ID}:${LEAF_ID}`]: INCARNATION_ID } + }) + expect(await captureSourceTerminal(source())).toBeNull() + } +) + +it('keeps remote proxy and host PTY namespaces distinct, and refuses absent or changed remote owners', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + seed(harness) + const terminal = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + const proxy = toRemoteRuntimePtyId(terminal.handle, 'paired') + useAppStore.setState({ + terminalLayoutsByTabId: { + ...useAppStore.getState().terminalLayoutsByTabId, + [TAB_ID]: { + ...useAppStore.getState().terminalLayoutsByTabId[TAB_ID], + ptyIdsByLeafId: { [LEAF_ID]: proxy } + } + } + }) + rpc.mockResolvedValue({ terminal }) + expect(await captureSourceTerminal(source(proxy, 'runtime:paired'))).toMatchObject({ + target: { kind: 'environment', environmentId: 'paired' }, + handle: terminal.handle + }) + expect( + await captureSourceTerminal(source(toRemoteRuntimePtyId(terminal.handle), 'runtime:paired')) + ).toBeNull() + expect(await captureSourceTerminal(source(undefined, 'runtime:paired'))).toBeNull() + expect(await captureSourceTerminal(source(proxy, 'local'))).toBeNull() + terminal.handle = 'different' + expect(await captureSourceTerminal(source(proxy, 'runtime:paired'))).toBeNull() + expect(rpc).toHaveBeenCalledTimes(2) +}) diff --git a/tests/e2e/terminal-presentation-retirement.unit.test.ts b/tests/e2e/terminal-presentation-retirement.unit.test.ts new file mode 100644 index 000000000000..7ddd1dda8b73 --- /dev/null +++ b/tests/e2e/terminal-presentation-retirement.unit.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { retireTerminalPresentation } from '@/components/terminal-pane/terminal-presentation-retirement' +import type { TerminalTabCloseRequest } from '../../src/shared/terminal-tab-close' +import type { WorkspaceSessionState } from '../../src/shared/workspace-session-state-types' +import { registerMobileAndTerminalCloseIpcBridge } from '@/hooks/ipc-events/mobile-terminal-close-ipc-bridge' +import { mountedOwner } from '@/components/terminal-pane/terminal-presentation-retirement.test-fixture' +import { + createHarness, + WORKTREE_ID, + TAB_ID, + LEAF_ID, + PTY_ID, + INCARNATION_ID +} from '../../src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures' + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +beforeEach(() => { + localStorage.clear() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +it('completes host stop, direct mounted disposal and explicit durable intent together', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const session = harness.getSession() + useAppStore.setState({ + tabsByWorktree: session.tabsByWorktree, + terminalLayoutsByTabId: session.terminalLayoutsByTabId + }) + useAppStore.getState().createUnifiedTab(WORKTREE_ID, 'terminal', { id: TAB_ID, entityId: TAB_ID }) + const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (!source.presentation || !source.incarnationId) { + throw new Error('Expected original presentation') + } + const { presentation, incarnationId } = source + const owner = mountedOwner(false, { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + ptyId: PTY_ID + }) + harness.setVerifiedStopResult(true) + harness.setStopAndWaitAction(async () => { + await harness.runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + expect(owner.source.terminal.dispose).not.toHaveBeenCalled() + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + harness.setCloseTerminalTabAction(() => retireTerminalPresentation(presentation.identity)) + try { + await expect( + harness.runtime.closeTerminal(source.handle, incarnationId, presentation) + ).resolves.toMatchObject({ ptyKilled: true }) + expect(owner.source.terminal.dispose).toHaveBeenCalledOnce() + expect(owner.transports.size).toBe(0) + expect(owner.bindings.size).toBe(0) + expect(owner.sourceTransport.destroy).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(harness.getSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeDefined() + expect(harness.flushOrThrow).toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalled() + } finally { + owner.unregister() + } +}) + +it.each([false, true])( + 'persists the actual renderer effects after caught disposal failure before one failed IPC receipt (write fails=%s)', + async (writeFails) => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const session = harness.getSession() + useAppStore.setState({ + tabsByWorktree: session.tabsByWorktree, + terminalLayoutsByTabId: session.terminalLayoutsByTabId + }) + const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + if (!source.presentation) { + throw new Error('Expected original') + } + const owner = mountedOwner(false, { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + ptyId: PTY_ID + }) + vi.spyOn(owner.source.terminal, 'dispose').mockImplementation(() => { + throw new Error('terminal dispose failed') + }) + let request: ((value: TerminalTabCloseRequest) => void) | undefined + const respond = vi.fn() + const set = vi.fn(async (_state: WorkspaceSessionState) => { + expect(owner.manager.destroy).toHaveBeenCalledOnce() + expect(useAppStore.getState().terminalLayoutsByTabId[TAB_ID]).toBeUndefined() + if (writeFails) { + throw new Error('persistence failed') + } + }) + const flush = vi.fn(async () => {}) + Object.assign(window, { + api: { + ...window.api, + session: { set, flush }, + ui: { + onOpenFileFromMobile: () => () => {}, + onOpenDiffFromMobile: () => () => {}, + onCloseTerminal: () => () => {}, + onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, + onTerminalTabCloseRequest: (listener: (value: TerminalTabCloseRequest) => void) => { + request = listener + return () => {} + }, + respondTerminalTabClose: respond + } + } + }) + const unsubs: (() => void)[] = [] + registerMobileAndTerminalCloseIpcBridge(unsubs, vi.fn()) + if (!request) { + throw new Error('Expected IPC listener') + } + try { + request({ + requestId: 'close', + tabId: TAB_ID, + localPtyTeardownOwnedExternally: true, + originalSurface: source.presentation.identity + }) + await vi.waitFor(() => + expect(respond).toHaveBeenCalledExactlyOnceWith({ + requestId: 'close', + error: 'terminal_tab_close_failed' + }) + ) + expect(set).toHaveBeenCalledOnce() + expect(set.mock.calls[0][0].tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(flush).toHaveBeenCalledTimes(writeFails ? 0 : 1) + expect(owner.sourceTransport.detach).toHaveBeenCalledOnce() + } finally { + unsubs.forEach((unsubscribe) => unsubscribe()) + owner.unregister() + } + } +) From 03fc6985d3b3deda8b838fdc181561904b3f67e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:42:42 -0700 Subject: [PATCH 047/207] Verify failed renderer cleanup rejects the host close receipt --- ...minal-presentation-retirement.unit.test.ts | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/e2e/terminal-presentation-retirement.unit.test.ts b/tests/e2e/terminal-presentation-retirement.unit.test.ts index 7ddd1dda8b73..da9796f44178 100644 --- a/tests/e2e/terminal-presentation-retirement.unit.test.ts +++ b/tests/e2e/terminal-presentation-retirement.unit.test.ts @@ -2,7 +2,10 @@ import { beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { retireTerminalPresentation } from '@/components/terminal-pane/terminal-presentation-retirement' -import type { TerminalTabCloseRequest } from '../../src/shared/terminal-tab-close' +import type { + TerminalTabCloseRequest, + TerminalTabCloseResponse +} from '../../src/shared/terminal-tab-close' import type { WorkspaceSessionState } from '../../src/shared/workspace-session-state-types' import { registerMobileAndTerminalCloseIpcBridge } from '@/hooks/ipc-events/mobile-terminal-close-ipc-bridge' import { mountedOwner } from '@/components/terminal-pane/terminal-presentation-retirement.test-fixture' @@ -74,9 +77,10 @@ it.each([false, true])( terminalLayoutsByTabId: session.terminalLayoutsByTabId }) const source = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) - if (!source.presentation) { + if (!source.presentation || !source.incarnationId) { throw new Error('Expected original') } + const { presentation, incarnationId } = source const owner = mountedOwner(false, { tabId: TAB_ID, worktreeId: WORKTREE_ID, @@ -87,7 +91,13 @@ it.each([false, true])( throw new Error('terminal dispose failed') }) let request: ((value: TerminalTabCloseRequest) => void) | undefined - const respond = vi.fn() + let rejectReceipt = (_error: Error): void => {} + const receipt = new Promise((_resolve, reject) => { + rejectReceipt = reject + }) + const respond = vi.fn((response: TerminalTabCloseResponse) => { + rejectReceipt(new Error(response.error ?? 'Unexpected success')) + }) const set = vi.fn(async (_state: WorkspaceSessionState) => { expect(owner.manager.destroy).toHaveBeenCalledOnce() expect(useAppStore.getState().terminalLayoutsByTabId[TAB_ID]).toBeUndefined() @@ -119,13 +129,23 @@ it.each([false, true])( if (!request) { throw new Error('Expected IPC listener') } - try { - request({ + const dispatch = request + harness.setVerifiedStopResult(true) + harness.setCloseTerminalTabAction(() => { + dispatch({ requestId: 'close', tabId: TAB_ID, localPtyTeardownOwnedExternally: true, - originalSurface: source.presentation.identity + originalSurface: presentation.identity }) + return receipt + }) + try { + await expect( + harness.runtime.closeTerminal(source.handle, incarnationId, presentation) + ).rejects.toThrow('terminal_tab_close_failed') + expect(harness.flushOrThrow).not.toHaveBeenCalled() + expect(harness.getSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeUndefined() await vi.waitFor(() => expect(respond).toHaveBeenCalledExactlyOnceWith({ requestId: 'close', From e8e66992730417358a9958445df26437cb9d8b17 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:47:37 -0700 Subject: [PATCH 048/207] Preserve existing terminal source admission alongside retained exit capture --- .../cli-chat-resume-terminal-capture.ts | 20 +++++++++---------- ...-chat-resume-terminal-capture.unit.test.ts | 19 +++++++++++++----- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts index 797537a565dd..660bfedeabdb 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-terminal-capture.ts @@ -53,10 +53,12 @@ export async function captureSourceTerminal(source: CliChatResumeSource) { const original = terminal.presentation?.identity const authority = parseHostAuthorityKey(source.executionHostId) if ( - !renderer || - !renderer.hasLeaf || + (!owner && source.ptyId === undefined && !renderer?.hasLeaf) || !original || !terminal.incarnationId || + original.worktreeId !== source.workspaceId || + original.tabId !== source.tabId || + original.leafId !== leafId || (terminal.executionHostId != null && terminal.executionHostId !== authority?.at) || original.ptyId !== terminal.ptyId || original.incarnationId !== terminal.incarnationId @@ -65,14 +67,12 @@ export async function captureSourceTerminal(source: CliChatResumeSource) { } const matches = owner ? terminal.handle === owner.terminal && - renderer.ptyId === source.ptyId && - renderer.worktreeId === original.worktreeId && - renderer.tabId === original.tabId && - renderer.leafId === original.leafId && - renderer.createdAt === original.createdAt && - renderer.generation === original.generation && - original.hasLeaf && - (renderer.incarnationId === null || renderer.incarnationId === original.incarnationId) + (renderer === null || + ((renderer.ptyId === null || renderer.ptyId === source.ptyId) && + renderer.createdAt === original.createdAt && + renderer.generation === original.generation && + (!renderer.hasLeaf || original.hasLeaf) && + (renderer.incarnationId === null || renderer.incarnationId === original.incarnationId))) : (source.ptyId === undefined || terminal.ptyId === source.ptyId) && isRetainedTerminalSurfaceIdentity(original, renderer) return matches diff --git a/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts index 522e0509f9fb..4a2a9f8cc569 100644 --- a/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts +++ b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts @@ -19,10 +19,7 @@ beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) rpc.mockReset() }) -function source( - ptyId: string | undefined = PTY_ID, - executionHostId: 'local' | 'runtime:paired' = 'local' -) { +function source(ptyId: string | undefined, executionHostId: 'local' | 'runtime:paired' = 'local') { return { workspaceId: WORKTREE_ID, tabId: TAB_ID, @@ -131,7 +128,7 @@ it.each(['row', 'incarnation', 'pty', 'workspace', 'leaf', 'host'] as const)( useAppStore.setState({ terminalPtyIncarnationsByPaneKey: { [`${TAB_ID}:${LEAF_ID}`]: INCARNATION_ID } }) - expect(await captureSourceTerminal(source())).toBeNull() + expect(await captureSourceTerminal(source(PTY_ID))).toBeNull() } ) @@ -163,3 +160,15 @@ it('keeps remote proxy and host PTY namespaces distinct, and refuses absent or c expect(await captureSourceTerminal(source(proxy, 'runtime:paired'))).toBeNull() expect(rpc).toHaveBeenCalledTimes(2) }) + +it('requires a real row/leaf for the new missing-binding exception while retaining existing nonnull-source admission', async () => { + const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) + const terminal = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + rpc.mockResolvedValue({ terminal }) + expect(await captureSourceTerminal(source(undefined))).toBeNull() + expect(await captureSourceTerminal(source(PTY_ID))).toMatchObject({ handle: terminal.handle }) + const proxy = toRemoteRuntimePtyId(terminal.handle, 'paired') + expect(await captureSourceTerminal(source(proxy, 'runtime:paired'))).toMatchObject({ + handle: terminal.handle + }) +}) From c843e12a7c9f56d4e5da9e514c48fc77f7a08e78 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:19:26 -0700 Subject: [PATCH 049/207] Exercise captured terminal stop contract in continuity test --- ...ca-runtime-terminal-close-continuity.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index 7f62c196e080..87494fef9b52 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -405,13 +405,14 @@ describe('terminal close and handle incarnation continuity', () => { it('forwards the captured incarnation to the stop and never falls back after a refused stop', async () => { const harness = createHarness({ registerPtyBacked: true }) - const terminal = (await harness.runtime.listTerminals()).terminals.find( - (row) => row.ptyId === PTY_ID - )! + const terminal = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + const presentation = terminal.presentation + if (!presentation) { + throw new Error('Missing original source') + } + const close = () => harness.runtime.closeTerminal(terminal.handle, INCARNATION_ID, presentation) harness.setVerifiedStopResult(false) - await expect(harness.runtime.closeTerminal(terminal.handle, INCARNATION_ID)).rejects.toThrow( - 'terminal_close_unverifiable' - ) + await expect(close()).rejects.toThrow('terminal_close_unverifiable') expect(harness.stopAndWait).toHaveBeenCalledWith( PTY_ID, expect.objectContaining({ expectedIncarnationId: INCARNATION_ID }) @@ -421,9 +422,7 @@ describe('terminal close and handle incarnation continuity', () => { expect(harness.closeTerminalPane).not.toHaveBeenCalled() harness.setVerifiedStopResult(true) harness.acknowledged.resolve() - await expect( - harness.runtime.closeTerminal(terminal.handle, INCARNATION_ID) - ).resolves.toMatchObject({ ptyKilled: true }) + await expect(close()).resolves.toMatchObject({ ptyKilled: true }) }) it('closes nothing when its handle now runs a later incarnation than the caller expected', async () => { From fca05afd160d12c92bd580413300b42c4fc64334 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:58:46 -0700 Subject: [PATCH 050/207] Fix terminal retirement fixture types --- ...time-terminal-close-continuity-fixtures.ts | 14 ++++++------- .../terminal-close-runtime.test-fixture.ts | 21 +++++++++++++++++++ ...al-presentation-retirement.test-fixture.ts | 12 ++++++++++- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts index 2c81beff7d0d..23584d925d49 100644 --- a/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts +++ b/src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts @@ -3,7 +3,7 @@ import { vi, type Mock } from 'vitest' import { makePaneKey } from '../../../shared/stable-pane-id' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { RuntimeTerminalListResult } from '../../../shared/runtime-types' -import { TerminalCloseRuntime } from './terminal-close-runtime.test-fixture' +import * as terminalClose from './terminal-close-runtime.test-fixture' import { withMainLayout } from './main-layout-close-fixture' import { CANARY_INCARNATION_ID, @@ -49,7 +49,7 @@ export { } from './orca-runtime-terminal-close-continuity-state-fixture' export type CloseContinuityHarness = { - runtime: TerminalCloseRuntime + runtime: terminalClose.TerminalCloseRuntime acknowledged: ReturnType closeTerminal: Mock<(...args: unknown[]) => unknown> closeTerminalPane: Mock<(...args: unknown[]) => unknown> @@ -99,13 +99,13 @@ function createHarness( addedAt: 1 } const store = withDurableRuntimeStore({ + ...terminalClose.terminalCloseStoreDefaults(), getRepos: () => [repo], getRepo: (id: string) => (id === REPO_ID ? repo : undefined), - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined, - getSettings: () => ({ workspaceDir: '/tmp/workspaces' }), getProjects: () => [], - getWorkspaceSession: () => (sessionAvailable ? session : undefined), + get getWorkspaceSession() { + return sessionAvailable ? () => session : undefined + }, setWorkspaceSession: (next: WorkspaceSessionState) => { session = next }, @@ -160,7 +160,7 @@ function createHarness( : []), ...(options.includeCanary ? [canaryProcess] : []) ]) - const runtime = new TerminalCloseRuntime(store as never) + const runtime = new terminalClose.TerminalCloseRuntime(store) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these close paths call only the listed notifier methods. runtime.setNotifier({ closeTerminal, closeTerminalPane, closeTerminalTab } as never) runtime.setPtyController({ diff --git a/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts b/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts index ad3dd90431c5..487958fb6810 100644 --- a/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts +++ b/src/main/runtime/__fixtures__/terminal-close-runtime.test-fixture.ts @@ -1,7 +1,28 @@ +import { vi } from 'vitest' import { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeStore } from '../runtime-store-contract' export class TerminalCloseRuntime extends OrcaRuntimeService { dropArchivedPtyRecord(ptyId: string): void { this.dropDisconnectedPtyRecord(ptyId) } } + +export function terminalCloseStoreDefaults() { + return { + addRepo: vi.fn(), + updateRepo: vi.fn(), + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getGitHubCache: () => ({ pr: {}, issue: {} }), + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '' + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts index 7d76601c19d3..39d6c771b9d3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-presentation-retirement.test-fixture.ts @@ -8,6 +8,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { FitAddon } from '@xterm/addon-fit' import { WebglAddon } from '@xterm/addon-webgl' import { ImageAddon } from '@xterm/addon-image' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' import { PaneManager } from '@/lib/pane-manager/pane-manager' import { retireManagedPanePreservingPty } from '@/lib/pane-manager/pane-split-close' @@ -29,6 +30,9 @@ function disposable(value: T): T { } function pane(id: number, leafId: string): ManagedPaneInternal { + if (!isTerminalLeafId(leafId)) { + throw new Error('Expected a valid fixture leaf id') + } const terminal = disposable(new Terminal()) vi.spyOn(terminal, 'clearSelection').mockImplementation(() => {}) vi.spyOn(terminal, 'focus').mockImplementation(() => {}) @@ -154,7 +158,13 @@ export function mountedOwner( requestTerminalLinkAction: vi.fn(), effectiveMacOptionAsAlt: 'false', effectiveMacOptionAsAltRef: { current: 'false' }, - initialLayoutRef: { current: { root: { type: 'leaf', leafId: address.leafId } } }, + initialLayoutRef: { + current: { + root: { type: 'leaf', leafId: address.leafId }, + activeLeafId: address.leafId, + expandedLeafId: null + } + }, expandedStyleSnapshotRef: { current: new Map() }, isActiveRef: { current: false }, isVisibleRef: { current: false }, From 6e66bbacf0dfbcedc0a201b55a987a066da04617 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:10:25 -0700 Subject: [PATCH 051/207] test: bound saved resume ownership and refusal work --- config/vitest.performance.config.ts | 4 + .../spawn-preflight-saved-resume.test.ts | 83 +++++++++ ...nt-session-terminal-writer-acquire.test.ts | 172 +++++++++++++++++- .../hook-status-resume-sleep-ordering.test.ts | 73 ++++++++ ...-status-refused-resume.performance.test.ts | 150 +++++++++++++++ 5 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index 8c0b623d4c74..fa2c1ddc0b90 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -5,6 +5,10 @@ import baseConfig from './vitest.config' import { nodeRuntimePool } from './scripts/vitest-node-runtime-pool' const contracts = [ + 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts', + 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts', + 'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts', 'src/main/sqlite/sync-database.test.ts', 'src/main/runtime/orchestration/db/row-column-lists.test.ts', 'src/shared/native-chat-tool-attribution-allocation.test.ts', diff --git a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts index 007799dcdef2..afe5f1ad48f5 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts @@ -1,3 +1,4 @@ +import { CLAUDE_CLI_CONVERSATIONS } from '../../../claude/claude-cli-conversations' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { localProvider } from '../provider/registry' import { spawnPtyFromRuntimeController } from './spawn' @@ -144,6 +145,88 @@ describe('actual runtime saved-resume outcomes', () => { return ctx } + it.each([10, 100])( + 'counts real saved-spawn projection and freshness across %i chats', + async (size) => { + const records = Array.from({ length: size }, (_, index) => ({ + ...chat, + sessionId: `chat-${index}`, + providerHandleChain: chat.providerHandleChain.map((link) => ({ + ...link, + handle: { ...link.handle, nativeId: `unrelated-${index}` } + })) + })) + const list = vi.spyOn(store, 'listRecords').mockImplementation(() => records) + const visible = vi.spyOn(store, 'getVisibleSessionTabIndex').mockReturnValue({ + present: true, + sessionIds: records.map((record) => record.sessionId) + }) + const read = vi.spyOn(CLAUDE_CLI_CONVERSATIONS.sessionHistory, 'rowSessionId') + const runtime = chatHost() + let rowReads = 0 + const originalRow = runtime.getAgentProviderSessionRows()[0]! + const rows = Array.from({ length: size }, (_, index) => ({ + ...originalRow, + get paneKey() { + rowReads += 1 + return index === size - 1 ? 'tab-1:leaf' : `unrelated-pane-${index}` + } + })) + runtime.getAgentProviderSessionRows = () => rows + const run = async (ordinary = false) => { + const ctx = freshContext(runtime) + ctx.spawnIdentityPaneKey = 'tab-1:leaf' + if (ordinary) { + delete ctx.args.resumeOrigin + } + await executeRuntimePtySpawn(ctx) + return ctx + } + const spawn = vi.spyOn(localProvider, 'spawn').mockResolvedValue({ id: 'counted-shell' }) + try { + await admitColdRestoreReattach( + runtime, + { coldRestore: {}, isReattach: true }, + { paneKey: 'tab-1:leaf', saved: undefined }, + null + ) + await run(true) + expect([ + list.mock.calls.length, + visible.mock.calls.length, + read.mock.calls.length, + rowReads + ]).toEqual([0, 0, 0, 0]) + for (let pane = 0; pane < size; pane += 1) { + expect((await run()).resumeAdmission?.verdict).toBe('admitted') + if (pane === 0) { + expect([read.mock.calls.length, rowReads]).toEqual([3 * size, size]) + } + } + // Each pane projects N heads and two handle reads per link; P=N exposes the aggregate cost. + expect(read.mock.calls.length).toBe(size * 3 * size) + expect([list.mock.calls.length, visible.mock.calls.length, rowReads]).toEqual([ + size, + size, + size * size + ]) + const target = records[0]! + target.providerHandleChain[0]!.handle.nativeId = C + const refused = await run() + expect(refused.resumeAdmission?.verdict).toBe('chat-home') + expect(refused.spawnOptions.command).toBeUndefined() + target.providerHandleChain[0]!.handle.nativeId = 'successor-conversation' + expect((await run()).resumeAdmission?.verdict).toBe('admitted') + expect(read.mock.calls.length).toBe((size + 2) * 3 * size) + } finally { + spawn.mockRestore() + read.mockRestore() + list.mockRestore() + visible.mockRestore() + } + } + ) + it('chooses a plain shell only in the fresh callback and does not forget before commit', async () => { const runtime = chatHost() const ctx = freshContext(runtime) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index 001d926b0860..8ea92782f2f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -1,3 +1,14 @@ +import { + assertCliConversationWritersExited, + posixCliWriterShellAlone +} from '../../runtime/structured-agent-cli-writers' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +const physicalSnapshot = vi.hoisted(() => vi.fn()) +vi.mock('../../../shared/process-table-snapshot-reader', async (importOriginal) => ({ + ...(await importOriginal()), + getFreshShellForegroundSnapshot: physicalSnapshot +})) import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,7 +49,7 @@ const acquire = vi.fn(async ({ fence, observedAt: NOW } })) -const assertNoTerminalWriter = vi.fn(async () => { +const assertNoTerminalWriter = vi.fn(async (_record: AgentSessionRecord) => { if (terminalWriting) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'conversationHeldElsewhere' @@ -50,8 +61,15 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-terminal-writer-acquire-')) resetHostTestOperationIds() terminalWriting = false + physicalSnapshot.mockReset() acquire.mockClear() - assertNoTerminalWriter.mockClear() + assertNoTerminalWriter.mockReset().mockImplementation(async () => { + if (terminalWriting) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } + }) store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -136,3 +154,153 @@ it('refuses the chat agent start while a terminal writes its conversation, once timeout: 10_000 }) }) + +it.each([10, 100])( + 'drives acquisition through the real census for %i matching/unrelated panes', + async (size) => { + let enumerated = 0 + const saved: Record = Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + `pane-${index}`, + { + paneKey: `pane-${index}`, + worktreeId: 'workspace-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: THREAD }, + state: 'done' as const, + prompt: '', + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const + } + ]) + ) + const population = new Proxy(saved, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + enumerated += keys.length + return keys + } + }) + let rowVisits = 0 + let linkVisits = 0 + const rows = new Proxy( + Array.from({ length: size }, (_, index) => ({ + paneKey: `unrelated-${index}`, + connectionId: null, + agentType: 'codex', + providerSession: { key: 'session_id' as const, id: 'unrelated' }, + state: 'done' as const, + prompt: '', + receivedAt: 1, + stateStartedAt: 1 + })), + { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + rowVisits += 1 + } + return Reflect.get(target, key, receiver) + } + } + ) + const links = new Proxy( + rows.map(() => ({ + agent: 'codex', + id: 'unrelated', + paneOwned: true, + worktreeId: 'workspace-1', + terminal: () => null + })), + { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + linkVisits += 1 + } + return Reflect.get(target, key, receiver) + } + } + ) + rowVisits = 0 + const shell = [{ pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }] + physicalSnapshot.mockResolvedValue(shell) + const physical = vi.fn(() => posixCliWriterShellAlone(10)) + assertNoTerminalWriter.mockImplementation((record) => + assertCliConversationWritersExited( + { + saved: () => Object.values(population), + rows: () => rows, + links: () => links, + observation: () => null, + sleeping: () => false, + terminal: () => ({ pane: { ptyId: 'shell', incarnationId: 'i1', identity: 'shell:i1' } }), + shellAlone: physical + }, + record.provider, + record.providerHandleChain.at(-1)?.handle.nativeId ?? '' + ) + ) + const start = async () => { + await host.close(SESSION, 'evict') + await host.revealSession(SESSION) + const before = acquire.mock.calls.length + await send('acquire after census') + await vi.waitFor(() => expect(acquire.mock.calls.length).toBe(before + 1)) + } + for (const record of Object.values(saved)) { + record.providerSession.id = 'unrelated' + } + await start() + expect([enumerated, physical.mock.calls.length]).toEqual([2 * size, 0]) + expect([rowVisits, linkVisits]).toEqual([size, size]) + for (const record of Object.values(saved)) { + record.providerSession.id = THREAD + } + enumerated = 0 + rowVisits = 0 + linkVisits = 0 + await start() + // Across each physical await, existing saved-membership and final coverage revalidate the population. + expect(enumerated).toBe((6 * size + 2) * size) + expect([rowVisits, linkVisits]).toEqual([(4 * size + 1) * size, size]) + expect(physical).toHaveBeenCalledTimes(size) + expect(physicalSnapshot).toHaveBeenCalledTimes(size) + + await host.close(SESSION, 'evict') + await host.revealSession(SESSION) + physicalSnapshot.mockResolvedValue([ + ...shell, + { pid: 11, ppid: 10, stat: 'T', command: 'codex' } + ]) + physical.mockClear() + const before = acquire.mock.calls.length + await rejection(await send('live terminal witness')) + expect(acquire).toHaveBeenCalledTimes(before) + expect(physical).toHaveBeenCalledTimes(1) + + let finish = () => {} + physicalSnapshot.mockResolvedValue(shell) + physicalSnapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(shell) + }) + ) + physical.mockClear() + await send('cancel during physical proof') + await vi.waitFor(() => expect(physical).toHaveBeenCalledTimes(1)) + const closed = host.close(SESSION, 'evict') + finish() + await closed + await assertNoTerminalWriter.mock.results.at(-1)?.value + // Closing prevents the late spawn; this inherited census still finishes every physical proof. + expect(physical).toHaveBeenCalledTimes(size) + expect(acquire).toHaveBeenCalledTimes(before) + physicalSnapshot.mockResolvedValue(shell) + enumerated = 0 + await host.revealSession(SESSION) + await send('successful successor') + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(before + 1)) + expect(enumerated).toBeLessThanOrEqual((6 * size + 2) * size) + } +) diff --git a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts index 93efd99d7c83..40ced350f759 100644 --- a/src/main/runtime/hook-status-resume-sleep-ordering.test.ts +++ b/src/main/runtime/hook-status-resume-sleep-ordering.test.ts @@ -205,3 +205,76 @@ it.each([false, true])( } } ) + +it.each([10, 100])( + 'counts actual refused cleanup copies/writes over %i saved panes', + async (size) => { + const session = makeWorkspaceSessionWithHeadlessTerminal() + const source = sleepCapture(11).get(PTY_ID)!.records[0]! + session.sleepingAgentSessionsByPaneKey = Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + `pane-${index}`, + { ...source, paneKey: `pane-${index}` } + ]) + ) + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(runtimeStore) + const originalClone = globalThis.structuredClone + let copied = 0 + let clones = 0 + const clone = vi.spyOn(globalThis, 'structuredClone').mockImplementation((value, options) => { + if (value && typeof value === 'object' && 'sleepingAgentSessionsByPaneKey' in value) { + copied += Object.keys(value.sleepingAgentSessionsByPaneKey ?? {}).length + clones += 1 + } + return originalClone(value, options) + }) + const clear = (index: number, stale = false) => { + const paneKey = `pane-${index}` + const record = { ...source, paneKey } + return runtime.forgetRefusedSavedResume( + TEST_WORKTREE_ID, + paneKey, + { + agent: 'codex', + providerSession: SESSION, + expectedSavedRecord: { + paneKey, + capturedAt: stale ? -1 : record.capturedAt, + updatedAt: record.updatedAt + } + }, + record + ) + } + try { + await clear(0, true) + expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([1, size, 0]) + // The durable fixture executes the real mutation/rollback, failing only its disk-write primitive. + runtimeStore.flushOrThrow.mockImplementationOnce(() => { + throw new Error('disk full') + }) + await expect(clear(0)).rejects.toThrow('disk full') + expect(getSession().sleepingAgentSessionsByPaneKey?.['pane-0']).toBeDefined() + clones = 0 + copied = 0 + runtimeStore.flushOrThrow.mockClear() + await Promise.all(Array.from({ length: size }, (_, index) => clear(index))) + // Each accepted edit clones before deleting, then stages one smaller map: sum(2k-1)=N². + expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([ + 2 * size, + size * size, + size + ]) + expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({}) + await clear(0) + expect([clones, copied, runtimeStore.flushOrThrow.mock.calls.length]).toEqual([ + 2 * size + 1, + size * size, + size + ]) + } finally { + clone.mockRestore() + } + } +) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts new file mode 100644 index 000000000000..00f532251f77 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts @@ -0,0 +1,150 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { + AgentStatusIpcPayload, + RefusedPaneResume +} from '../../../../shared/agent-status-types' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { createStoreSessionMockApi } from '@/store/slices/store-session-test-harness' +import { registerAgentStatusListeners } from './agent-status-listeners' + +const bridge = vi.hoisted(() => ({ getState: vi.fn(), setState: vi.fn() })) +vi.mock('@/store', () => ({ useAppStore: bridge })) + +afterEach(() => vi.unstubAllGlobals()) + +it.each([10, 100])( + 'counts refused-clear queue moves and real store notifications at %i entries', + (size) => { + vi.stubGlobal('window', undefined) + createStoreSessionMockApi() + const store = createTestStore() + bridge.getState.mockImplementation(store.getState) + bridge.setState.mockImplementation(store.setState) + const refused: RefusedPaneResume = { + agent: 'codex', + providerSession: { key: 'session_id', id: 'conversation' }, + expectedStatus: { paneKey: 'tab:leaf', receivedAt: 10, stateStartedAt: 1 }, + expectedSavedRecord: { paneKey: 'tab:leaf', capturedAt: 1, updatedAt: 1 } + } + const event = (index: number): AgentStatusIpcPayload => ({ + paneKey: index % 2 ? `other-${index}` : 'tab:leaf', + connectionId: null, + agentType: 'codex', + providerSession: refused.providerSession, + state: 'done', + prompt: '', + receivedAt: 10, + stateStartedAt: 1 + }) + let reads = 0 + let writes = 0 + const countQueue = (entries: T[]): T[] => + new Proxy(entries, { + get(target, key, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + reads += 1 + } + return Reflect.get(target, key, receiver) + }, + set(target, key, value, receiver) { + if (typeof key === 'string' && /^\d+$/.test(key)) { + writes += 1 + } + return Reflect.set(target, key, value, receiver) + } + }) + let copied = 0 + const countMap = (entries: T): T => + new Proxy(entries, { + ownKeys(target) { + const keys = Reflect.ownKeys(target) + copied += keys.length + return keys + } + }) + store.setState({ + agentStatusByPaneKey: countMap( + Object.fromEntries( + Array.from({ length: size }, (_, index) => { + const row = event(index) + return [index === 0 ? 'tab:leaf' : `other-${index}`, { ...row, updatedAt: 10 }] + }) + ) + ), + sleepingAgentSessionsByPaneKey: countMap( + Object.fromEntries( + Array.from({ length: size }, (_, index) => [ + index === 0 ? 'tab:leaf' : `other-${index}`, + { + paneKey: index === 0 ? 'tab:leaf' : `other-${index}`, + worktreeId: 'wt', + agent: 'codex', + providerSession: refused.providerSession, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1 + } + ]) + ) + ) + }) + const notifications = vi.fn() + const unsubscribe = store.subscribe(notifications) + const pending = countQueue( + Array.from({ length: size }, (_, index) => ({ + data: event(index), + firstSeenAt: 0, + replay: false + })) + ) + const burst = countQueue(Array.from({ length: size }, (_, index) => event(index))) + // Newer same-conversation work must survive the old refusal too. + burst[size - 1] = { ...event(0), receivedAt: 11 } + const onClear = vi.fn>(() => () => {}) + vi.stubGlobal('window', { + ...window, + api: { + ...window.api, + agentStatus: { + onSet: vi.fn(() => () => {}), + onClear + } + } + }) + const unsubs: (() => void)[] = [] + registerAgentStatusListeners({ + unsubs, + pendingAgentStatusEvents: pending, + liveAgentStatusBurstQueue: burst, + transientClearWatermarkByConnectionId: new Map(), + enqueueLiveAgentStatus: vi.fn(), + drainQueuedLiveAgentStatusesForPane: vi.fn() + }) + const clear = onClear.mock.calls[0]![0] + reads = 0 + writes = 0 + copied = 0 + try { + clear({ paneKey: 'tab:leaf', refusedResume: refused }) + const removed = size / 2 + // Reverse splice shifts 1+…+m surviving entries in EACH queue, besides one predicate read per row. + expect([reads, writes]).toEqual([3 * size + removed * (removed + 1), removed * (removed + 1)]) + expect(copied).toBe(2 * size) + expect(notifications).toHaveBeenCalledTimes(2) + expect([pending.length, burst.length]).toEqual([removed, removed]) + expect(burst.at(-1)?.receivedAt).toBe(11) + expect(store.getState().agentStatusByPaneKey['tab:leaf']).toBeUndefined() + expect(Object.keys(store.getState().sleepingAgentSessionsByPaneKey)).toHaveLength(size - 1) + notifications.mockClear() + copied = 0 + clear({ paneKey: 'tab:leaf', refusedResume: refused }) + expect(notifications).not.toHaveBeenCalled() + expect(copied).toBe(0) + } finally { + unsubs.forEach((dispose) => dispose()) + unsubscribe() + onClear.mockRestore() + } + } +) From ab7f01df276885a5df1943cda51d9ae4023f326b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:56:32 -0700 Subject: [PATCH 052/207] Align retirement fixture with workspace sleep subscription --- tests/e2e/terminal-presentation-retirement.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/terminal-presentation-retirement.unit.test.ts b/tests/e2e/terminal-presentation-retirement.unit.test.ts index da9796f44178..85f16ba3f4cc 100644 --- a/tests/e2e/terminal-presentation-retirement.unit.test.ts +++ b/tests/e2e/terminal-presentation-retirement.unit.test.ts @@ -114,7 +114,7 @@ it.each([false, true])( onOpenFileFromMobile: () => () => {}, onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, - onSleepWorktree: () => () => {}, + onWorkspaceSleep: () => () => {}, onResumeSleepingAgents: () => () => {}, onTerminalTabCloseRequest: (listener: (value: TerminalTabCloseRequest) => void) => { request = listener From dfb4cb88e0d9a19e6ce58bae971d1a7d8e696f2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:40:33 -0700 Subject: [PATCH 053/207] Settle refused saved resume claims from actual launch results --- .github/workflows/performance-contracts.yml | 4 ++ ...nt-session-terminal-writer-acquire.test.ts | 72 ++++++++++--------- ...session-tabs-targeted-agent-resume.spec.ts | 72 ++++++++++++++++++- .../agent-session-resume-claims.test.ts | 6 ++ .../projection/agent-session-resume-claims.ts | 11 ++- .../terminal/launch/mobile-terminal-create.ts | 3 +- .../launch/mobile-terminal-owned-create.ts | 2 +- ...-status-refused-resume.performance.test.ts | 26 +++---- 8 files changed, 144 insertions(+), 52 deletions(-) diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index e76ba2981c77..c4f71fd43094 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -17,6 +17,10 @@ on: - '!config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs' # Keep in sync with the contract list in config/vitest.performance.config.ts; # without these a rename lands green and only breaks the next nightly. + - 'src/main/ipc/pty/runtime/spawn-preflight-saved-resume.test.ts' + - 'src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts' + - 'src/main/runtime/hook-status-resume-sleep-ordering.test.ts' + - 'src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts' - 'src/main/sqlite/sync-database.test.ts' - 'src/main/runtime/orchestration/db/row-column-lists.test.ts' - 'src/shared/native-chat-tool-attribution-allocation.test.ts' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts index 8ea92782f2f6..a85e86432f3a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-terminal-writer-acquire.test.ts @@ -18,7 +18,10 @@ import { agentSessionRefusalError } from '../../../shared/agent-session-wire-ref import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -99,6 +102,7 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() + closeTestJournalHostDatabase(root) await rm(root, { recursive: true, force: true }) }) @@ -184,42 +188,42 @@ it.each([10, 100])( }) let rowVisits = 0 let linkVisits = 0 - const rows = new Proxy( - Array.from({ length: size }, (_, index) => ({ - paneKey: `unrelated-${index}`, - connectionId: null, - agentType: 'codex', - providerSession: { key: 'session_id' as const, id: 'unrelated' }, - state: 'done' as const, - prompt: '', - receivedAt: 1, - stateStartedAt: 1 - })), - { - get(target, key, receiver) { - if (typeof key === 'string' && /^\d+$/.test(key)) { - rowVisits += 1 - } - return Reflect.get(target, key, receiver) + const rows = Array.from({ length: size }, (_, index) => ({ + paneKey: `unrelated-${index}`, + connectionId: null, + agentType: 'codex', + providerSession: { key: 'session_id' as const, id: 'unrelated' }, + state: 'done' as const, + prompt: '', + receivedAt: 1, + stateStartedAt: 1 + })) + const links = rows.map(() => ({ + agent: 'codex', + id: 'unrelated', + paneOwned: true, + worktreeId: 'workspace-1', + terminal: () => null + })) + rows.forEach((row, index) => + Object.defineProperty(rows, index, { + configurable: true, + enumerable: true, + get: () => { + rowVisits += 1 + return row } - } + }) ) - const links = new Proxy( - rows.map(() => ({ - agent: 'codex', - id: 'unrelated', - paneOwned: true, - worktreeId: 'workspace-1', - terminal: () => null - })), - { - get(target, key, receiver) { - if (typeof key === 'string' && /^\d+$/.test(key)) { - linkVisits += 1 - } - return Reflect.get(target, key, receiver) + links.forEach((link, index) => + Object.defineProperty(links, index, { + configurable: true, + enumerable: true, + get: () => { + linkVisits += 1 + return link } - } + }) ) rowVisits = 0 const shell = [{ pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }] diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts index e5973a5e39ea..02f7a5883ba6 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts @@ -14,6 +14,16 @@ import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' import type { PtyProcessInfo } from '../../providers/pty-provider-contract' import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity' +import type { RuntimePtyController } from '../runtime-pty-controller-contract' +import { AgentSessionRecordStore } from '../agent-session-record-store' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { + chooseSavedResumeFreshLaunch, + savedResumeResult +} from '../../ipc/pty/host-env/saved-resume-admission' const PANE_KEY = `host-tab:${HEADLESS_LEAF_ID}` const SESSION = { key: 'session_id', id: 'provider-session-1' } as const @@ -73,7 +83,7 @@ function sessionRow(paneKey: string, receivedAt = 0): AgentStatusIpcPayload { function makeRuntime( fixture: Fixture, opts: { - spawn?: () => Promise<{ id: string }> + spawn?: NonNullable rows?: () => AgentStatusIpcPayload[] checkHookAgentPresence?: () => Promise<'live' | 'unverifiable' | 'exited' | null> listProcesses?: () => Promise @@ -82,7 +92,7 @@ function makeRuntime( ) => () => void } = {} ) { - const spawn = vi.fn<(args: unknown) => Promise<{ id: string }>>( + const spawn = vi.fn>( opts.spawn ?? (() => Promise.resolve({ id: 'resumed-pty' })) ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store implements the runtime store methods these tests reach. @@ -149,6 +159,64 @@ function storedRecord(fixture: Fixture) { } describe('resumeAgentSessionInWorkspace', () => { + it('releases a saved resume claim when actual admission creates a plain shell', async () => { + const fixture = makeSleptWorkspace() + const saved = storedRecord(fixture) + if (!saved) { + throw new Error('missing saved conversation') + } + saved.origin = 'live' + const chat = agentSessionRecordFixture( + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'exit-observed', detail: 'closed', observedAt: 1 } + }) + ) + chat.providerHandleChain[0]!.handle.nativeId = SESSION.id + let nativeOpen = true + const store: AgentSessionRecordStore = Object.create(AgentSessionRecordStore.prototype) + store.listRecords = () => [chat] + store.getVisibleSessionTabIndex = () => ({ + present: true, + sessionIds: nativeOpen ? [chat.sessionId] : [] + }) + const effectiveCommands: (string | undefined)[] = [] + const { runtime, spawn, openOldPane, resume } = makeRuntime(fixture, { + spawn: async (args) => { + const admission = await chooseSavedResumeFreshLaunch( + { openAgentSessionRecordStore: async () => store }, + args, + null, + args + ) + effectiveCommands.push(args.command) + return { + id: effectiveCommands.length === 1 ? 'refused-shell' : 'resumed-C', + launchAgent: args.launchAgent, + launchConfig: args.launchConfig, + ...savedResumeResult(admission) + } + } + }) + await openOldPane() + expect(runtime.getTerminalLaunchResult('refused-shell')).toMatchObject({ + agentResumeRefused: 'chat-home', + savedResumeDecision: { agent: 'claude', providerSession: SESSION, verdict: 'chat-home' } + }) + expect(runtime.getTerminalLaunchResult('refused-shell')?.launchAgent).toBeUndefined() + expect(effectiveCommands).toEqual([undefined]) + nativeOpen = false + await expect(resume({ mutationId: 'after-native-close' })).resolves.toMatchObject({ + ok: true, + ptyId: 'resumed-C' + }) + expect(effectiveCommands[1]).toContain(SESSION.id) + expect(spawn).toHaveBeenCalledTimes(2) + expect(runtime.getTerminalLaunchResult('refused-shell')).toBeDefined() + }) + // Review repro, on the runtime-owned path: the spawn started but its reply was lost. it('retains ownership after an ambiguous create with a running resume', async () => { const fixture = makeSleptWorkspace() diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts index a9761c36ad3b..b70a3a96d2b6 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.test.ts @@ -41,6 +41,12 @@ describe('AgentSessionResumeClaims', () => { it('keeps a lost-reply attempt held unless a retry of the same request binds', () => { const { claims, exits } = makeClaims() claims.claim(SESSION, 'tab:a', 'request-1')?.settleFailure(true, new Error('reply lost')) + // A retry's known refusal cannot account for its earlier ambiguous dispatch. + claims + .claim(SESSION, 'tab:a', 'request-1') + ?.bind('plain-shell', { agentResumeRefused: 'chat-home' }) + expect(exits.has('plain-shell')).toBe(false) + expect(claims.claim(SESSION, 'tab:b')).toBeNull() // Another request's spawn accounts only for itself. claims.claim(SESSION, 'tab:a', 'request-2')?.bind('pty-2') exits.get('pty-2')?.() diff --git a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts index b4489e3ab221..d22c842b50d5 100644 --- a/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts +++ b/src/main/runtime/session-lifecycle/projection/agent-session-resume-claims.ts @@ -1,5 +1,6 @@ import { agentProviderSessionsEqual, + type TerminalLaunchResult, type AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -28,7 +29,7 @@ type Hold = AgentSessionResumeHold & { /** A resumer's share of a hold, settled only from its own create's evidence. */ export type AgentSessionResumeClaim = { /** The exact PTY this resumer's create produced; the hold ends once every such PTY exits. */ - bind: (ptyId: string) => void + bind: (ptyId: string, result?: TerminalLaunchResult) => void /** Releases when nothing was dispatched or the spawn proved its exit; otherwise stays held. */ settleFailure: (dispatched: boolean, error?: unknown) => void } @@ -137,11 +138,17 @@ export class AgentSessionResumeClaims { private share(hold: Hold, request: string | undefined): AgentSessionResumeClaim { let open = true return { - bind: (ptyId) => { + bind: (ptyId, result) => { if (!open) { return } open = false + if (result?.agentResumeRefused) { + // A known plain shell settles only this share, never another uncertain dispatch. + hold.unsettled -= 1 + this.endIfSettled(hold) + return + } hold.unsettled -= 1 + (request ? (hold.ambiguous.get(request) ?? 0) : 0) if (request) { hold.ambiguous.delete(request) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-create.ts index 7fc297d94b78..059c7e9eab02 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-create.ts @@ -4,6 +4,7 @@ import { OrcaRuntimeWithFailedStartPanes } from './failed-start-pane' import type { WorktreeStartupLaunch } from '../../../../shared/worktree/launch-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { + TerminalLaunchResult, AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' @@ -44,7 +45,7 @@ export type OwnedMobileTerminalCreateOptions = { /** See TerminalCreateOptions; a create that throws before it proves nothing spawned. */ onPtySpawnDispatched?: () => void /** The PTY this create produced, reported before publication can fail. */ - onPtyCreated?: (ptyId: string) => void + onPtyCreated?: (ptyId: string, result?: TerminalLaunchResult) => void } export abstract class OrcaRuntimeWithCreateMobileSessionTerminal extends OrcaRuntimeWithFailedStartPanes { diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 31d8f94ac61b..797f57ca2b94 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -131,7 +131,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext ) : await create() if (terminal.ptyId) { - opts.onPtyCreated?.(terminal.ptyId) + opts.onPtyCreated?.(terminal.ptyId, terminal) } const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { diff --git a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts index 00f532251f77..f3deefcb70be 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts @@ -38,21 +38,23 @@ it.each([10, 100])( }) let reads = 0 let writes = 0 - const countQueue = (entries: T[]): T[] => - new Proxy(entries, { - get(target, key, receiver) { - if (typeof key === 'string' && /^\d+$/.test(key)) { + const countQueue = (entries: T[]): T[] => { + entries.forEach((entry, index) => + Object.defineProperty(entries, index, { + configurable: true, + enumerable: true, + get: () => { reads += 1 - } - return Reflect.get(target, key, receiver) - }, - set(target, key, value, receiver) { - if (typeof key === 'string' && /^\d+$/.test(key)) { + return entry + }, + set: (next: T) => { writes += 1 + entry = next } - return Reflect.set(target, key, value, receiver) - } - }) + }) + ) + return entries + } let copied = 0 const countMap = (entries: T): T => new Proxy(entries, { From 35039057b4e56db5bf2145a2cbb07a4104bb6084 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:23:41 -0700 Subject: [PATCH 054/207] Correct capture fixture owner facts and relocated writer accounting --- config/main-layout-writer-baseline.txt | 2 +- ...cli-chat-resume-terminal-capture.unit.test.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index ea882afe8c79..29fc2ea849f2 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -59,10 +59,10 @@ 1 src/main/runtime/runtime-workspace-session-controller.ts 2 src/main/runtime/session-graph/session-graph-promotion.ts 2 src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts -2 src/main/runtime/session-lifecycle/projection/build-headless-mobile-session-browser-tabs.ts 2 src/main/runtime/session-tabs/headless-tab-group-moves.ts 6 src/main/runtime/session-tabs/headless-tab-properties.ts 1 src/main/runtime/session-tabs/structured-session-tab-close.ts +2 src/main/runtime/session-tabs/terminal-surface-close-commit.ts 1 src/main/runtime/session-tabs/terminal-surface-retirements.ts 2 src/main/runtime/terminal/adoption/adopt-terminal-orphans-from-inventory.ts 1 src/main/runtime/terminal/driver/terminal-driver-resources.ts diff --git a/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts index 516983fe4d1c..db082c55474a 100644 --- a/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts +++ b/tests/e2e/cli-chat-resume-terminal-capture.unit.test.ts @@ -88,6 +88,7 @@ it.each(['row', 'incarnation', 'pty', 'workspace', 'leaf', 'host'] as const)( async (change) => { const harness = createHarness({ registerPtyBacked: true, publishMobileSurface: true }) seed(harness) + let resolvedTerminal: ReturnType | undefined rpc.mockImplementation(async () => { if (change === 'row') { harness.editSession((state) => ({ @@ -102,13 +103,10 @@ it.each(['row', 'incarnation', 'pty', 'workspace', 'leaf', 'host'] as const)( })) } if (change === 'incarnation') { - harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { - tabId: TAB_ID, - leafId: LEAF_ID, - incarnationId: 'successor' - }) + harness.replacePersistedIncarnation('successor') } const terminal = harness.runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + resolvedTerminal = terminal if (change === 'host') { terminal.executionHostId = 'ssh:successor' } @@ -123,10 +121,12 @@ it.each(['row', 'incarnation', 'pty', 'workspace', 'leaf', 'host'] as const)( } return { terminal } }) - useAppStore.setState({ - terminalPtyIncarnationsByPaneKey: { [`${TAB_ID}:${LEAF_ID}`]: INCARNATION_ID } - }) expect(await captureSourceTerminal(source(PTY_ID))).toBeNull() + if (change === 'incarnation') { + expect(rpc).toHaveResolvedWith({ terminal: resolvedTerminal }) + expect(resolvedTerminal).toMatchObject({ ptyId: PTY_ID, incarnationId: INCARNATION_ID }) + expect(resolvedTerminal).not.toHaveProperty('presentation') + } } ) From ec8b66fc6b289e19d962794e08a516add7061271 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:30:26 -0700 Subject: [PATCH 055/207] Fix saved resume test type contracts --- .../mobile-session-tabs-targeted-agent-resume.spec.ts | 3 ++- .../pty-connection/saved-resume-cancellation.test.ts | 2 +- .../agent-status-refused-resume.performance.test.ts | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts index 73d7c1ffbac7..651fea8c6ed5 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-targeted-agent-resume.spec.ts @@ -11,6 +11,7 @@ import { makeWorkspaceSessionWithHeadlessTerminal } from '../orca-runtime-test-fixtures.spec' import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import type { TerminalLaunchResult } from '../../../shared/agent-session-resume' import type { PtyProcessInfo } from '../../providers/pty-provider-contract' import { deterministicAgentSessionUuid } from '../runtime-agent-launch-resolution' import { deriveRemoteRuntimeTerminalCreateHandle } from '../remote-runtime-terminal-create-identity' @@ -231,7 +232,7 @@ describe('resumeAgentSessionInWorkspace', () => { const fixture = makeSleptWorkspace() const record = storedRecord(fixture)! record.origin = 'live' - const matching = savedResumeResult({ + const matching: TerminalLaunchResult = savedResumeResult({ verdict: kind === 'unverifiable' ? 'unverifiable' : 'chat-home', refused: { agent: 'claude', providerSession: SESSION } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts index 72e3b6e2fee2..6291ea78499b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/saved-resume-cancellation.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusEntry } from '@shared/agent-status-types' +import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' import type { AgentLaunchConfigRegistryEntry, AgentLaunchConfigRegistrationMetadata diff --git a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts index f3deefcb70be..e9cc5a5dd128 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-refused-resume.performance.test.ts @@ -69,7 +69,10 @@ it.each([10, 100])( Object.fromEntries( Array.from({ length: size }, (_, index) => { const row = event(index) - return [index === 0 ? 'tab:leaf' : `other-${index}`, { ...row, updatedAt: 10 }] + return [ + index === 0 ? 'tab:leaf' : `other-${index}`, + { ...row, updatedAt: 10, stateHistory: [] } + ] }) ) ), From 872f19999d5d781e97694d1fa16a11e04039c048 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:36:25 -0700 Subject: [PATCH 056/207] Pin authenticated final-pane retirement through the real Store --- ...time-original-terminal-close-store.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/main/runtime/orca-runtime-original-terminal-close-store.test.ts diff --git a/src/main/runtime/orca-runtime-original-terminal-close-store.test.ts b/src/main/runtime/orca-runtime-original-terminal-close-store.test.ts new file mode 100644 index 000000000000..da071243f3f6 --- /dev/null +++ b/src/main/runtime/orca-runtime-original-terminal-close-store.test.ts @@ -0,0 +1,107 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from '../persistence/loading-store/profile-state-delayed-authority-fixture' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { ClientSessionTabSelectionStore } from './client-session-tab-selection' +import { TerminalCloseRuntime } from './__fixtures__/terminal-close-runtime.test-fixture' +import { createCloseContinuityGraphFixture } from './__fixtures__/orca-runtime-terminal-close-continuity-graph-fixture' +import { + INCARNATION_ID, + LEAF_ID, + PTY_ID, + REPO_ID, + TAB_ID, + WORKTREE_ID, + WORKTREE_PATH, + makeSession +} from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' + +it('retires the authenticated final pane through the real Store and forgets its tab selection', async () => { + const { store, readState } = await fixture() + store.addRepo({ + id: REPO_ID, + path: WORKTREE_PATH, + displayName: 'Original retirement', + badgeColor: '#000000', + addedAt: 1 + }) + store.setWorkspaceSession(makeSession()) + await store.flushPendingOrThrowAsync() + const runtime = new TerminalCloseRuntime(store) + const kill = vi.fn(() => true) + const closeTerminalTab = vi.fn(async () => {}) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn(), + closeTerminalTab + }) + const stopAndWait = vi.fn(async () => { + await runtime.onPtyExit(PTY_ID, 0, INCARNATION_ID, { hostExitConfirmed: true }) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[TAB_ID]).toBeDefined() + return true + }) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: INCARNATION_ID + }) + createCloseContinuityGraphFixture({ + runtime, + ptyId: PTY_ID, + publishMobileSurface: true, + getSession: () => store.getWorkspaceSession(), + setSession: (session) => store.setWorkspaceSession(session), + markSiblingPtyIncluded: () => {} + }).syncFixtureGraph() + const command = { + type: 'closePane', + workspace: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID + } as const + await expect(runtime.applyLayoutCommand(command)).resolves.toMatchObject({ + ok: false, + code: 'last_pane' + }) + const source = runtime.resolveTerminalPane(`${TAB_ID}:${LEAF_ID}`, WORKTREE_ID) + expect(source.presentation).toBeDefined() + expect(source.incarnationId).toBe(INCARNATION_ID) + if (!source.presentation || !source.incarnationId) { + throw new Error('Missing authenticated original') + } + const apply = vi.spyOn(store, 'applyLayoutCommand') + const forget = vi.spyOn(ClientSessionTabSelectionStore.prototype, 'forgetTabs') + await expect( + runtime.closeTerminal(source.handle, source.incarnationId, source.presentation) + ).resolves.toMatchObject({ ptyKilled: true }) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(apply).toHaveResolvedWith({ ok: true, result: { closed: [TAB_ID] } }) + } + expect(forget).toHaveBeenCalledWith(WORKTREE_ID, [TAB_ID]) + expect(stopAndWait).toHaveBeenCalledExactlyOnceWith( + PTY_ID, + expect.objectContaining({ expectedIncarnationId: INCARNATION_ID }) + ) + expect(kill).not.toHaveBeenCalled() + expect(closeTerminalTab).toHaveBeenCalledExactlyOnceWith(TAB_ID, { + localPtyTeardownOwnedExternally: true, + originalSurface: source.presentation.identity + }) + expect(store.getWorkspaceSession().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(readState().workspaceSession.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(store.getWorkspaceSession().closedTerminalTabTombstonesByTabId?.[TAB_ID]).toBeDefined() +}) From 25094d90cc790b708b1358172f0c181d1f107ac6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:04:49 -0700 Subject: [PATCH 057/207] Pin status identity preservation across published owner transitions --- ...Events-agent-status-owner-ordering.test.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts new file mode 100644 index 000000000000..c6931b221e9d --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../shared/terminal-tab-types' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '../store/slices/store-test-helpers' +import { classifyPaneKeyLiveness } from '../store/slices/agent-status-capacity-eviction' +import { + resetAgentPaneAuthorityAliasesForTests, + resolveAgentPaneAuthorityKey +} from '../store/slices/agent-pane-authority' +import { buildWindowApi } from './ipc-events-agent-status-window-test-fixtures' + +const holder = vi.hoisted(() => ({ + getStore: (): ReturnType => { + throw new Error('Witness store not initialized') + } +})) +vi.mock('../store', () => ({ + useAppStore: { + getState: () => holder.getStore().getState(), + subscribe: (...args: Parameters['subscribe']>) => + holder.getStore().subscribe(...args) + } +})) +vi.mock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() +})) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) + +const A = '11111111-1111-4111-8111-111111111111' +const B = '22222222-2222-4222-8222-222222222222' +const R = '33333333-3333-4333-8333-333333333333' +const TAB = 'tab-ordering' +const WT = 'wt-ordering' +const key = (leaf: string): string => makePaneKey(TAB, leaf) +const layout = (...leaves: string[]): TerminalLayoutSnapshot => ({ + root: leaves.slice(1).reduce( + (first, leafId) => ({ + type: 'split', + direction: 'vertical', + first, + second: { type: 'leaf', leafId } + }), + { type: 'leaf', leafId: leaves[0]! } + ), + activeLeafId: leaves[0]!, + expandedLeafId: null +}) +const cleanups: (() => void)[] = [] +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_800_000_000_000) + resetAgentPaneAuthorityAliasesForTests() +}) +afterEach(() => { + cleanups + .splice(0) + .toReversed() + .forEach((cleanup) => cleanup()) + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +async function witness() { + const store = createTestStore() + holder.getStore = () => store + const tab = makeTab({ id: TAB, worktreeId: WT, ptyId: 'pty-A', title: 'Shell' }) + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: WT, repoId: TEST_REPO.id })] }, + tabsByWorktree: { [WT]: [tab] }, + terminalLayoutsByTabId: { [TAB]: layout(R) } + }) + let phase = 'mount' + const publications: unknown[] = [] + const removals: { phase: string; incoming: string; keys: string[]; owner: string }[] = [] + const capture = (): void => { + const state = store.getState() + publications.push({ + phase, + layouts: state.terminalLayoutsByTabId, + aliases: [A, B, R].map((leaf) => [key(leaf), resolveAgentPaneAuthorityKey(key(leaf))]), + projection: state.agentConversationsByPaneKey, + live: Object.keys(state.agentStatusByPaneKey), + sleeping: Object.keys(state.sleepingAgentSessionsByPaneKey) + }) + } + cleanups.push( + store.subscribe((state, previous) => { + capture() + for (const incoming of [key(A), key(B)]) { + if ( + state.agentConversationsByPaneKey[incoming] && + !previous.agentConversationsByPaneKey[incoming] + ) { + // Observational only: exercise the candidate's classifier, never remove a production key. + const classify = classifyPaneKeyLiveness(state) + removals.push({ + phase, + incoming, + owner: resolveAgentPaneAuthorityKey(key(A)), + keys: Object.keys(state.agentConversationsByPaneKey).filter( + (candidate) => candidate !== incoming && classify(candidate) === 'dead' + ) + }) + } + } + }) + ) + let onSet: ((data: AgentStatusIpcPayload) => void) | undefined + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (callback) => { + onSet = callback + return () => {} + } + }) + ) + const { registerAgentStatusIpcBridge } = await import('./ipc-events/agent-status-ipc-bridge') + const unsubs: (() => void)[] = [] + const bridge = registerAgentStatusIpcBridge(unsubs) + cleanups.push(bridge.disposeAsyncState, bridge.unsubscribeStore, ...unsubs) + await Promise.resolve() + const transaction = vi.spyOn(store.getState(), 'transactAgentStatuses') + const timer = vi.spyOn(globalThis, 'setTimeout') + const event = ( + leaf: string, + providerSessionOnly = false, + runtimeBacked = true + ): AgentStatusIpcPayload => ({ + paneKey: key(leaf), + state: 'working', + prompt: '', + agentType: 'claude', + receivedAt: Date.now(), + stateStartedAt: Date.now(), + providerSession: { key: 'session_id', id: `conversation-${leaf}` }, + ...(providerSessionOnly ? { providerSessionOnly: true } : {}), + ...(runtimeBacked ? { worktreeId: WT, terminalHandle: `term-${leaf}` } : {}) + }) + return { + store, + tab, + publications, + removals, + transaction, + timer, + event, + stage: (name: string) => { + phase = name + capture() + }, + emit: (data: AgentStatusIpcPayload) => { + if (!onSet) { + throw new Error('Missing production onSet') + } + onSet(data) + }, + settle: async () => { + await vi.advanceTimersByTimeAsync(150) + phase = 'settled' + capture() + } + } +} + +it.each([ + ['live', A, B], + ['live', B, A], + ['provider-only', A, B], + ['provider-only', B, A] +])( + 'preserves two %s source-before-layout identities in order %s then %s', + async (route, first, second) => { + const w = await witness() + for (const leaf of [first, second]) { + w.stage(`source-${leaf}`) + w.emit(w.event(leaf, route === 'provider-only')) + await vi.advanceTimersByTimeAsync(34) + } + expect(w.removals.at(-1)?.keys).toEqual([key(first)]) + for (const leaf of [A, B]) { + w.stage(`ordinary-cleanup-${leaf}`) + w.store.getState().removeAgentStatus(key(leaf)) + w.store.getState().clearSleepingAgentSession(key(leaf)) + } + expect(w.store.getState().agentStatusByPaneKey).toEqual({}) + expect(w.store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) + w.stage('later-layout') + w.store.getState().setTabLayout(TAB, layout(A, B)) + await w.settle() + expect(Object.keys(w.store.getState().agentConversationsByPaneKey).sort()).toEqual( + [key(A), key(B)].sort() + ) + console.info( + 'OWNER_ORDERING_TRACE', + JSON.stringify({ + route, + first, + second, + publications: w.publications, + wouldRemove: w.removals + }) + ) + } +) + +it.each(['normalization', 'scoped-hydration'])( + 'replays pending B before projection-only A transfers during %s', + async (operation) => { + const w = await witness() + w.store.getState().setTabLayout(TAB, layout(A)) + w.stage('source-A') + w.emit(w.event(A, true)) + w.store.getState().clearSleepingAgentSession(key(A)) + expect(w.store.getState().agentStatusByPaneKey).toEqual({}) + expect(w.store.getState().sleepingAgentSessionsByPaneKey).toEqual({}) + await vi.advanceTimersByTimeAsync(34) + w.stage('pending-B') + w.emit(w.event(B, false, false)) + expect(w.store.getState().agentConversationsByPaneKey[key(B)]).toBeUndefined() + expect(w.timer.mock.calls.filter((call) => call[1] === 100)).toHaveLength(1) + const batchesBefore = w.transaction.mock.calls.length + const duplicate = { + ...layout(A, R, B), + activeLeafId: R, + ptyIdsByLeafId: { [A]: 'pty-A', [R]: 'pty-A', [B]: 'pty-B' } + } + w.stage(operation) + if (operation === 'normalization') { + w.store.getState().setTabLayout(TAB, duplicate) + } else { + w.store.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + activeRepoId: TEST_REPO.id, + activeWorktreeId: WT, + activeTabId: TAB, + tabsByWorktree: { [WT]: [w.tab] }, + terminalLayoutsByTabId: { [TAB]: duplicate } + }, + { replaceWorkspaceKeys: [WT] } + ) + } + expect(w.transaction.mock.calls.length - batchesBefore).toBe(1) + expect(w.removals.find((row) => row.incoming === key(B))).toEqual({ + phase: operation, + incoming: key(B), + owner: key(A), + keys: [key(A)] + }) + expect(collectLeafIds(w.store.getState().terminalLayoutsByTabId[TAB].root)).toEqual([R, B]) + expect(resolveAgentPaneAuthorityKey(key(A))).toBe(key(R)) + expect(w.store.getState().agentConversationsByPaneKey[key(R)]?.providerSession.id).toBe( + `conversation-${A}` + ) + expect(w.store.getState().agentConversationsByPaneKey[key(A)]).toBeUndefined() + await w.settle() + expect(w.store.getState().agentConversationsByPaneKey[key(B)]).toBeDefined() + expect(w.store.getState().agentConversationsByPaneKey[key(R)]).toBeDefined() + console.info( + 'OWNER_ORDERING_TRACE', + JSON.stringify({ + operation, + synchronousPendingBatches: w.transaction.mock.calls.length - batchesBefore, + pendingRetryTimers: w.timer.mock.calls.filter((call) => call[1] === 100).length, + publications: w.publications, + wouldRemove: w.removals + }) + ) + } +) From bcb0a68851ba7870912cb5f6f2d525a86f9cfcd8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:32:04 -0700 Subject: [PATCH 058/207] test(e2e): capture exact terminal parking diagnostics --- ...terminal-materialization-reconnect.spec.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index 395f06dbf56a..e436e86e55e3 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -144,6 +144,7 @@ async function parkHostTerminal( ): Promise { let lastError = 'terminal.stopExact was never attempted' for (let attempt = 0; attempt < 10; attempt += 1) { + const stopStartedAt = Date.now() const stop = await callRuntime<{ stopped: number stoppedPtyIds: string[] @@ -158,7 +159,32 @@ async function parkHostTerminal( return null }) if (stop) { - expect(stop.postStopVerified).toBe(true) + const diagnostic: Record = { + expectedPtyId: options.expectedPtyId, + elapsedMs: Date.now() - stopStartedAt, + stop + } + if (stop.postStopVerified === false) { + diagnostic.inventory = await readFreshTerminalInventory(() => + callRuntime(page, environmentId, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + ).catch((error: unknown) => ({ error: String(error) })) + diagnostic.surface = await hostSurfaceStatus(page, environmentId, worktreeId, parentTabId) + .then((status) => ({ parentTabId, status })) + .catch((error: unknown) => ({ parentTabId, error: String(error) })) + } + await test + .info() + .attach('host-terminal-park', { + body: JSON.stringify(diagnostic, null, 2), + contentType: 'application/json' + }) + .catch((error: unknown) => { + diagnostic.attachmentError = String(error) + }) + expect(stop.postStopVerified, JSON.stringify(diagnostic, null, 2)).toBe(true) expect(stop.stopped).toBe(1) expect(stop.stoppedPtyIds).toEqual([options.expectedPtyId]) return From 914747fd34746ed6466a01bf60fcf948841e0a12 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:00:02 -0700 Subject: [PATCH 059/207] Restore optional terminal presentation contracts --- .../terminal/terminal-lifecycle-methods.ts | 12 +++--- src/main/runtime/runtime-layout-apply.test.ts | 40 ++++++++++++++++++- src/main/runtime/runtime-layout-apply.ts | 18 ++++----- .../explicit-terminal-close-stops.ts | 1 + .../terminal/launch/failed-start-pane.ts | 2 +- .../recovery/resolve-terminal-pane.ts | 5 +-- ...ession-terminal-tab-retirement-identity.ts | 7 +++- .../right-sidebar/AiVaultVirtualRow.test.tsx | 5 ++- 8 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 7f80896b51f1..43b22ae8c596 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -234,11 +234,13 @@ export const TERMINAL_LIFECYCLE_METHODS = [ 'terminal', params.terminal, () => - context.runtime.closeTerminal( - params.terminal, - params.expectedIncarnationId, - params.originalPresentation - ) + params.originalPresentation + ? context.runtime.closeTerminal( + params.terminal, + params.expectedIncarnationId, + params.originalPresentation + ) + : context.runtime.closeTerminal(params.terminal, params.expectedIncarnationId) ) }) }), diff --git a/src/main/runtime/runtime-layout-apply.test.ts b/src/main/runtime/runtime-layout-apply.test.ts index 2c601490a9d3..300844f64a05 100644 --- a/src/main/runtime/runtime-layout-apply.test.ts +++ b/src/main/runtime/runtime-layout-apply.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { applyWindowLayoutCommand, type WindowLayoutCommand } from './runtime-layout-apply' +import { + applyWindowLayoutCommand, + type WindowLayoutCommand, + type LayoutCommandRunOptions +} from './runtime-layout-apply' import type { RuntimeStore } from './runtime-store-contract' import type { ExecutionHostId } from '../../shared/execution-host' import type { LayoutApplyOutcome } from '../../shared/workspace-layout/workspace-layout-command-types' @@ -52,6 +56,40 @@ describe('applyWindowLayoutCommand', () => { }) }) + it('forwards present close options intact to the owning Store', async () => { + const { store, applyLayoutCommand } = storeWith() + const run: LayoutCommandRunOptions = { + originalPresentation: { + identity: { + ...CLOSE, + worktreeId: CLOSE.workspace, + createdAt: 1, + generation: 0, + hasLeaf: true, + ptyId: 'pty-1', + incarnationId: 'i1' + }, + holders: [] + }, + admit: () => true, + live: { isBindingLive: () => true }, + stopBeforeWrite: () => true, + onEffects: vi.fn() + } + const tabClose: WindowLayoutCommand = { + type: 'closeTabs', + workspace: CLOSE.workspace, + tabIds: [CLOSE.tabId] + } + await applyWindowLayoutCommand(store, () => ['local'], CLOSE, undefined, run) + await applyWindowLayoutCommand(store, () => ['local'], tabClose, undefined, run) + expect(applyLayoutCommand.mock.calls).toEqual([ + [{ ...CLOSE, userPaneClose: true }, ['local'], run], + [{ ...tabClose, force: true }, ['local'], run] + ]) + expect(mocks.markAgentLaunchesClosedByUser).not.toHaveBeenCalled() + }) + it('applies other commands to the routed partition only', async () => { const { store, applyLayoutCommand } = storeWith() const rename = { ...CLOSE, type: 'renamePane', title: 'Pane' } as const diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 77a0c5c2bf64..7df6a146dedd 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -89,11 +89,10 @@ export async function applyWindowLayoutCommand( // that asks main to refuse pinned tabs (the CLI, new phones) gets that refusal, not the force. // Read before the close drops the tab's record. const terminalTabId = terminalIdOf(store, hostIds[0], command.workspace, command.tabIds[0]) - const reply = await store.applyLayoutCommand( - { ...command, force: command.refusePinned ? command.force === true : true }, - holders, - run - ) + const close = { ...command, force: command.refusePinned ? command.force === true : true } + const reply = await (run + ? store.applyLayoutCommand(close, holders, run) + : store.applyLayoutCommand(close, holders)) // Only a tab the close took: a refused (pinned) tab's launches still finish into it. if ( command.recordClose === 'user' && @@ -152,11 +151,10 @@ export async function applyWindowLayoutCommand( if (!run?.originalPresentation) { markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) } - return store.applyLayoutCommand( - { type: 'closePane', workspace, tabId, leafId, userPaneClose: true }, - holders, - run - ) + const close = { type: 'closePane' as const, workspace, tabId, leafId, userPaneClose: true } + return run + ? store.applyLayoutCommand(close, holders, run) + : store.applyLayoutCommand(close, holders) } /** diff --git a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts index 49ea9f944b98..b7a315b2db8c 100644 --- a/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts +++ b/src/main/runtime/session-tabs/explicit-terminal-close-stops.ts @@ -86,6 +86,7 @@ export abstract class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRun runtimeId: this.runtimeId, record, currentPty, + paneOfPty: (current) => this.paneOfPty(current), leaf, source, incarnation diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index 7c9b14e17ece..e31626cf86dc 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -63,7 +63,7 @@ export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCre : [this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ?? []].flat(), command, (move) => rekeyMovedPaneOwners(this, move), - { ...this.layoutCommandRun(command), ...options } + options ? { ...this.layoutCommandRun(command), ...options } : this.layoutCommandRun(command) ) // A window's sleep or wake lands after the hook statuses main accepted before it, as in the window. return await (ORCA_LAYOUT_OWNER_IS_MAIN && ['sleep', 'wake'].includes(command.type) diff --git a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts index 9ebe22a7623b..2fcfc9594c58 100644 --- a/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts +++ b/src/main/runtime/terminal/recovery/resolve-terminal-pane.ts @@ -89,9 +89,7 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith ) })) : [] - const routedHostId = - worktreeId && parsed ? this.getWorkspaceSessionHostIdForTab(worktreeId, parsed.tabId) : null - const rowIdentity = holders.find((holder) => holder.hostId === routedHostId)?.identity + const rowIdentity = holders[0]?.identity const executionPtyId = pty?.ptyId ?? record?.ptyId ?? leaf?.ptyId ?? null const retained = executionPtyId ? this.handleByPtyIncarnation.get(executionPtyId) : null const execution = this.getPtyExecutionHostMetadata(executionPtyId) @@ -109,6 +107,7 @@ export abstract class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWith runtimeId: this.runtimeId, record, currentPty: pty, + paneOfPty: (current) => this.paneOfPty(current), leaf, row: rowIdentity, address: { worktreeId, ...parsed }, diff --git a/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts index 79262a599e3c..25d085c5a94f 100644 --- a/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts +++ b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts @@ -1,3 +1,4 @@ +import type { PtyPaneOf } from './terminal/surface/pty-pane-query' import type { TerminalHandleRecord } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalSurfaceCloseIdentity } from '../../shared/terminal-tab-close' @@ -167,11 +168,12 @@ export function isOriginalTerminalHandle(args: { runtimeId: string record: TerminalHandleRecord | undefined currentPty: RuntimePtyWorktreeRecord | null | undefined + paneOfPty: PtyPaneOf leaf: RuntimeLeafRecord | undefined source: TerminalSurfaceCloseIdentity incarnation: string | undefined }): boolean { - const { runtimeId, record, currentPty, leaf, source, incarnation } = args + const { runtimeId, record, currentPty, paneOfPty, leaf, source, incarnation } = args return ( !!incarnation && !!source.ptyId && @@ -185,7 +187,7 @@ export function isOriginalTerminalHandle(args: { (!leaf || (leaf.worktreeId === source.worktreeId && (!leaf.ptyId || leaf.ptyId === source.ptyId))) && ((record.tabId === source.tabId && record.leafId === source.leafId) || - currentPty?.paneKey === `${source.tabId}:${source.leafId}`) + (!!currentPty && paneOfPty(currentPty).paneKey === `${source.tabId}:${source.leafId}`)) ) } @@ -194,6 +196,7 @@ export function captureAddressedTerminalPresentation(args: { runtimeId: string record: TerminalHandleRecord | undefined currentPty: RuntimePtyWorktreeRecord | null | undefined + paneOfPty: PtyPaneOf leaf: RuntimeLeafRecord | null | undefined row: TerminalSurfaceCloseIdentity | null | undefined address: { worktreeId: string; tabId: string; leafId: string } diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx index 8b127eac276e..f763a4416936 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx @@ -7,6 +7,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import { AiVaultVirtualRow } from './AiVaultVirtualRow' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' const cliSession: AiVaultSession = { id: 'local:codex:session-1:/tmp/session-1.jsonl', @@ -33,6 +34,7 @@ const cliSession: AiVaultSession = { afterEach(() => { cleanup() + setLocalRuntimeCapabilitiesForTests(null) }) function renderSession( @@ -196,8 +198,9 @@ describe('AiVaultVirtualRow Resume in New Terminal', () => { const owner = { sessionId: 'chat-1', workspaceId: 'worktree-1' } it.each(['claude', 'codex'] as const)( - 'offers the fork on a chat-owned %s row from both menus', + 'offers the fork on an unsupported host for a chat-owned %s row from both menus', async (agent) => { + setLocalRuntimeCapabilitiesForTests([]) const session: AiVaultSession = { ...cliSession, agent, structuredSession: owner } const { onResumeInNewCli, onResume } = renderSession(session) const user = userEvent.setup() From 3b79d19c31053f2efc8c4e6c492e6a92ba75022b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:30:07 -0700 Subject: [PATCH 060/207] test: verify parked terminal with fresh host inventory --- ...terminal-materialization-reconnect.spec.ts | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts index e436e86e55e3..8f22f50d1375 100644 --- a/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts +++ b/tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts @@ -117,7 +117,8 @@ async function hostSurfaceStatus( page: Page, environmentId: string, worktreeId: string, - parentTabId: string + parentTabId: string, + expectedLeafId?: string ): Promise { const snapshot = await callRuntime( page, @@ -126,21 +127,20 @@ async function hostSurfaceStatus( { worktree: `id:${worktreeId}` } ) const surface = snapshot.tabs.find( - (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId === parentTabId && + (expectedLeafId === undefined || candidate.leafId === expectedLeafId) ) return surface?.type === 'terminal' ? surface.status : null } -/** Park the fixture PTY so the host republishes its pane as a pending handle. - * Why: exact stop only confirms when it observes the fixture exit inside its verification - * window, and a loaded headless host can miss that window even though the PTY is going away. - * The precondition this journey needs is the parked surface, so retry until the host shows it. */ async function parkHostTerminal( page: Page, environmentId: string, worktreeId: string, parentTabId: string, - options: { expectedPtyId: string } + options: { expectedPtyId: string; original: RuntimeTerminalShow } ): Promise { let lastError = 'terminal.stopExact was never attempted' for (let attempt = 0; attempt < 10; attempt += 1) { @@ -149,6 +149,7 @@ async function parkHostTerminal( stopped: number stoppedPtyIds: string[] postStopVerified: boolean + postStopFailure?: string }>(page, environmentId, 'terminal.stopExact', { worktree: `id:${worktreeId}`, expectedPtyIds: [options.expectedPtyId], @@ -161,19 +162,41 @@ async function parkHostTerminal( if (stop) { const diagnostic: Record = { expectedPtyId: options.expectedPtyId, + expectedHostId: options.original.executionHostId ?? null, + expectedLeafId: options.original.leafId, elapsedMs: Date.now() - stopStartedAt, stop } + let parked = stop.postStopVerified === true if (stop.postStopVerified === false) { - diagnostic.inventory = await readFreshTerminalInventory(() => + const inventory = await readFreshTerminalInventory(() => callRuntime(page, environmentId, 'terminal.list', { - worktree: `id:${worktreeId}`, requireFreshPtyLiveness: true }) ).catch((error: unknown) => ({ error: String(error) })) - diagnostic.surface = await hostSurfaceStatus(page, environmentId, worktreeId, parentTabId) - .then((status) => ({ parentTabId, status })) + const surface = await hostSurfaceStatus( + page, + environmentId, + worktreeId, + parentTabId, + options.original.leafId + ) + .then((status) => ({ parentTabId, leafId: options.original.leafId, status })) .catch((error: unknown) => ({ parentTabId, error: String(error) })) + Object.assign(diagnostic, { inventory, surface }) + parked = + stop.postStopFailure === 'terminal_liveness_unavailable' && + inventory !== null && + 'terminals' in inventory && + inventory.truncated === false && + inventory.totalCount === inventory.terminals.length && + options.original.executionHostId !== undefined && + inventory.hostScope?.hostIds.includes(options.original.executionHostId) === true && + inventory.hostScope.omittedHostIds.length === 0 && + !inventory.terminals.some((terminal) => terminal.ptyId === options.expectedPtyId) && + options.original.tabId === parentTabId && + 'status' in surface && + surface.status === 'pending-handle' } await test .info() @@ -184,9 +207,10 @@ async function parkHostTerminal( .catch((error: unknown) => { diagnostic.attachmentError = String(error) }) - expect(stop.postStopVerified, JSON.stringify(diagnostic, null, 2)).toBe(true) - expect(stop.stopped).toBe(1) - expect(stop.stoppedPtyIds).toEqual([options.expectedPtyId]) + const evidence = JSON.stringify(diagnostic, null, 2) + expect(stop.stopped, evidence).toBe(1) + expect(stop.stoppedPtyIds, evidence).toEqual([options.expectedPtyId]) + expect(parked, evidence).toBe(true) return } // Why: the host reports a set mismatch once the target PTY is no longer live, which is the @@ -254,7 +278,8 @@ async function runMaterializationJourney( gate.holdEnd([terminal]) }, originalHandle) await parkHostTerminal(page, environmentId, worktreeId, created.tab.parentTabId, { - expectedPtyId: originalTerminal.terminal.ptyId + expectedPtyId: originalTerminal.terminal.ptyId, + original: originalTerminal.terminal }) // Why: the stale error must land on an already-parked surface, or the journey proves nothing // about materializing a pending handle. From 8e3d6d10ff33fc320d2a1dcab62fe2d2dc928883 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:38:54 -0700 Subject: [PATCH 061/207] fix: check close command and ordering witness types --- src/main/runtime/runtime-layout-apply.ts | 8 +++++++- .../useIpcEvents-agent-status-owner-ordering.test.ts | 8 +++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 7df6a146dedd..7ba31a15a6c7 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -151,7 +151,13 @@ export async function applyWindowLayoutCommand( if (!run?.originalPresentation) { markAgentLaunchesClosedByUser(workspace, { kind: 'pane', tabId, leafId }) } - const close = { type: 'closePane' as const, workspace, tabId, leafId, userPaneClose: true } + const close: CommandOf<'closePane'> = { + type: 'closePane', + workspace, + tabId, + leafId, + userPaneClose: true + } return run ? store.applyLayoutCommand(close, holders, run) : store.applyLayoutCommand(close, holders) diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts index c6931b221e9d..c9320ecf8918 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-owner-ordering.test.ts @@ -19,6 +19,7 @@ import { resolveAgentPaneAuthorityKey } from '../store/slices/agent-pane-authority' import { buildWindowApi } from './ipc-events-agent-status-window-test-fixtures' +import type { AgentStatusSetData } from './ipc-events-agent-status-store-test-fixtures' const holder = vi.hoisted(() => ({ getStore: (): ReturnType => { @@ -120,7 +121,7 @@ async function witness() { } }) ) - let onSet: ((data: AgentStatusIpcPayload) => void) | undefined + let onSet: ((data: AgentStatusSetData) => void) | undefined vi.stubGlobal( 'window', buildWindowApi({ @@ -141,8 +142,9 @@ async function witness() { leaf: string, providerSessionOnly = false, runtimeBacked = true - ): AgentStatusIpcPayload => ({ + ): AgentStatusIpcPayload & AgentStatusSetData => ({ paneKey: key(leaf), + connectionId: null, state: 'working', prompt: '', agentType: 'claude', @@ -164,7 +166,7 @@ async function witness() { phase = name capture() }, - emit: (data: AgentStatusIpcPayload) => { + emit: (data: ReturnType) => { if (!onSet) { throw new Error('Missing production onSet') } From 05e87b91760bc40c7a5584d0c4669c7734eaaa2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:08:24 -0700 Subject: [PATCH 062/207] Bound terminal conversation projection residency --- .../components/tab-bar/cli-chat-resume-run.ts | 55 ++- .../native-chat-cli-switch-run.test.ts | 1 + .../tab-bar/native-chat-cli-switch-run.ts | 19 +- ...seIpcEvents-agent-conversation-cap.test.ts | 376 ++++++++++++++++++ .../pending-conversation-surface-switches.ts | 45 +++ .../src/store/agent-conversation-retention.ts | 77 ++++ src/renderer/src/store/index.ts | 6 +- .../src/store/slices/store-test-helpers.ts | 8 +- 8 files changed, 558 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/hooks/useIpcEvents-agent-conversation-cap.test.ts create mode 100644 src/renderer/src/lib/pending-conversation-surface-switches.ts create mode 100644 src/renderer/src/store/agent-conversation-retention.ts diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts index 3d6788499a2d..92b72ea46974 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.ts @@ -19,6 +19,10 @@ import { import { adoptingAgentOnHost } from '../right-sidebar/ai-vault-session-resume-in-chat-launch' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { captureSourceTerminal } from './cli-chat-resume-terminal-capture' +import { + isCliChatResumePending, + pendingCliChatResumes as pending +} from '@/lib/pending-conversation-surface-switches' /** The execution host closes only the captured shell; refused or unknown closes leave it alone. */ async function retireSwitchedTerminalPane( @@ -35,7 +39,6 @@ async function retireSwitchedTerminalPane( }) } -const pending = new Map() const listeners = new Set<() => void>() const changed = () => listeners.forEach((listener) => listener()) const keyFor = (source: CliChatResumeSource) => @@ -52,7 +55,7 @@ const subscribe = (listener: () => void) => { export function useCliChatResumePending(source: CliChatResumeSource | null): boolean { return useSyncExternalStore( subscribe, - () => !!source && pending.has(keyFor(source)), + () => !!source && isCliChatResumePending(keyFor(source)), () => false ) } @@ -61,7 +64,7 @@ export function useCliChatResumePending(source: CliChatResumeSource | null): boo * that already holds it, revealed. The terminal pane closes once the chat has it. */ export async function runCliChatResume(source: CliChatResumeSource): Promise { const key = keyFor(source) - if (pending.has(key)) { + if (isCliChatResumePending(key)) { return } const target = structuredAgentSessionTargetForHost(source.executionHostId) @@ -90,24 +93,15 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise { - if (pending.get(key) === controller) { - pending.delete(key) + const operation = pending.get(controller) + if (operation?.key === key) { + operation.key = null changed() } } - pending.set(key, controller) - changed() - const sourceIncarnation = captureSourceTerminal(source) - const stopSource = useAppStore.subscribe(() => { - if (!isCurrent()) { - controller.abort() - release() - } - }) - const timer = setTimeout( - () => controller.abort(new Error('Conversation preparation timed out.')), - 30_000 - ) + pending.set(controller, { key, source }) + let stopSource = () => {} + let timer: ReturnType | undefined let dispatched = false const handoff = () => { if (dispatched) { @@ -123,6 +117,18 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise { + if (!isCurrent()) { + controller.abort() + release() + } + }) + timer = setTimeout( + () => controller.abort(new Error('Conversation preparation timed out.')), + 30_000 + ) let retireSource = false try { await waitForPromiseWithSignal(sourceIncarnation, controller.signal) @@ -191,14 +197,14 @@ export async function runCliChatResume(source: CliChatResumeSource): Promise ({ + agentConversationsByPaneKey: state.agentConversationsByPaneKey + })) + } } } diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts index 9c43406f5bba..71a086d74d0c 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/store', () => ({ useAppStore: { + setState: vi.fn(), getState: () => ({ settings: {}, closeUnifiedTab: mocks.closeUnifiedTab }) } })) diff --git a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts index ac599e2c9e17..57f61bedbb23 100644 --- a/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts +++ b/src/renderer/src/components/tab-bar/native-chat-cli-switch-run.ts @@ -15,8 +15,8 @@ import { AiVaultChatForkSourceSchema } from '../../../../shared/ai-vault-chat-fo import type { AiVaultAgent } from '../../../../shared/ai-vault-types' import { agentLabel } from '../../../../shared/ai-vault-session-filters' import { parseExecutionHostId } from '../../../../shared/execution-host' +import { pendingNativeChatCliSwitches as pending } from '@/lib/pending-conversation-surface-switches' -const pending = new Set() const listeners = new Set<() => void>() const changed = () => listeners.forEach((listener) => listener()) @@ -69,9 +69,9 @@ export async function runNativeChatCliSwitch(args: { if (pending.has(args.sessionId) || !target || !hostId) { return } - pending.add(args.sessionId) - changed() + pending.set(args.sessionId, args) try { + changed() // Fail closed: a host that doesn't advertise the switch never receives it. if (!(await supportsInPlaceSwitch(target, args.agent))) { throw new Error(nativeChatCliSwitchFailureText(null, args.agent)) @@ -106,7 +106,16 @@ export async function runNativeChatCliSwitch(args: { toast.error(nativeChatCliSwitchFailureText(error, args.agent)) } finally { pending.delete(args.sessionId) - changed() - args.onComplete?.() + try { + useAppStore.setState((state) => ({ + agentConversationsByPaneKey: state.agentConversationsByPaneKey + })) + } finally { + try { + changed() + } finally { + args.onComplete?.() + } + } } } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-conversation-cap.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-conversation-cap.test.ts new file mode 100644 index 000000000000..b06cf2a53bc0 --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-agent-conversation-cap.test.ts @@ -0,0 +1,376 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { UseBoundStore, StoreApi } from 'zustand' +import type { AppState } from '../store/types' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentStatusSetData } from './ipc-events-agent-status-store-test-fixtures' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '../store/slices/store-test-helpers' +import { buildWindowApi } from './ipc-events-agent-status-window-test-fixtures' +import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree' +import { captureTerminalSurfaceIdentity } from '../../../shared/terminal-tab-close' +import { adoptsCliConversations } from '../../../shared/structured-agent-cli-conversation-adoption' +import { + collectAgentPaneConversations, + resolvePaneLastAgentConversation +} from '@/lib/ai-vault-tab-title-requests' +import { + transferAgentPaneAuthorityAlias, + resetAgentPaneAuthorityAliasesForTests +} from '../store/slices/agent-pane-authority' +import * as capacity from '../store/slices/agent-status-capacity-eviction' +import * as switches from '../lib/pending-conversation-surface-switches' + +const mocks = vi.hoisted(() => { + const store: { current?: UseBoundStore> } = {} + return { + store, + support: vi.fn(async () => true), + call: vi.fn(), + rpc: vi.fn(), + launch: vi.fn(), + toast: vi.fn() + } +}) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store.current + } +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + supportsInPlaceSwitch: mocks.support, + supportsStructuredAgentSessionCliSource: async () => true, + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.rpc })) +vi.mock('@/lib/launch-ai-vault-session', () => ({ launchAiVaultSessionInNewTab: mocks.launch })) +vi.mock('@/runtime/local-runtime-capabilities', async (original) => ({ + ...(await original>()), + readLocalRuntimeCapabilitiesOrUnknown: () => [ + 'agent-session.structured.v1', + 'agent-session.structured.agents.v1', + 'agent-session.structured.switch.v1:codex' + ] +})) +vi.mock('@/runtime/host-structured-agents', async (original) => ({ + ...(await original>()), + readHostStructuredAgents: () => [{ agent: 'codex', capabilities: {} }] +})) +vi.mock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) +const cleanup: (() => void)[] = [] +const running: Promise[] = [] +afterEach(async () => { + cleanup + .splice(0) + .toReversed() + .forEach((close) => close()) + await Promise.allSettled(running.splice(0)) + resetAgentPaneAuthorityAliasesForTests() + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() +}) +const leaf = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` +const pane = (tab: string, n = 1) => `${tab}:${leaf(n)}` +const layout = (n: number) => ({ + root: { type: 'leaf' as const, leafId: leaf(n) }, + activeLeafId: leaf(n), + expandedLeafId: null +}) +it.each([600, 6000])( + 'bounds real publications across held switches and retries at %i callbacks', + async (n) => { + vi.useFakeTimers() + vi.setSystemTime(1_800_000_000_000) + vi.clearAllMocks() + const store = createTestStore() + mocks.store.current = store + const tabs = ['cli', 'retry', 'root-b', 'root-c', 'rootless'].map((id) => + makeTab({ id, worktreeId: 'wt', createdAt: 1 }) + ) + const roots = [pane('retry'), pane('root-b'), pane('root-c')] + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt', repoId: TEST_REPO.id })] }, + tabsByWorktree: { wt: tabs }, + terminalLayoutsByTabId: { + ...Object.fromEntries( + tabs.filter((tab) => tab.id !== 'rootless').map((tab) => [tab.id, layout(1)]) + ), + rootless: { root: null, activeLeafId: null, expandedLeafId: null } + } + }) + let onSet: ((data: AgentStatusSetData) => void) | undefined + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (callback) => { + onSet = callback + return () => {} + } + }) + ) + const { registerAgentStatusIpcBridge } = await import('./ipc-events/agent-status-ipc-bridge') + const { runCliChatResume } = await import('../components/tab-bar/cli-chat-resume-run') + const { resolveCliChatResumeSource } = + await import('../components/tab-bar/cli-chat-resume-source') + const { runNativeChatCliSwitch } = + await import('../components/tab-bar/native-chat-cli-switch-run') + const unsubs: (() => void)[] = [] + const bridge = registerAgentStatusIpcBridge(unsubs) + cleanup.push(bridge.disposeAsyncState, bridge.unsubscribeStore, ...unsubs) + await Promise.resolve() + let callbacks = 0 + const emit = async (key: string, id = key) => { + const data: AgentStatusIpcPayload & AgentStatusSetData = { + paneKey: key, + state: 'done', + prompt: '', + agentType: 'codex', + providerSessionOnly: true, + worktreeId: 'wt', + terminalHandle: `term-${key}`, + providerSession: { key: 'session_id', id }, + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + if (!onSet) { + throw new Error('Missing bridge callback') + } + onSet(data) + callbacks += 1 + await vi.advanceTimersByTimeAsync(34) + expect(store.getState().agentConversationsByPaneKey[key]?.providerSession.id).toBe(id) + store.getState().clearSleepingAgentSession(key) + } + for (const key of [ + pane('cli'), + ...roots, + pane('cli', 2), + pane('retry', 2), + pane('native'), + pane('alias-old'), + pane('alias-new') + ]) { + await emit(key, key === pane('cli') || key === pane('retry') ? 'conversation' : key) + } + const first = resolveCliChatResumeSource(store.getState(), { + tab: { id: 'cli', worktreeId: 'wt' }, + paneKey: pane('cli') + })! + const retry = resolveCliChatResumeSource(store.getState(), { + tab: { id: 'retry', worktreeId: 'wt' }, + paneKey: pane('retry') + })! + expect(first.providerSessionId).toBe('conversation') + expect(retry.providerSessionId).toBe('conversation') + mocks.rpc.mockImplementation(async (_target, method, args) => { + expect(method).toBe('terminal.resolvePane') + const tabId = args.paneKey.split(':')[0] + const identity = captureTerminalSurfaceIdentity(store.getState(), 'wt', tabId, leaf(1))! + return { + terminal: { + handle: `handle-${tabId}`, + ptyId: identity.ptyId, + incarnationId: 'incarnation', + presentation: { identity: { ...identity, incarnationId: 'incarnation' }, holders: [] } + } + } + }) + const refusal = { + ok: false as const, + refusal: { code: 'agent_session_operation_invalid', message: 'Not admitted' } + } + const oldAnswer = Promise.withResolvers() + const newAnswer = Promise.withResolvers() + const nativeAnswer = Promise.withResolvers<{ source: unknown }>() + const nativeLaunch = Promise.withResolvers<{ status: 'created' }>() + cleanup.push(() => { + oldAnswer.resolve(refusal) + newAnswer.resolve(refusal) + nativeAnswer.resolve({ source: null }) + nativeLaunch.resolve({ status: 'created' }) + }) + const returns = [oldAnswer, newAnswer] + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.switchToChat' ? returns.shift()!.promise : nativeAnswer.promise + ) + mocks.launch.mockReturnValue({ tabId: null, runtimeLaunch: nativeLaunch.promise }) + const ownerRefusal = agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere', + owner: { sessionId: 'existing', workspaceId: 'wt' } + }) + mocks.support.mockRejectedValueOnce(ownerRefusal) + const old = runCliChatResume(first) + running.push(old) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + const native = runNativeChatCliSwitch({ + sessionId: 'native-session', + workspaceId: 'wt', + executionHostId: 'local', + agent: 'codex', + chatTabId: 'native' + }) + running.push(native) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + transferAgentPaneAuthorityAlias({ fromPaneKey: first.paneKey, toPaneKey: pane('alias-old') }) + const { cli: _removed, ...withoutCli } = store.getState().terminalLayoutsByTabId + store.setState({ + terminalLayoutsByTabId: withoutCli, + tabsByWorktree: { wt: tabs.filter((tab) => tab.id !== 'cli') } + }) + mocks.support.mockRejectedValueOnce(ownerRefusal) + const newer = runCliChatResume(retry) + running.push(newer) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + transferAgentPaneAuthorityAlias({ fromPaneKey: retry.paneKey, toPaneKey: pane('alias-new') }) + let protectedKeys = [ + first.paneKey, + pane('cli', 2), + retry.paneKey, + pane('retry', 2), + pane('alias-old'), + pane('alias-new'), + pane('native') + ] + let publications = 0 + const assertBound = () => { + const state = store.getState() + const rooted = new Set( + Object.entries(state.terminalLayoutsByTabId).flatMap(([tab, tree]) => + collectLeafIds(tree.root).map((id) => `${tab}:${id}`) + ) + ) + const keys = Object.keys(state.agentConversationsByPaneKey) + expect( + keys.filter((key) => !rooted.has(key) && !protectedKeys.includes(key)).length + ).toBeLessThanOrEqual(500) + expect(keys.length).toBeLessThanOrEqual( + rooted.size + 500 + protectedKeys.filter((key) => !rooted.has(key)).length + ) + for (const key of [...roots, ...protectedKeys]) { + expect(state.agentConversationsByPaneKey[key]).toBeDefined() + } + publications += 1 + } + cleanup.push(store.subscribe(assertBound)) + const batches = vi.spyOn(store.getState(), 'transactAgentStatuses') + const records = vi.spyOn(store.getState(), 'recordAgentProviderSession') + let classifiedKeys = 0 + let protectionEntries = 0 + const classify = capacity.classifyPaneKeyLiveness + const protect = switches.protectedConversationPaneKeys + const classifier = vi.spyOn(capacity, 'classifyPaneKeyLiveness').mockImplementation((state) => { + const lookup = classify(state) + return (key) => { + classifiedKeys += 1 + return lookup(key) + } + }) + vi.spyOn(switches, 'protectedConversationPaneKeys').mockImplementation((entries) => { + protectionEntries += Object.keys(entries).length + return protect(entries) + }) + const deliveredBefore = callbacks + for (let index = 0; index < n; index += 1) { + await emit(pane(index % 2 ? 'unknown' : 'rootless', index + 10)) + } + expect(callbacks - deliveredBefore).toBe(n) + expect(records).toHaveBeenCalledTimes(n) + expect(batches).toHaveBeenCalledTimes(0) + expect(publications).toBeGreaterThanOrEqual(n) + expect(publications).toBeLessThanOrEqual(3 * n) + expect(classifier.mock.calls.length).toBeLessThanOrEqual(publications) + expect(classifiedKeys).toBeLessThanOrEqual(510 * publications) + expect(Object.keys(store.getState().agentConversationsByPaneKey)).toHaveLength(509) + const oldest = pane(n % 2 ? 'unknown' : 'rootless', n - 500 + 10) + await emit(oldest, 'replacement-does-not-refresh-residency') + store.setState( + { + ...store.getState(), + agentConversationsByPaneKey: Object.fromEntries( + Object.entries(store.getState().agentConversationsByPaneKey).toReversed() + ) + }, + true + ) + await emit(pane('unknown', n + 10)) + expect(store.getState().agentConversationsByPaneKey[oldest]).toBeUndefined() + let nestedWrites = 0 + const stopNested = store.subscribe(() => { + if (nestedWrites++ === 0) { + store.setState((state) => ({ + agentConversationsByPaneKey: state.agentConversationsByPaneKey + })) + } + }) + store.setState({ activeWorktreeId: 'wt' }) + stopNested() + expect(nestedWrites).toBe(2) + protectedKeys = [retry.paneKey, pane('retry', 2), pane('alias-new'), pane('native')] + oldAnswer.resolve(refusal) + await old + expect(Object.keys(store.getState().agentConversationsByPaneKey)).toHaveLength(506) + expect(store.getState().agentConversationsByPaneKey[pane('alias-new')]).toBeDefined() + expect(mocks.call).toHaveBeenCalledTimes(3) + protectedKeys = [pane('native')] + newAnswer.resolve(refusal) + await newer + expect(Object.keys(store.getState().agentConversationsByPaneKey)).toHaveLength(504) + nativeAnswer.resolve({ + source: { + agent: 'codex', + sourceAgent: 'codex', + sessionId: 'native-provider', + filePath: '/repo/rollout.jsonl', + cwd: '/repo', + codexHome: null, + executionHostPlatform: 'darwin' + } + }) + await vi.waitFor(() => expect(mocks.launch).toHaveBeenCalledOnce()) + await emit(pane('pressure-after-native-receipt', n + 11)) + protectedKeys = [] + nativeLaunch.resolve({ status: 'created' }) + await native + expect(Object.keys(store.getState().agentConversationsByPaneKey)).toHaveLength(503) + for (const key of roots) { + expect( + resolvePaneLastAgentConversation( + store.getState(), + { worktreeId: 'wt', paneKey: key }, + adoptsCliConversations + )?.providerSession.id + ).toBe(key === pane('retry') ? 'conversation' : key) + } + expect( + collectAgentPaneConversations(store.getState(), adoptsCliConversations, { allPanes: true }) + ).toEqual(expect.arrayContaining(roots.map((paneKey) => expect.objectContaining({ paneKey })))) + expect(mocks.rpc.mock.calls.every(([, method]) => method !== 'terminal.close')).toBe(true) + expect(classifier.mock.calls.length).toBeLessThanOrEqual(publications) + expect(classifiedKeys).toBeLessThanOrEqual(510 * publications) + expect(publications).toBeLessThanOrEqual(3 * n + 64) + console.info('CAP_COUNTS', { + n, + callbacks, + batches: batches.mock.calls.length, + records: records.mock.calls.length, + publications, + classifierBuilds: classifier.mock.calls.length, + classifiedKeys, + protectionEntries, + retained: Object.keys(store.getState().agentConversationsByPaneKey).length + }) + }, + 120_000 +) diff --git a/src/renderer/src/lib/pending-conversation-surface-switches.ts b/src/renderer/src/lib/pending-conversation-surface-switches.ts new file mode 100644 index 000000000000..810867f167c9 --- /dev/null +++ b/src/renderer/src/lib/pending-conversation-surface-switches.ts @@ -0,0 +1,45 @@ +import type { CliChatResumeSource } from '@/components/tab-bar/cli-chat-resume-source' +import type { AgentPaneConversationIdentity } from '@/store/slices/agent-pane-conversation' +import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import { getTabIdFromPaneKey } from '@/store/slices/agent-status-pane-key-tab-binding' + +export const pendingCliChatResumes = new Map< + AbortController, + { key: string | null; source: CliChatResumeSource } +>() +export const pendingNativeChatCliSwitches = new Map< + string, + { chatTabId: string | null; workspaceId: string; executionHostId: string } +>() + +export function isCliChatResumePending(key: string): boolean { + return Array.from(pendingCliChatResumes.values()).some((operation) => operation.key === key) +} + +export function protectedConversationPaneKeys( + entries: Record +): Set { + const protectedKeys = new Set() + for (const { source } of pendingCliChatResumes.values()) { + protectedKeys.add(source.paneKey) + protectedKeys.add(resolveAgentPaneAuthorityKey(source.paneKey)) + } + const sources = [ + ...Array.from(pendingCliChatResumes.values(), ({ source }) => source), + ...pendingNativeChatCliSwitches.values() + ] + for (const [key, entry] of Object.entries(entries)) { + for (const source of sources) { + const tabId = 'tabId' in source ? source.tabId : source.chatTabId + if ( + tabId !== null && + getTabIdFromPaneKey(key) === tabId && + entry.worktreeId === source.workspaceId && + entry.executionHostId === source.executionHostId + ) { + protectedKeys.add(key) + } + } + } + return protectedKeys +} diff --git a/src/renderer/src/store/agent-conversation-retention.ts b/src/renderer/src/store/agent-conversation-retention.ts new file mode 100644 index 000000000000..4dd0d46633c7 --- /dev/null +++ b/src/renderer/src/store/agent-conversation-retention.ts @@ -0,0 +1,77 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from './types' +import type { StoreSetArgs } from './react-commit-cascade-write-probe' +import { protectedConversationPaneKeys } from '@/lib/pending-conversation-surface-switches' +import { + classifyPaneKeyLiveness, + MAX_LIVE_AGENT_STATUSES +} from './slices/agent-status-capacity-eviction' + +type Conversations = AppState['agentConversationsByPaneKey'] + +function cappedConversations(state: AppState, previous: Conversations): Conversations { + const entries = state.agentConversationsByPaneKey + const incoming = Object.keys(entries) + const keys = [ + ...Object.keys(previous).filter((key) => Object.hasOwn(entries, key)), + ...incoming.filter((key) => !Object.hasOwn(previous, key)) + ] + let evicted: string[] = [] + if (keys.length > MAX_LIVE_AGENT_STATUSES) { + const classify = classifyPaneKeyLiveness(state) + const protectedKeys = protectedConversationPaneKeys(entries) + const eligible = keys.filter((key) => classify(key) !== 'live' && !protectedKeys.has(key)) + evicted = eligible.slice(0, Math.max(0, eligible.length - MAX_LIVE_AGENT_STATUSES)) + } + if (evicted.length === 0 && keys.every((key, index) => key === incoming[index])) { + return entries + } + const removed = new Set(evicted) + return Object.fromEntries( + keys.filter((key) => !removed.has(key)).map((key) => [key, entries[key]]) + ) +} + +export function withAgentConversationRetention( + createState: StateCreator +): StateCreator { + return (set, get, api) => { + const wrap = + (write: typeof set): typeof set => + (...args: StoreSetArgs): void => { + if (args[1] === true) { + const [partial] = args + write((state) => { + const requested = typeof partial === 'function' ? partial(state) : partial + const entries = cappedConversations(requested, get().agentConversationsByPaneKey) + return entries === requested.agentConversationsByPaneKey + ? requested + : { ...requested, agentConversationsByPaneKey: entries } + }, true) + } else { + const [partial] = args + write((state) => { + const requested = typeof partial === 'function' ? partial(state) : partial + if ( + !Object.hasOwn(requested, 'agentConversationsByPaneKey') && + !Object.hasOwn(requested, 'terminalLayoutsByTabId') + ) { + return requested + } + const current = get() + const candidate = { ...current, ...requested } + const entries = cappedConversations(candidate, current.agentConversationsByPaneKey) + return entries === candidate.agentConversationsByPaneKey + ? requested + : { ...requested, agentConversationsByPaneKey: entries } + }) + } + } + api.setState = wrap(api.setState) + const initial = createState(wrap(set), get, api) + const entries = cappedConversations(initial, {}) + return entries === initial.agentConversationsByPaneKey + ? initial + : { ...initial, agentConversationsByPaneKey: entries } + } +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f9b0498a146d..5c0d1ada8579 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -63,6 +63,7 @@ import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../shared/sidebar-inputs-swit import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' import { withStoreIdentityChurnProbe } from './store-identity-churn-probe' import { withEditorBufferRecords } from './editor-buffer-record-lifecycle' +import { withAgentConversationRetention } from './agent-conversation-retention' import { attachEditorBufferIdentityEvents } from './editor-buffer-identity-events' import { registerRendererMemoryProfileContributor, @@ -79,11 +80,14 @@ const withDevelopmentStoreProbes = (createState: StateCreator) ? withStoreIdentityChurnProbe(createState) : createState +const withAppStoreWritePolicies = (createState: StateCreator) => + withAgentConversationRetention(withDevelopmentStoreProbes(createState)) + // Why buffer records outermost: the probes must see each caller's own partial and frame. // Why e2e checks the buffer invariant too: a writer that skips the record must fail there, not drift. export const useAppStore = create()( withEditorBufferRecords( - withDevelopmentStoreProbes( + withAppStoreWritePolicies( withReactCommitCascadeWriteProbe((...a) => { // Why first: settling inside the bump's own notify means hook subscribers never see it unsettled. // Switch point: the stamp-sorted list reads no settled epoch. diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index c299e7eccfa5..691ed0c4c5d6 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -1,4 +1,4 @@ -import { create } from 'zustand' +import { create, type StateCreator } from 'zustand' import type { AppState } from '../types' import type { Worktree } from '../../../../shared/worktree/types' import { createRepoSlice } from './repos' @@ -54,6 +54,7 @@ import { createStructuredChatSleptAtSlice } from './structured-chat-slept-at' import { createWorkspaceAttentionSlice } from './workspace-attention/workspace-attention-slice' import '@/i18n/i18n' import { withEditorBufferRecords } from '../editor-buffer-record-lifecycle' +import { withAgentConversationRetention } from '../agent-conversation-retention' import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' export { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' export { @@ -64,10 +65,13 @@ export { makeTabGroup } from './store-session-test-harness' +const withTestStorePolicies = (creator: StateCreator, check: boolean) => + withEditorBufferRecords(withAgentConversationRetention(creator), check) + export function createTestStore() { // Why the record middleware: views of one file share a record only through it, as in the app. return create()( - withEditorBufferRecords( + withTestStorePolicies( (...a) => ({ ...createRepoSlice(...a), ...createSparsePresetsSlice(...a), From 10ee6df13bf5e48fdb3ee74d27114425d06e0ba0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:46:38 -0700 Subject: [PATCH 063/207] Avoid repeated conversation projection and placement scans Copy conversation associations once per transaction and reuse call-local tab membership. Qualify component work through actual bridge, placement, retirement and recorded-output contracts; classify the existing own-property presence read correctly. --- .../scripts/check-layout-writer-ratchets.mjs | 18 +- .../check-layout-writer-ratchets.test.mjs | 11 ++ ...ile-session-tab-application-launch.test.ts | 35 +++- .../mobile-session-switch-placement.test.ts | 129 ++++++++++---- ...ent-session-windowless-layout-host.test.ts | 160 +++++++++++++++++- ...ucturedSession.history-read-state.test.tsx | 56 +++++- ...ents-agent-status-batch-projection.test.ts | 124 +++++++++++++- ...-runtime-terminal-switch-placement.test.ts | 78 ++++++--- .../store/slices/agent-pane-conversation.ts | 21 ++- .../store/slices/agent-status-live-actions.ts | 3 +- .../store/slices/agent-status-live-reducer.ts | 5 +- .../agent-status-provider-session-actions.ts | 5 +- .../agent-status-provider-session.test.ts | 39 +++++ .../src/store/slices/agent-status-runtime.ts | 18 ++ ...session-tab-placement-work.test-fixture.ts | 54 ++++++ src/shared/session-tab-placement.ts | 5 +- 16 files changed, 680 insertions(+), 81 deletions(-) create mode 100644 src/shared/session-tab-placement-work.test-fixture.ts diff --git a/config/scripts/check-layout-writer-ratchets.mjs b/config/scripts/check-layout-writer-ratchets.mjs index 73c338a320e1..de60e7d72471 100644 --- a/config/scripts/check-layout-writer-ratchets.mjs +++ b/config/scripts/check-layout-writer-ratchets.mjs @@ -328,6 +328,21 @@ function isSameNameCopy(property, name) { return ts.isPropertyAccessExpression(value) && value.name.text === name } +/** Only the property-name argument of the standard presence read is a read. */ +function isOwnPresenceRead(node) { + const call = node.parent + if (!ts.isCallExpression(call) || call.arguments[1] !== node) { + return false + } + const callee = call.expression + return ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === 'hasOwn' && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' + ) +} + /** Layout writes in one file under one ratchet's rules. */ export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') { const names = [...ratchet.writerNames, ...ratchet.assignedFields] @@ -389,7 +404,8 @@ export function countLayoutWrites(sourceText, ratchet, fileName = 'file.tsx') { !(ts.isPropertyAssignment(node.parent) && node.parent.name === node) && !ts.isComputedPropertyName(node.parent) && !(ts.isElementAccessExpression(node.parent) && node.parent.argumentExpression === node) && - !ts.isLiteralTypeNode(node.parent) + !ts.isLiteralTypeNode(node.parent) && + !isOwnPresenceRead(node) ) { count += 1 } diff --git a/config/scripts/check-layout-writer-ratchets.test.mjs b/config/scripts/check-layout-writer-ratchets.test.mjs index 126e33260120..9c5be29cfbbd 100644 --- a/config/scripts/check-layout-writer-ratchets.test.mjs +++ b/config/scripts/check-layout-writer-ratchets.test.mjs @@ -52,6 +52,17 @@ describe('renderer layout writes', () => { expect(countLayoutWrites(src, rendererRatchet, 'a.tsx')).toBe(3) }) + it('reads Object.hasOwn property names while retaining computed and direct writes', () => { + expect( + countLayoutWrites("Object.hasOwn(requested, 'terminalLayoutsByTabId')", rendererRatchet) + ).toBe(0) + const writes = "patch.terminalLayoutsByTabId = next; patch['terminalLayoutsByTabId'] = next" + expect(countLayoutWrites(writes, rendererRatchet)).toBe(2) + const other = + "Object.hasOwn('terminalLayoutsByTabId', key); other.hasOwn(state, 'terminalLayoutsByTabId'); Object.other(state, 'terminalLayoutsByTabId')" + expect(countLayoutWrites(other, rendererRatchet)).toBe(3) + }) + it('allows only the frozen mirror writers', () => { expect(rendererRatchet.allowed('src/renderer/src/store/workspace-layout-cache.ts')).toBe(true) expect( diff --git a/mobile/src/session/use-mobile-session-tab-application-launch.test.ts b/mobile/src/session/use-mobile-session-tab-application-launch.test.ts index 9cfb0ad2e473..d6fea1e5d4e4 100644 --- a/mobile/src/session/use-mobile-session-tab-application-launch.test.ts +++ b/mobile/src/session/use-mobile-session-tab-application-launch.test.ts @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, type SetStateAction } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' @@ -212,3 +212,36 @@ describe('landing on a launched tab', () => { expect(state.creatingTerminalRef.current).toBeNull() }) }) + +it('applies desktop retirement and replacement in host order without a phone activation', () => { + const { state, sendRequest } = scope(null) + let published: MobileSessionTab[] = [] + state.setSessionTabs.mockImplementation((next: SetStateAction) => { + published = typeof next === 'function' ? next(published) : next + }) + state.selectedSessionTabIdRef.current = 'C' + state.activeSessionTabIdRef.current = 'C' + state.activeHandleRef.current = null + const apply = mount(state) + const a = terminalTab('A', 'term_A') + const b = terminalTab('B', 'term_B') + const c = { ...chatTab('C', 'conversation'), agent: 'codex' as const, isActive: true } + const t = terminalTab('T', 'term_T', true) + apply(snapshot([a, c, b])) + expect(published.map((tab) => tab.id)).toEqual(['A', 'C', 'B']) + expect(state.activeSessionTabIdRef.current).toBe('C') + apply(snapshot([{ ...a, isActive: true }, b])) + expect(published.map((tab) => tab.id)).toEqual(['A', 'B']) + expect(state.activeSessionTabIdRef.current).toBe('A') + apply(snapshot([a, t, b])) + expect(published.map((tab) => tab.id)).toEqual(['A', 'T', 'B']) + expect(published.find((tab) => tab.id === state.activeSessionTabIdRef.current)).toEqual(t) + expect(state.setActiveSessionTabId).toHaveBeenLastCalledWith('T') + expect(state.activeSessionTabTypeRef.current).toBe('terminal') + expect(state.setActiveHandle).toHaveBeenLastCalledWith('term_T') + expect(state.activeHandleRef.current).toBe('term_T') + expect(state.subscribeToTerminal).toHaveBeenLastCalledWith('term_T') + expect(state.unsubscribeTerminal).toHaveBeenCalledWith('term_A') + expect(state.selectedSessionTabIdRef.current).toBe('C') + expect(activations(sendRequest)).toEqual([]) +}) diff --git a/src/main/runtime/mobile-session-switch-placement.test.ts b/src/main/runtime/mobile-session-switch-placement.test.ts index 38a68850b2b8..f35f9db8a463 100644 --- a/src/main/runtime/mobile-session-switch-placement.test.ts +++ b/src/main/runtime/mobile-session-switch-placement.test.ts @@ -1,49 +1,101 @@ -import { expect, it, vi } from 'vitest' -import type { - RuntimeMobileSessionTabsSnapshot, - RuntimeMobileSessionSnapshotTab -} from '../../shared/runtime-types' +import { withRuntimePaneAdmission } from './runtime-durable-store-fixture' +import { addWorkspace } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { withChatTabChanges } from './agent-session-tab-layout' +import { observeSessionTabPlacementWork } from '../../shared/session-tab-placement-work.test-fixture' +import * as profile from './profile-session-store.test-fixture' +import { afterEach, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') await import('./orca-runtime-test-lifecycle.spec') const { store, TEST_WORKTREE_ID: WT } = await import('./orca-runtime-test-fixtures.spec') +afterEach(() => vi.restoreAllMocks()) + it.each([ - { only: false, pinned: false, retire: true }, - { only: true, pinned: false, retire: true }, - { only: false, pinned: true, retire: true }, - { only: false, pinned: true, retire: false } + { only: false, pinned: false, retire: true, n: 0 }, + { only: true, pinned: false, retire: true, n: 0 }, + { only: false, pinned: true, retire: true, n: 0 }, + { only: false, pinned: true, retire: false, n: 0 }, + ...[32, 320].map((n) => ({ only: false, pinned: false, retire: true, n })) ])( - 'preserves current headless placement and pin partitions ($only/$pinned/$retire)', - async ({ only, pinned, retire }) => { - const runtime = new OrcaRuntimeService(store) - const chat = (id: string): RuntimeMobileSessionSnapshotTab => ({ - type: 'agent-session', + 'preserves current headless placement and pin partitions ($only/$pinned/$retire/$n)', + async ({ only, pinned, retire, n }) => { + const runtimeStore: typeof store & Parameters[0] = { ...store } + profile.announceSessionWrites(runtimeStore) + const session = profile.asProfileSessionStore(runtimeStore, 'local') + withRuntimePaneAdmission(runtimeStore) + const runtime = new OrcaRuntimeService(runtimeStore) + const [A, C, B, U, D] = ['A', 'C', 'B', 'U', 'D'].map((id) => `agent-session:${id}`) + const chat = (id: string) => ({ + type: 'agent-session' as const, id, - sessionId: id, - agent: 'codex', + sessionId: id.slice(14), + agent: 'codex' as const, title: id, isActive: false, - isPinned: pinned && ['A', 'C', 'B'].includes(id) + isPinned: pinned && [A, C, B].includes(id) }) - const source = only ? ['C'] : pinned ? ['A', 'C', 'B', 'U'] : ['A', 'C', 'B'] - const initial: RuntimeMobileSessionTabsSnapshot = { + const source = n + ? Array.from({ length: n }, (_, i) => (i === n / 2 ? C : `agent-session:old-${i}`)) + : only + ? [C] + : pinned + ? [A, C, B, U] + : [A, C, B] + const current = Array.from({ length: n }, (_, i) => `agent-session:current-${i}`) + const work = observeSessionTabPlacementWork() + const snapshot = (left = source, snapshotVersion = 1): RuntimeMobileSessionTabsSnapshot => ({ worktree: WT, publicationEpoch: 'headless:test', - snapshotVersion: 1, + snapshotVersion, activeGroupId: 'R', - activeTabId: 'D', + activeTabId: D, activeTabType: 'agent-session', - tabs: [...source, 'D'].map(chat), + tabs: [...left, D].map(chat), tabGroups: [ - { id: 'L', activeTabId: 'C', tabOrder: source }, - { id: 'R', activeTabId: 'D', tabOrder: ['D'] } + { id: 'L', activeTabId: left.includes(C) ? C : left[0], tabOrder: left }, + { id: 'R', activeTabId: D, tabOrder: [D] } ] - } - runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [initial] }) + }) + const savedChat = (id: string) => ({ + id, + entityId: chat(id).sessionId, + kind: 'agent-session' as const, + entry: { agentSessionAgent: 'codex' as const, isPinned: chat(id).isPinned } + }) + const saved = addWorkspace(session.get('local'), WT, [ + { id: 'L', tabs: source.map(savedChat), activeTabId: C }, + { id: 'R', tabs: [savedChat(D)] } + ]) + saved.activeGroupIdByWorktree![WT] = 'R' + session.set(saved, 'local') + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [snapshot()] }) runtime.setPtyController({ spawn: vi.fn(async () => { - if (retire) { - expect(runtime.retireStructuredAgentSessionTabFromSnapshot('C')).toBe(true) + const closed = (n ? source : retire ? [C] : []).map(chat) + const changed = withChatTabChanges('local', session.get('local'), WT, [ + ...current.map((id, i) => ({ + open: { + sessionId: chat(id).sessionId, + agent: 'codex' as const, + tabId: id, + placement: { groupId: 'L', afterTabId: source[i] } + } + })), + ...closed.map(({ sessionId: close }) => ({ close })) + ]) + if (changed) { + session.set(changed, 'local') + } + for (const { sessionId } of closed) { + expect(runtime.retireStructuredAgentSessionTabFromSnapshot(sessionId)).toBe(true) + } + if (n) { + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [snapshot(current, n + 2)] + }) } await runtime.publishStructuredAgentSessionTab({ workspaceId: WT, @@ -58,23 +110,25 @@ it.each([ getForegroundProcess: async () => null }) const created = await runtime.createMobileSessionTerminal(`id:${WT}`, { - afterTabId: 'C', + afterTabId: C, targetGroupId: 'L', command: 'codex resume conversation', activate: false }) const result = await runtime.listMobileSessionTabs(`id:${WT}`) - expect(result.tabs.some((tab) => tab.id === 'C')).toBe(!retire) + expect(result.tabs.some((tab) => tab.id === C)).toBe(!retire) expect( result.tabs.some((tab) => tab.type === 'agent-session' && tab.sessionId === 'concurrent') ).toBe(true) if (!only) { expect(result.tabGroups?.find((group) => group.id === 'L')?.tabOrder).toEqual( - pinned - ? ['A', ...(!retire ? ['C'] : []), 'B', created.tab.parentTabId, 'U'] - : ['A', created.tab.parentTabId, 'B'] + n + ? [...current.slice(0, n / 2 + 1), created.tab.parentTabId, ...current.slice(n / 2 + 1)] + : pinned + ? [A, ...(!retire ? [C] : []), B, created.tab.parentTabId, U] + : [A, created.tab.parentTabId, B] ) - expect(result.tabGroups?.find((group) => group.id === 'R')?.tabOrder).toContain('D') + expect(result.tabGroups?.find((group) => group.id === 'R')?.tabOrder).toContain(D) } else { expect( result.tabGroups?.some((group) => group.tabOrder.includes(created.tab.parentTabId)) @@ -82,5 +136,12 @@ it.each([ } const refreshed = await runtime.listMobileSessionTabs(`id:${WT}`) expect(refreshed.tabGroups).toEqual(result.tabGroups) + if (n) { + console.info('HOST_PLACEMENT_WORK', JSON.stringify({ n, samples: work.samples })) + expect(work.samples.length).toBeGreaterThan(0) + for (const sample of work.samples) { + expect(sample.reads + sample.adds + sample.has).toBeLessThanOrEqual(12 * n + 32) + } + } } ) diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index a6c94c612496..a9a5ffb09577 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -1,9 +1,19 @@ +import { isAgentSessionProviderHandleChain } from '../../shared/agent-session-provider-handle' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { publishCommittedPtySpawn } from '../ipc/pty/spawn-commit-publication' +import type { PtySpawnCommitInput } from '../ipc/pty/spawn-commit-assembly' +import { createRuntimePtySpawnState } from '../ipc/pty/runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from '../ipc/pty/runtime/controller-deps' +import { unregisterPty } from '../memory/pty-registry' +import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import * as storeDraft from './agent-session-store-draft' +import * as storeRows from './agent-session-record-rows' // A windowless host's saved chat tab against a real chat host: the index hiding a chat reaches the // runtime and takes the tab out of the layout, and a chat whose record is gone loses its tab. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionLeaseFixture, agentSessionRecordFixture @@ -12,7 +22,10 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-sessi import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + readPersistedTestAgentSessionStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import { OrcaRuntimeService } from './orca-runtime' import { withChatTabChanges } from './agent-session-tab-layout' import { @@ -106,7 +119,7 @@ async function runtimeWithSavedChats( }) } await settled() - return { runtime, state } + return { runtime, state, store } } function installHost(runtime: OrcaRuntimeService) { @@ -284,3 +297,144 @@ describe('a windowless chat tab against the chat host', () => { expect(savedChatIds(state)).toEqual([CHAT_GONE, CHAT_B]) }) }) + +it.each([ + [8, 1], + [80, 1], + [256, 1], + [8, 10] +])('measures retirement at %i links/%i sessions', async (k, sessions) => { + const record = released(CHAT_A) + record.provider = 'codex' + const handle = codexProviderHandle('88888888-8888-4888-8888-888888888888') + record.providerHandleChain = Array.from({ length: k }, (_, index) => ({ + linkId: `link-${index}`, + origin: index === 0 ? 'created' : 'resumed', + mintedAtFence: index + 1, + observedAt: index + 1, + handle + })) + record.lease = { ...record.lease, runtimeFence: k, provenHandleLinkId: `link-${k - 1}` } + expect(isAgentSessionProviderHandleChain(record.providerHandleChain)).toBe(true) + const ids = Array.from({ length: sessions }, (_, i) => (i ? `chat-matching-${i}` : CHAT_A)) + const records = ids.map((sessionId) => ({ + ...record, + sessionId, + lease: { ...record.lease, sessionId } + })) + await seedTestAgentSessionRecordStore(root, { + records: [...records, released(CHAT_B)], + sessionTabs: shown(...ids, CHAT_B) + }) + const { runtime, state, store } = await runtimeWithSavedChats([...ids, CHAT_B]) + for (let index = 0; index < 3; index += 1) { + await runtime.publishStructuredAgentSessionTab({ + workspaceId: `other-${index}`, + sessionId: CHAT_B, + agent: 'codex', + activate: false + }) + } + const host = await installHost(runtime) + const probes = { + visibility: vi.spyOn(host, 'setSessionTabVisibility'), + transactions: vi.spyOn(AgentSessionStoreTransactions.prototype, 'transact'), + drafts: vi.spyOn(storeDraft, 'draftAgentSessionStoreState'), + differences: vi.spyOn(storeDraft, 'agentSessionStoreDraftRowWrites'), + changedRowCommits: vi.spyOn(storeRows, 'writeAgentSessionStoreRows'), + hidden: vi.spyOn(runtime, 'onStructuredSessionTabHidden'), + savedWrites: vi.spyOn(store, 'setWorkspaceSession'), + layoutCommands: vi.spyOn(store, 'applyLayoutCommand'), + retirement: vi.spyOn(runtime, 'retireStructuredAgentSessionTabFromSnapshot') + } + const publications = vi.fn() + const unsubscribe = runtime.onMobileSessionTabsChanged(publications) + const dismissals = vi.spyOn(host.restartResume, 'dismiss') + const visits = { recordVisits: 0, snapshotVisits: 0, findVisits: 0 } + for (const method of [Symbol.iterator, 'values'] as const) { + const original = Map.prototype[method] + vi.spyOn(Map.prototype, method).mockImplementation(function* (this: Map) { + for (const item of original.call(this)) { + const value = method === 'values' ? item : item[1] + if (typeof value === 'object' && value !== null) { + if ('providerHandleChain' in value) { + visits.recordVisits += 1 + } + if ('snapshotVersion' in value && 'tabs' in value) { + visits.snapshotVisits += 1 + } + } + yield item + } + }) + } + const find = Array.prototype.find + vi.spyOn(Array.prototype, 'find').mockImplementation(function (this: object, predicate, thisArg) { + return find.call(this, (value, index, array) => { + visits.findVisits += 1 + return predicate.call(thisArg, value, index, array) + }) + }) + const reply = { + id: `perf-retirement-${k}`, + savedResumeDecision: { + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: handle.nativeId }, + verdict: 'admitted' as const + } + } + const ports = { runtime, sendPtySpawnedToRenderer: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this publication uses only these ports; it never starts a process. + const spawnState = createRuntimePtySpawnState(ports as PtyRuntimeControllerDeps, { + cols: 80, + rows: 24, + resumeOrigin: 'switch', + worktreeId: WT + }) + spawnState.result = reply + const commit: PtySpawnCommitInput = { + state: spawnState, + nativeWindowsConptySpawn: false, + seedPaneKey: null, + registration: { + store: undefined, + worktreeId: WT, + shellOverride: undefined, + rendererLaunchAuthority: null + }, + preAllocatedHandle: null, + paneKey: null, + memorySessionId: null, + resumeArgvDropped: false, + codexLaunchEnv: undefined, + initiallyHidden: false, + hiddenDelivery: 'runtime-owned', + earlyExit: 'throw', + joinPayload: { reservation: reply, reply } + } + const counts = () => ({ + ...Object.fromEntries(Object.entries(probes).map(([key, spy]) => [key, spy.mock.calls.length])), + dismissals: dismissals.mock.calls.length, + publications: publications.mock.calls.length, + ...visits + }) + try { + expect(await publishCommittedPtySpawn(commit)).toBe(reply) + const beforeDrain = counts() + await Promise.all(dismissals.mock.results.map(({ value }) => value)) + await settled() + const afterDrain = counts() + console.info('RETIREMENT_WORK', JSON.stringify({ k, sessions, beforeDrain, afterDrain })) + expect( + afterDrain.recordVisits + afterDrain.snapshotVisits + afterDrain.findVisits + ).toBeLessThanOrEqual(128 * k * sessions * sessions) + expect(host.deps.store.getVisibleSessionTabIndex().sessionIds).toEqual([CHAT_B]) + expect(savedChatIds(state)).toEqual([CHAT_B]) + expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual(shown(CHAT_B)) + expect(host.collaboratorsForTests().sessions.size).toBe(0) + } finally { + unsubscribe() + unregisterPty(reply.id) + vi.restoreAllMocks() + } +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index 3a759d092221..e3ddababfb16 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -1,6 +1,12 @@ +import recordedOmpReply from '../../../../main/acp/fixtures/omp-v17-reply-without-thought.jsonl?raw' +import { SessionNotificationSchema } from '../../../../main/acp/generated/acp-protocol.generated' +import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import { agentSessionTabFixture } from './native-chat-workspace-test-fixtures' +import * as cliSwitch from '../tab-bar/native-chat-cli-switch-run' +import { useAppStore } from '@/store' // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' @@ -29,6 +35,7 @@ afterEach(() => { cleanup() localStorage.clear() resetStructuredSessionMocks() + vi.restoreAllMocks() }) { @@ -237,3 +244,50 @@ afterEach(() => { expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull() }) } + +it('keeps the recorded OMP reply readable with its CLI switch unavailable', () => { + const frames = recordedOmpReply + .trim() + .split('\n') + .map((line) => SessionNotificationSchema.safeParse(JSON.parse(line).message.params)) + .flatMap((result) => (result.success ? [result.data] : [])) + const text = frames + .flatMap(({ update }) => + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [] + ) + .join('') + expect(text).toContain('marker recorded') + const tab = agentSessionTabFixture('omp-capture', 'wt-1', { + agentSessionAgent: 'omp', + entityId: frames[0].sessionId + }) + mocks.messages = [{ id: 'recorded-omp', role: 'assistant', blocks: [{ type: 'text', text }] }] + useAppStore.setState({ + settings: { ...useAppStore.getState().settings, experimentalNativeChat: true }, + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': [makeWorktree('wt-1', 'recorded', { path: '/repo' })] }, + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [tab] } + }) + const invoke = vi.spyOn(cliSwitch, 'runNativeChatCliSwitch') + render( + , + { wrapper: TooltipProvider } + ) + const button = screen.getByRole('button', { name: 'Switch to CLI' }) + expect(button.hasAttribute('disabled')).toBe(true) + fireEvent.click(button) + expect(invoke).not.toHaveBeenCalled() + expect(mocks.messageListProps).toMatchObject({ + session: { sessionId: frames[0].sessionId, messages: mocks.messages } + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts index 29e1834d4b71..ef28c30f4265 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-batch-projection.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createTestStore, makeTab, @@ -619,4 +619,126 @@ describe('useIpcEvents agent status snapshot integration', () => { vi.useRealTimers() } }) + + it.each([16, 160])( + 'copies startup association entries linearly at %i events with immutable reentry', + async (n) => { + vi.useFakeTimers() + const store = createTestStore() + const pane = (i: number) => `rootless:00000000-0000-4000-8000-${String(i).padStart(12, '0')}` + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: [makeWorktree({ id: 'wt', repoId: TEST_REPO.id })] }, + tabsByWorktree: { wt: [makeTab({ id: 'rootless', worktreeId: 'wt' })] }, + terminalLayoutsByTabId: { + rootless: { root: null, activeLeafId: null, expandedLeafId: null } + } + }) + const record = store.getState().recordAgentProviderSession + for (let i = 0; i < n; i++) { + record(pane(i), 'codex', { key: 'session_id', id: `seed-${i}` }, undefined, { + worktreeId: 'wt' + }) + } + const initial = Object.freeze(store.getState().agentConversationsByPaneKey) + const entries: AgentStatusSetData[] = Array.from({ length: n }, (_, i) => ({ + paneKey: pane(n + i), + state: 'working', + prompt: '', + agentType: 'codex', + providerSessionOnly: i % 2 === 0, + worktreeId: 'wt', + terminalHandle: `term-${i}`, + providerSession: { key: 'session_id', id: `startup-${i}` }, + receivedAt: 1000, + stateStartedAt: 1000 + })) + let copied = 0, + enumerated = 0 + const assign = Object.assign + const association = (source: unknown): source is Record => + typeof source === 'object' && source !== null && Object.hasOwn(source, pane(0)) + vi.spyOn(Object, 'assign').mockImplementation((target, ...sources) => + assign( + target, + ...sources.map((source) => + association(source) + ? new Proxy(source, { + ownKeys(value) { + const found = Reflect.ownKeys(value) + enumerated += found.length + return found + }, + get(value, key) { + copied += 1 + return value[key] + } + }) + : source + ) + ) + ) + const snapshots: (typeof initial)[] = [], + order: string[] = [] + cleanup.push( + store.subscribe((state, previous) => { + if (state.agentConversationsByPaneKey === previous.agentConversationsByPaneKey) { + return + } + snapshots.push(Object.freeze(state.agentConversationsByPaneKey)) + order.push('published') + if (snapshots.length === 1) { + store.getState().transactAgentStatuses((outer) => { + store.getState().transactAgentStatuses((inner) => { + inner.apply({ + kind: 'providerSession', + paneKey: pane(2 * n - 1), + agent: 'codex', + providerSession: { key: 'session_id', id: 'reentered' }, + timing: { updatedAt: 2000 }, + routing: { worktreeId: 'wt' } + }) + inner.afterCommit(() => order.push('inner')) + }) + outer.afterCommit(() => order.push('outer')) + }) + } + }) + ) + const snapshot = vi.fn(async () => entries) + vi.doMock('../store', () => ({ + useAppStore: { getState: store.getState, subscribe: store.subscribe } + })) + stubAuxiliaryModules() + vi.stubGlobal('window', buildWindowApi({ onSet: () => () => {}, getSnapshot: snapshot })) + const { registerAgentStatusIpcBridge } = await import('./ipc-events/agent-status-ipc-bridge') + const unsubs: (() => void)[] = [] + const bridge = registerAgentStatusIpcBridge(unsubs) + cleanup.push(bridge.disposeAsyncState, bridge.unsubscribeStore, ...unsubs) + await vi.advanceTimersByTimeAsync(0) + console.info('STARTUP_ASSOCIATION_WORK', { n, copied, enumerated }) + expect(snapshot).toHaveBeenCalledOnce() + expect(snapshots).toHaveLength(2) + expect(order).toEqual(['published', 'published', 'inner', 'outer']) + expect(Object.keys(initial)).toEqual(Array.from({ length: n }, (_, i) => pane(i))) + expect(Object.keys(snapshots[0])).toEqual(Array.from({ length: 2 * n }, (_, i) => pane(i))) + expect(snapshots[0][pane(2 * n - 1)].providerSession.id).toBe(`startup-${n - 1}`) + expect(snapshots[1][pane(2 * n - 1)].providerSession.id).toBe('reentered') + expect(snapshots[0]).not.toBe(snapshots[1]) + expect(Object.keys(snapshots[1])).toEqual(Object.keys(snapshots[0])) + expect(copied).toBe(3 * n) + expect(enumerated).toBe(3 * n) + } + ) +}) + +const cleanup: (() => void)[] = [] +afterEach(() => { + for (const close of cleanup.splice(0).toReversed()) { + close() + } + vi.restoreAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() }) diff --git a/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts index a3346b1c9b03..f6d2300c6e1d 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-switch-placement.test.ts @@ -1,5 +1,6 @@ +import { observeSessionTabPlacementWork } from '../../../shared/session-tab-placement-work.test-fixture' // @vitest-environment happy-dom -import { beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' @@ -20,7 +21,7 @@ const chat = (id: string) => ({ isActive: false }) let frame: RuntimeMobileSessionTabsResult -beforeEach(() => { +function resetFrame(n = 0) { resetWebSessionTabsSnapshotFreshnessForTests() useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ @@ -31,6 +32,9 @@ beforeEach(() => { activeRuntimeEnvironmentId: ENV }) }) + const source = n + ? Array.from({ length: n }, (_, i) => (i === n / 2 ? 'C' : `old-${i}`)) + : ['A', 'C', 'B'] frame = { worktree: WT, publicationEpoch: 'host-placement', @@ -38,21 +42,33 @@ beforeEach(() => { activeGroupId: 'R', activeTabId: 'agent-session:D', activeTabType: 'agent-session', - tabs: ['A', 'C', 'B', 'D'].map(chat), + tabs: [...source, 'D'].map(chat), tabGroups: [ { id: 'L', activeTabId: 'agent-session:C', - tabOrder: ['A', 'C', 'B'].map((id) => `agent-session:${id}`) + tabOrder: source.map((id) => `agent-session:${id}`) }, { id: 'R', activeTabId: 'agent-session:D', tabOrder: ['agent-session:D'] } ] } useAppStore.setState((state) => applyWebSessionTabsSnapshot(state, frame, ENV)) -}) -it.each([false, true])( - 'keeps client neighbors and pin partitions through early retirement and refresh (pinned: %s)', - async (pinned) => { +} +beforeEach(() => resetFrame()) +afterEach(() => vi.restoreAllMocks()) +it.each([ + { pinned: false, n: 0 }, + { pinned: true, n: 0 }, + { pinned: false, n: 32 }, + { pinned: false, n: 320 } +])( + 'keeps client neighbors and pin partitions through early retirement and refresh (pinned: $pinned/$n)', + async ({ pinned, n }) => { + const current = Array.from({ length: n }, (_, i) => `current-${i}`) + if (n) { + resetFrame(n) + } + const work = observeSessionTabPlacementWork() if (pinned) { frame = { ...frame, @@ -79,7 +95,9 @@ it.each([false, true])( ...frame, snapshotVersion: frame.snapshotVersion + 1, tabs: [ - ...frame.tabs.filter((tab) => tab.id !== 'agent-session:C'), + ...(n + ? [...current, 'D'].map(chat) + : frame.tabs.filter((tab) => tab.id !== 'agent-session:C')), { type: 'terminal', id: 'T::leaf', @@ -96,8 +114,9 @@ it.each([false, true])( id: 'L', activeTabId: 'T', tabOrder: [ - 'agent-session:A', - 'agent-session:B', + ...(n + ? current.map((id) => `agent-session:${id}`) + : ['agent-session:A', 'agent-session:B']), 'T', ...(pinned ? ['agent-session:U'] : []) ] @@ -144,21 +163,30 @@ it.each([false, true])( const order = () => useAppStore.getState().groupsByWorktree[WT].find((group) => group.id === sourceGroup)! .tabOrder - expect(order()).toEqual([ - 'structured-agent-session-A', - ...(pinned ? ['structured-agent-session-B'] : []), - toWebTerminalSurfaceTabId('T'), - ...(pinned ? ['structured-agent-session-U'] : ['structured-agent-session-B']), - 'concurrent' - ]) + const expected = n + ? [ + ...current.slice(0, n / 2 + 1).map((id) => `structured-agent-session-${id}`), + toWebTerminalSurfaceTabId('T'), + ...current.slice(n / 2 + 1).map((id) => `structured-agent-session-${id}`), + 'concurrent' + ] + : [ + 'structured-agent-session-A', + ...(pinned ? ['structured-agent-session-B'] : []), + toWebTerminalSurfaceTabId('T'), + ...(pinned ? ['structured-agent-session-U'] : ['structured-agent-session-B']), + 'concurrent' + ] + expect(order()).toEqual(expected) expect(useAppStore.getState().activeGroupIdByWorktree[WT]).toBe(sourceGroup) + if (n) { + console.info('PAIRED_PLACEMENT_WORK', JSON.stringify({ n, samples: work.samples })) + expect(work.samples.length).toBeGreaterThan(0) + for (const sample of work.samples) { + expect(sample.reads + sample.adds + sample.has).toBeLessThanOrEqual(12 * n + 32) + } + } await refreshWebRuntimeSessionTabsSnapshot(ENV, WT, { acceptCurrentSnapshot: true }) - expect(order()).toEqual([ - 'structured-agent-session-A', - ...(pinned ? ['structured-agent-session-B'] : []), - toWebTerminalSurfaceTabId('T'), - ...(pinned ? ['structured-agent-session-U'] : ['structured-agent-session-B']), - 'concurrent' - ]) + expect(order()).toEqual(expected) } ) diff --git a/src/renderer/src/store/slices/agent-pane-conversation.ts b/src/renderer/src/store/slices/agent-pane-conversation.ts index 7c4bc5664496..5e32cac8bccb 100644 --- a/src/renderer/src/store/slices/agent-pane-conversation.ts +++ b/src/renderer/src/store/slices/agent-pane-conversation.ts @@ -21,7 +21,10 @@ export function projectAgentPaneConversation( entry: Pick< AgentStatusEntry, 'paneKey' | 'agentType' | 'providerSession' | 'worktreeId' | 'connectionId' - > + >, + copy: ( + entries: AppState['agentConversationsByPaneKey'] + ) => AppState['agentConversationsByPaneKey'] = (entries) => ({ ...entries }) ): AppState['agentConversationsByPaneKey'] { const current = state.agentConversationsByPaneKey const previous = current[entry.paneKey] @@ -34,7 +37,7 @@ export function projectAgentPaneConversation( if (!previous || previous.agent === agent) { return current } - const next = { ...current } + const next = copy(current) delete next[entry.paneKey] return next } @@ -49,15 +52,19 @@ export function projectAgentPaneConversation( ) { return current } - return { - ...current, - [entry.paneKey]: { + const next = copy(current) + Object.defineProperty(next, entry.paneKey, { + value: { paneKey: entry.paneKey, worktreeId, executionHostId, connectionId, agent, providerSession: entry.providerSession - } - } + }, + writable: true, + enumerable: true, + configurable: true + }) + return next } diff --git a/src/renderer/src/store/slices/agent-status-live-actions.ts b/src/renderer/src/store/slices/agent-status-live-actions.ts index 03b64af2dbbd..b307a2ba8ecb 100644 --- a/src/renderer/src/store/slices/agent-status-live-actions.ts +++ b/src/renderer/src/store/slices/agent-status-live-actions.ts @@ -32,6 +32,7 @@ export function createAgentStatusLiveActions( runtime: AgentStatusRuntime ): Pick { const { + projectConversation, get, set, applyGeneratedTabTitleUpdate, @@ -105,7 +106,7 @@ export function createAgentStatusLiveActions( return state } const previousEntries = state.agentStatusByPaneKey - const reduction = reduceAgentStatusLiveUpdate(state, built, updatedAt) + const reduction = reduceAgentStatusLiveUpdate(state, built, updatedAt, projectConversation) liveEntryDelta = { previousEntries, nextEntries: reduction.patch.agentStatusByPaneKey ?? previousEntries, diff --git a/src/renderer/src/store/slices/agent-status-live-reducer.ts b/src/renderer/src/store/slices/agent-status-live-reducer.ts index c3696cf1e9b7..1bcd35252e8e 100644 --- a/src/renderer/src/store/slices/agent-status-live-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-live-reducer.ts @@ -22,7 +22,8 @@ export type AgentStatusLiveUpdateReduction = { export function reduceAgentStatusLiveUpdate( state: AppState, build: AgentStatusLiveEntryBuild, - updatedAt: number + updatedAt: number, + projectConversation = projectAgentPaneConversation ): AgentStatusLiveUpdateReduction { const { entry, @@ -111,7 +112,7 @@ export function reduceAgentStatusLiveUpdate( } const patch: Partial = { agentStatusByPaneKey: nextLive, - agentConversationsByPaneKey: projectAgentPaneConversation(state, entry), + agentConversationsByPaneKey: projectConversation(state, entry), retainedAgentsByPaneKey: nextRetainedAgents, sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions, agentLaunchConfigByPaneKey: nextLaunchConfigs, diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 821c84e8c1ff..7cce49353d00 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -1,4 +1,3 @@ -import { projectAgentPaneConversation } from './agent-pane-conversation' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import type { @@ -28,7 +27,7 @@ import { settleAttentionRemovedBy } from './workspace-attention/attention-remova export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime ): Pick { - const { get, set, requestFreshness } = runtime + const { get, set, requestFreshness, projectConversation } = runtime return { recordAgentProviderSession: ( paneKey: string, @@ -152,7 +151,7 @@ export function createAgentStatusProviderSessionActions( } return { agentStatusByPaneKey: nextLive, - agentConversationsByPaneKey: projectAgentPaneConversation(s, { + agentConversationsByPaneKey: projectConversation(s, { ...record, agentType: agent }), diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index ece019e97107..e2e44fea3411 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -1,3 +1,6 @@ +import { normalizeHookPayload } from '../../../../shared/agent-hook-listener' +import { createHookListenerState } from '../../../../shared/agent-hook-listener/listener-state' +import { applyProviderSessionOnlyUpdate } from '../../hooks/ipc-events/agent-status-partial-updates' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { AppState } from '../types' @@ -803,3 +806,39 @@ describe('sleepingAgentSessionsByPaneKey after reminted-hook settlement', () => ) }) }) + +it('retains recorded Jcode session-start identity without a status row', () => { + const store = createTestStore() + seedStore(store, { tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] } }) + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + // Jcode 0.87.1 captured input; agent-hook-listener-jcode.test.ts pins provenance. + const payload = { + hook_event_name: 'session_start', + event: 'session_start', + session_id: 'session_jc_5', + model: 'claude-haiku-4-5', + source: 'create' + } + const event = normalizeHookPayload( + createHookListenerState(), + 'jcode', + { paneKey, payload }, + 'production' + ) + if (!event) { + throw new Error('Recorded session_start was rejected') + } + expect(event.providerSessionOnly).toBe(true) + applyProviderSessionOnlyUpdate({ + store: store.getState(), + batch: undefined, + paneKey, + routing: { tabId: 'tab-1', worktreeId: 'wt-1' }, + data: { ...event, ...event.payload, receivedAt: 1, stateStartedAt: 1 } + }) + expect(store.getState().agentStatusByPaneKey).toEqual({}) + expect(store.getState().agentConversationsByPaneKey[paneKey]).toMatchObject({ + agent: 'jcode', + providerSession: { key: 'session_id', id: 'session_jc_5' } + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-runtime.ts b/src/renderer/src/store/slices/agent-status-runtime.ts index 9e6a37a312ce..a4184e9120c3 100644 --- a/src/renderer/src/store/slices/agent-status-runtime.ts +++ b/src/renderer/src/store/slices/agent-status-runtime.ts @@ -1,3 +1,4 @@ +import { projectAgentPaneConversation } from './agent-pane-conversation' import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { AgentStatusBatchTransaction, AgentStatusBatchUpdate } from './agent-status-contract' @@ -11,6 +12,7 @@ export type AgentStatusStateUpdate = | ((state: AppState) => AppState | Partial) export type AgentStatusRuntime = { + projectConversation: typeof projectAgentPaneConversation get: () => AppState set: (update: AgentStatusStateUpdate) => void runAfterCommit: (effect: () => void) => void @@ -40,6 +42,19 @@ export function createAgentStatusRuntime( let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null let batchedAgentStatusFreshnessRequested = false const get = (): AppState => batchedAgentStatusState ?? storeGet() + let conversationDraft: AppState['agentConversationsByPaneKey'] | null = null + const projectConversation: typeof projectAgentPaneConversation = (state, entry) => + projectAgentPaneConversation(state, entry, (entries) => { + if (!batchedAgentStatusState) { + return { ...entries } + } + const next = + entries === conversationDraft + ? entries + : Object.setPrototypeOf(Object.assign(Object.create(null), entries), Object.prototype) + conversationDraft = next + return next + }) // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in // this slice can compile into a REPLACE the batch commit is unable to express. @@ -148,6 +163,7 @@ export function createAgentStatusRuntime( const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates const freshnessRequested = batchedAgentStatusFreshnessRequested const hasStagedWrites = batchedAgentStatusRevision !== revisionAtStart + conversationDraft = null batchedAgentStatusState = null batchedAgentStatusTouchedKeys = null batchedAgentStatusEffects = null @@ -167,6 +183,7 @@ export function createAgentStatusRuntime( } return result } finally { + conversationDraft = null batchedAgentStatusState = null batchedAgentStatusTouchedKeys = null batchedAgentStatusEffects = null @@ -223,6 +240,7 @@ export function createAgentStatusRuntime( } return { + projectConversation, get, set, runAfterCommit, diff --git a/src/shared/session-tab-placement-work.test-fixture.ts b/src/shared/session-tab-placement-work.test-fixture.ts new file mode 100644 index 000000000000..7f769ca80cdb --- /dev/null +++ b/src/shared/session-tab-placement-work.test-fixture.ts @@ -0,0 +1,54 @@ +import { vi } from 'vitest' +import * as placement from './session-tab-placement' + +export function observeSessionTabPlacementWork() { + const samples: { reads: number; adds: number; has: number }[] = [] + function measure(work: () => T): T { + const count = { reads: 0, adds: 0, has: 0 } + const { includes, indexOf } = Array.prototype + const { add, has } = Set.prototype + const receiver = (array: unknown[]) => + new Proxy(array, { + get(target, key) { + if (key === 'length') { + return target.length + } + if (typeof key !== 'string' || !/^\d+$/.test(key)) { + return undefined + } + count.reads += 1 + return target[Number(key)] + } + }) + Array.prototype.includes = function (value, from) { + return includes.call(receiver(this), value, from) + } + Array.prototype.indexOf = function (value, from) { + return indexOf.call(receiver(this), value, from) + } + Set.prototype.add = function (value) { + count.adds += 1 + return add.call(this, value) + } + Set.prototype.has = function (value) { + count.has += 1 + return has.call(this, value) + } + try { + return work() + } finally { + Object.assign(Array.prototype, { includes, indexOf }) + Object.assign(Set.prototype, { add, has }) + samples.push(count) + } + } + const created = placement.placeCreatedSessionTab + const unpinned = placement.placeUnpinnedSessionTabAtCapture + vi.spyOn(placement, 'placeCreatedSessionTab').mockImplementation((...args) => + measure(() => created(...args)) + ) + vi.spyOn(placement, 'placeUnpinnedSessionTabAtCapture').mockImplementation((...args) => + measure(() => unpinned(...args)) + ) + return { samples } +} diff --git a/src/shared/session-tab-placement.ts b/src/shared/session-tab-placement.ts index f1bbcbec9dcb..0727f809675c 100644 --- a/src/shared/session-tab-placement.ts +++ b/src/shared/session-tab-placement.ts @@ -23,8 +23,9 @@ export function placeSessionTabAtCapture( captured: CapturedSessionTabPlacement ): string[] { const next = order.filter((id) => id !== createdId) - const previous = captured.preceding.toReversed().find((id) => next.includes(id)) - const following = captured.following.find((id) => next.includes(id)) + const present = new Set(next) + const previous = captured.preceding.toReversed().find((id) => present.has(id)) + const following = captured.following.find((id) => present.has(id)) const index = following !== undefined ? next.indexOf(following) From c646ee027de8a1eb27c81568570457ff8398b9d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:07:12 -0700 Subject: [PATCH 064/207] Use an array-like receiver in retirement instrumentation Describe the native find observer receiver with ArrayLike while preserving its forwarding, counters and assertions. --- .../structured-agent-session-windowless-layout-host.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index a9a5ffb09577..5a7bb325fe3d 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -369,7 +369,11 @@ it.each([ }) } const find = Array.prototype.find - vi.spyOn(Array.prototype, 'find').mockImplementation(function (this: object, predicate, thisArg) { + vi.spyOn(Array.prototype, 'find').mockImplementation(function ( + this: ArrayLike, + predicate, + thisArg + ) { return find.call(this, (value, index, array) => { visits.findVisits += 1 return predicate.call(thisArg, value, index, array) From d007f3373ebcf04027314f5686a22ec6e073e25c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:42:22 -0700 Subject: [PATCH 065/207] Correct types and fixtures in conversation contract tests Return the native iterator completion explicitly, reuse complete publication dependencies, and validate recorded text at the renderer consumer boundary with complete settings. --- .../pty-spawn-commit-dependencies-fixture.ts | 2 +- ...ent-session-windowless-layout-host.test.ts | 8 ++--- ...ucturedSession.history-read-state.test.tsx | 30 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts index e45b6350842e..52dd633fd83d 100644 --- a/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts +++ b/src/main/persistence/loading-store/pty-spawn-commit-dependencies-fixture.ts @@ -9,7 +9,7 @@ function unexpectedPreflight(): never { export function createPtySpawnCommitDependencies( runtime: OrcaRuntimeService, - store: Store + store?: Store ): PtySpawnIpcDeps & PtyRuntimeControllerDeps { return { runtime, diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index 5a7bb325fe3d..a72617dbce78 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -3,7 +3,7 @@ import { codexProviderHandle } from '../../shared/agent-session-provider-handle- import { publishCommittedPtySpawn } from '../ipc/pty/spawn-commit-publication' import type { PtySpawnCommitInput } from '../ipc/pty/spawn-commit-assembly' import { createRuntimePtySpawnState } from '../ipc/pty/runtime/spawn-state' -import type { PtyRuntimeControllerDeps } from '../ipc/pty/runtime/controller-deps' +import { createPtySpawnCommitDependencies } from '../persistence/loading-store/pty-spawn-commit-dependencies-fixture' import { unregisterPty } from '../memory/pty-registry' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' import * as storeDraft from './agent-session-store-draft' @@ -366,6 +366,7 @@ it.each([ } yield item } + return undefined }) } const find = Array.prototype.find @@ -387,9 +388,8 @@ it.each([ verdict: 'admitted' as const } } - const ports = { runtime, sendPtySpawnedToRenderer: vi.fn() } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this publication uses only these ports; it never starts a process. - const spawnState = createRuntimePtySpawnState(ports as PtyRuntimeControllerDeps, { + const ports = createPtySpawnCommitDependencies(runtime) + const spawnState = createRuntimePtySpawnState(ports, { cols: 80, rows: 24, resumeOrigin: 'switch', diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index e3ddababfb16..444a6322d0e4 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -1,5 +1,6 @@ import recordedOmpReply from '../../../../main/acp/fixtures/omp-v17-reply-without-thought.jsonl?raw' -import { SessionNotificationSchema } from '../../../../main/acp/generated/acp-protocol.generated' +import { z } from 'zod' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' import { agentSessionTabFixture } from './native-chat-workspace-test-fixtures' import * as cliSwitch from '../tab-bar/native-chat-cli-switch-run' @@ -246,29 +247,30 @@ afterEach(() => { } it('keeps the recorded OMP reply readable with its CLI switch unavailable', () => { + const chunk = z.object({ + sessionId: z.string(), + update: z.object({ + sessionUpdate: z.literal('agent_message_chunk'), + content: z.object({ type: z.literal('text'), text: z.string() }) + }) + }) const frames = recordedOmpReply .trim() .split('\n') - .map((line) => SessionNotificationSchema.safeParse(JSON.parse(line).message.params)) + .map((line) => chunk.safeParse(JSON.parse(line).message.params)) .flatMap((result) => (result.success ? [result.data] : [])) - const text = frames - .flatMap(({ update }) => - update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' - ? [update.content.text] - : [] - ) - .join('') + const text = frames.map(({ update }) => update.content.text).join('') expect(text).toContain('marker recorded') + const { sessionId } = frames[0] const tab = agentSessionTabFixture('omp-capture', 'wt-1', { agentSessionAgent: 'omp', - entityId: frames[0].sessionId + entityId: sessionId }) mocks.messages = [{ id: 'recorded-omp', role: 'assistant', blocks: [{ type: 'text', text }] }] useAppStore.setState({ - settings: { ...useAppStore.getState().settings, experimentalNativeChat: true }, + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }), repos: [makeRepo()], worktreesByRepo: { 'repo-1': [makeWorktree('wt-1', 'recorded', { path: '/repo' })] }, - tabsByWorktree: {}, unifiedTabsByWorktree: { 'wt-1': [tab] } }) const invoke = vi.spyOn(cliSwitch, 'runNativeChatCliSwitch') @@ -287,7 +289,5 @@ it('keeps the recorded OMP reply readable with its CLI switch unavailable', () = expect(button.hasAttribute('disabled')).toBe(true) fireEvent.click(button) expect(invoke).not.toHaveBeenCalled() - expect(mocks.messageListProps).toMatchObject({ - session: { sessionId: frames[0].sessionId, messages: mocks.messages } - }) + expect(mocks.messageListProps?.session).toMatchObject({ sessionId, messages: mocks.messages }) }) From fa85b6758970d3d006cc683e387f5cde3a5f9b33 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:52:10 -0700 Subject: [PATCH 066/207] Test native switch visibility during Chat UI updates Cover capability hydration and live settings updates across native header, hover and tab-menu controls. --- ...ucturedSession.history-read-state.test.tsx | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index 444a6322d0e4..222b7371fc19 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -7,11 +7,24 @@ import * as cliSwitch from '../tab-bar/native-chat-cli-switch-run' import { useAppStore } from '@/store' // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { TabSessionSwitchHoverAction } from '../tab-bar/TabSessionSwitchHoverAction' +import { TabSessionSurfaceSwitchMenuItems } from '../tab-bar/TabSessionSurfaceSwitchMenuItems' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { + readLocalRuntimeCapabilitiesOrUnknown, + setLocalRuntimeCapabilitiesForTests +} from '@/runtime/local-runtime-capabilities' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' +import type { RuntimeStatus } from '../../../../shared/runtime-types' const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -291,3 +304,89 @@ it('keeps the recorded OMP reply readable with its CLI switch unavailable', () = expect(invoke).not.toHaveBeenCalled() expect(mocks.messageListProps?.session).toMatchObject({ sessionId, messages: mocks.messages }) }) + +it('gates eligible native header hover and menu switches through capability hydration and live Chat UI updates', async () => { + const tab = agentSessionTabFixture('native-controls', 'wt-1', { + entityId: '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + }) + const nativeChat = { sessionId: tab.entityId, agent: 'codex' as const } + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: false }), + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': [makeWorktree('wt-1', 'native', { path: '/repo' })] }, + unifiedTabsByWorktree: { 'wt-1': [tab] } + }) + mocks.status = 'ready' + setLocalRuntimeCapabilitiesForTests(null) + let answer: (status: RuntimeStatus) => void = () => { + throw new Error('Capability probe did not start') + } + const status = new Promise((resolve) => { + answer = resolve + }) + const getStatus = vi.fn(() => status) + Object.assign(window, { api: { ...window.api, runtime: { getStatus } } }) + const switchToCli = vi.spyOn(cliSwitch, 'runNativeChatCliSwitch') + render( + + + + + Open native tab menu + + + + + + ) + const expectHidden = () => { + expect(screen.queryAllByRole('button', { name: 'Switch to CLI', hidden: true })).toHaveLength(0) + expect(screen.queryByRole('menuitem', { name: 'Switch to CLI' })).toBeNull() + expect(screen.queryByRole('separator')).toBeNull() + expect(screen.queryByText('Resume in New Terminal')).toBeNull() + expect(screen.getByTestId('message-list')).toBeTruthy() + } + expect(readLocalRuntimeCapabilitiesOrUnknown()).toBeNull() + expectHidden() + const response = createCompatibleRuntimeStatusResponse('local') + if (!response.ok) { + throw new Error('Expected status fixture') + } + await act(async () => { + answer({ ...response.result, capabilities: ['agent-session.structured.switch.v1:codex'] }) + await status + }) + expect(readLocalRuntimeCapabilitiesOrUnknown()).toContain( + 'agent-session.structured.switch.v1:codex' + ) + expectHidden() + act(() => { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) + }) + }) + const buttons = screen.getAllByRole('button', { name: 'Switch to CLI', hidden: true }) + expect(buttons).toHaveLength(2) + for (const button of buttons) { + expect(button.hasAttribute('disabled')).toBe(false) + } + expect( + screen.getByRole('menuitem', { name: 'Switch to CLI' }).getAttribute('data-disabled') + ).toBeNull() + act(() => { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: false }) + }) + }) + expectHidden() + expect(getStatus).toHaveBeenCalledOnce() + expect(switchToCli).not.toHaveBeenCalled() +}) From 68904f71c984d2e32678a1f80e6ec0ce90772b7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:35:42 -0700 Subject: [PATCH 067/207] Restore canonical terminal-view label in runtime catalog Ship the existing English catalog value when imported callers provide a different fallback. --- src/renderer/src/i18n/en-runtime-required.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b62c493d633b..877e0bcbf8a5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3373,7 +3373,8 @@ "tab": { "bar": { "SortableTabContextMenu": { - "closeTabsToLeft": "Close All Tabs To The Left" + "closeTabsToLeft": "Close All Tabs To The Left", + "switchToTerminalView": "Show as terminal" } } }, From 2ae8accee99839ea8cd943ad68844c553332f290 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:24:38 -0700 Subject: [PATCH 068/207] Show one terminal control in native chat headers Keep the legacy action when Chat UI is off or the host is unsupported, and use the existing switch while capabilities load or are available. --- .../NativeChatResumeInCliButton.tsx | 6 +- ...ucturedSession.history-read-state.test.tsx | 64 ++++++++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx index cd9b9f70d43a..6ac5ed20bd81 100644 --- a/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResumeInCliButton.tsx @@ -3,6 +3,8 @@ import { SquareTerminal } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' +import { useInPlaceSwitchCapability } from '@/lib/in-place-switch-support' +import { isNativeChatEnabled } from '../../../../shared/structured-native-chat-launch-route' import type { AppState } from '@/store/types' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' import { translate } from '@/i18n/i18n' @@ -73,11 +75,13 @@ export function NativeChatResumeInCliButton({ ) const commandOverrides = useAppStore((state) => state.settings?.agentCmdOverrides) const agent = AI_VAULT_AGENTS.find((candidate) => candidate === nativeChat.agent) + const inPlace = useInPlaceSwitchCapability(executionHostScope, agent) + const enabled = useAppStore((state) => isNativeChatEnabled(state.settings)) const label = translate( 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', 'Continue with Terminal Agent' ) - if (!executionHostScope || !agent) { + if (!executionHostScope || !agent || (enabled && (inPlace !== false || agent === 'omp'))) { return null } return ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index 222b7371fc19..5e3719b50a66 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -5,6 +5,8 @@ import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' import { agentSessionTabFixture } from './native-chat-workspace-test-fixtures' import * as cliSwitch from '../tab-bar/native-chat-cli-switch-run' import { useAppStore } from '@/store' +import { makeTabGroup } from '@/store/slices/store-test-helpers' +import * as cliFork from '../tab-bar/native-chat-cli-fork-run' // @vitest-environment happy-dom import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -21,6 +23,7 @@ import { } from '@/components/ui/dropdown-menu' import { readLocalRuntimeCapabilitiesOrUnknown, + refreshLocalRuntimeCapabilities, setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' @@ -315,7 +318,18 @@ it('gates eligible native header hover and menu switches through capability hydr settings: createGlobalSettingsFixture({ experimentalNativeChat: false }), repos: [makeRepo()], worktreesByRepo: { 'repo-1': [makeWorktree('wt-1', 'native', { path: '/repo' })] }, - unifiedTabsByWorktree: { 'wt-1': [tab] } + unifiedTabsByWorktree: { 'wt-1': [tab] }, + groupsByWorktree: { + 'wt-1': [ + makeTabGroup({ + id: tab.groupId, + worktreeId: 'wt-1', + tabOrder: [tab.id], + activeTabId: tab.id + }) + ] + }, + activeGroupIdByWorktree: { 'wt-1': tab.groupId } }) mocks.status = 'ready' setLocalRuntimeCapabilitiesForTests(null) @@ -328,11 +342,13 @@ it('gates eligible native header hover and menu switches through capability hydr const getStatus = vi.fn(() => status) Object.assign(window, { api: { ...window.api, runtime: { getStatus } } }) const switchToCli = vi.spyOn(cliSwitch, 'runNativeChatCliSwitch') + const forkToCli = vi.spyOn(cliFork, 'runNativeChatCliFork') render( ) + expect(screen.getByRole('button', { name: 'Split Chat Right', hidden: true })).toBeTruthy() + const transcript = screen.getByTestId('message-list') + const legacyName = 'Show as terminal' const expectHidden = () => { expect(screen.queryAllByRole('button', { name: 'Switch to CLI', hidden: true })).toHaveLength(0) expect(screen.queryByRole('menuitem', { name: 'Switch to CLI' })).toBeNull() expect(screen.queryByRole('separator')).toBeNull() expect(screen.queryByText('Resume in New Terminal')).toBeNull() - expect(screen.getByTestId('message-list')).toBeTruthy() + expect(screen.getByTestId('message-list')).toBe(transcript) + expect(screen.getAllByRole('button', { name: legacyName, hidden: true })).toHaveLength(1) } expect(readLocalRuntimeCapabilitiesOrUnknown()).toBeNull() expectHidden() + act(() => { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) + }) + }) + const loadingButtons = screen.getAllByRole('button', { name: 'Switch to CLI', hidden: true }) + expect(loadingButtons).toHaveLength(2) + for (const button of loadingButtons) { + expect(button.hasAttribute('disabled')).toBe(true) + } + expect(screen.queryByRole('button', { name: legacyName, hidden: true })).toBeNull() + expect( + screen.getByRole('menuitem', { name: 'Switch to CLI' }).hasAttribute('data-disabled') + ).toBe(true) const response = createCompatibleRuntimeStatusResponse('local') if (!response.ok) { throw new Error('Expected status fixture') @@ -367,14 +401,9 @@ it('gates eligible native header hover and menu switches through capability hydr expect(readLocalRuntimeCapabilitiesOrUnknown()).toContain( 'agent-session.structured.switch.v1:codex' ) - expectHidden() - act(() => { - useAppStore.setState({ - settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) - }) - }) const buttons = screen.getAllByRole('button', { name: 'Switch to CLI', hidden: true }) expect(buttons).toHaveLength(2) + expect(screen.queryByRole('button', { name: legacyName, hidden: true })).toBeNull() for (const button of buttons) { expect(button.hasAttribute('disabled')).toBe(false) } @@ -387,6 +416,23 @@ it('gates eligible native header hover and menu switches through capability hydr }) }) expectHidden() - expect(getStatus).toHaveBeenCalledOnce() + act(() => { + useAppStore.setState({ + settings: createGlobalSettingsFixture({ experimentalNativeChat: true }) + }) + }) + getStatus.mockResolvedValue({ ...response.result, capabilities: [] }) + await act(async () => { + await refreshLocalRuntimeCapabilities() + }) + expect(screen.queryAllByRole('button', { name: 'Switch to CLI', hidden: true })).toHaveLength(0) + expect(screen.queryByRole('menuitem', { name: 'Switch to CLI' })).toBeNull() + expect(screen.getAllByRole('button', { name: legacyName, hidden: true })).toHaveLength(1) + expect(screen.getByRole('menuitem', { name: 'Resume in New Terminal' })).toBeTruthy() + expect(screen.getByRole('separator')).toBeTruthy() + expect(screen.getByTestId('message-list')).toBe(transcript) + expect(readLocalRuntimeCapabilitiesOrUnknown()).toEqual([]) + expect(getStatus).toHaveBeenCalledTimes(2) + expect(forkToCli).not.toHaveBeenCalled() expect(switchToCli).not.toHaveBeenCalled() }) From f46428b2e834987ec18ac22958736d532d32259a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:30:28 -0700 Subject: [PATCH 069/207] Align test observers and capture hook timeout diagnostics Use canonical header labels and the current provider history request route, retaining existing assertions. Capture invocation and pipe state before timeout cleanup. --- .../managed-hook-stdin-lifecycle.test.ts | 33 ++++++++++++++++++- ...ured-agent-session-create-adoption.test.ts | 5 +-- .../native-chat/NativeChatPaneHeader.test.tsx | 6 ++-- .../native-chat-header-ownership.test.tsx | 2 +- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 30d0d04abaf1..f17ad655cc9e 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -201,9 +201,40 @@ function runHookProcess( let stderr = '' let stdout = '' const timeout = setTimeout(() => { + const diagnostic = JSON.stringify({ + executable, + args, + spawnfile: child.spawnfile, + spawnargs: child.spawnargs, + inPane: Boolean(env.ORCA_PANE_KEY), + pid: child.pid ?? null, + exitCode: child.exitCode, + signalCode: child.signalCode, + killed: child.killed, + stdout, + stderr, + stdinErrors: stdinErrors.map(({ code, message }) => ({ code, message })), + stdin: { + destroyed: child.stdin.destroyed, + writableEnded: child.stdin.writableEnded, + writableFinished: child.stdin.writableFinished, + writableLength: child.stdin.writableLength, + writableNeedDrain: child.stdin.writableNeedDrain + }, + stdoutPipe: { + destroyed: child.stdout.destroyed, + readableEnded: child.stdout.readableEnded, + readableLength: child.stdout.readableLength + }, + stderrPipe: { + destroyed: child.stderr.destroyed, + readableEnded: child.stderr.readableEnded, + readableLength: child.stderr.readableLength + } + }) child.stdin.destroy() child.kill('SIGKILL') - reject(new Error(`hook did not finish with stdin ${stdin}d`)) + reject(new Error(`hook did not finish with stdin ${stdin}d\n${diagnostic}`)) }, 10_000) child.on('error', (error) => { clearTimeout(timeout) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index 8377abd8709d..e26f3d221c62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -67,9 +67,10 @@ function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachPar it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => { const params = await adoptedParams() - const history = vi.fn(async () => null) + const history = vi.fn(async () => ({ data: [], nextCursor: null })) const codex = fakeCodex({ // Codex 0.162 answers excludeTurns resumes with metadata, not historical turns. + 'thread/turns/list': history, 'thread/resume': () => ({ thread: { id: THREAD, turns: [] }, model: 'gpt-5.4', @@ -99,7 +100,7 @@ it('imports adoption at rest and resumes its original provider identity after it resolveLaunchArgs: () => [] }) }) - await rig.restart({ adapter: Object.assign(adapter, { providerHistoryWindow: history }) }) + await rig.restart({ adapter }) expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true, value: { diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx index 4834ea2b983b..9645930b2da2 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.test.tsx @@ -58,16 +58,16 @@ function renderHeader() { it('offers the terminal header action order and targets Resume and Close at the displayed chat', () => { const source = renderHeader() expect(screen.getAllByRole('button').map((button) => button.getAttribute('aria-label'))).toEqual([ - 'Continue with Terminal Agent', + 'Show as terminal', 'Split Chat Right', 'Close tab' ]) const folder = useAppStore.getState().folderWorkspaces[0] act(() => useAppStore.setState({ folderWorkspaces: [{ ...folder, executionHostId: 'ssh:dev' }] })) - expect(screen.queryByRole('button', { name: 'Continue with Terminal Agent' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Show as terminal' })).toBeNull() act(() => useAppStore.setState({ folderWorkspaces: [folder] })) expect(fullRequest).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Continue with Terminal Agent' })) + fireEvent.click(screen.getByRole('button', { name: 'Show as terminal' })) expect(fullRequest).toHaveBeenCalledTimes(1) expect(mocks.resume).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ diff --git a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx index a22c5f8d57f6..0f7fc2182318 100644 --- a/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-header-ownership.test.tsx @@ -356,7 +356,7 @@ it('keeps the real Continue pending operation disabled across header hide/reveal mocks.launch.mockResolvedValue(undefined) const view = render(overlay(true)) const source = chat(view.container, 'chat-0') - const button = () => within(source).getByRole('button', { name: 'Continue with Terminal Agent' }) + const button = () => within(source).getByRole('button', { name: 'Show as terminal' }) expect(fullRequest).not.toHaveBeenCalled() fireEvent.click(button()) expect(button().hasAttribute('disabled')).toBe(true) From da4b3e03fab2ec22899a97ff87be0cdeea039d65 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:06:40 -0700 Subject: [PATCH 070/207] Place grouped chat terminal controls beside pane actions Place the existing switch beside split and close while retaining floating controls for ungrouped chats and Find priority. --- .../native-chat/NativeChatPaneHeader.tsx | 22 ++++++++++++++++-- ...ucturedSession.history-read-state.test.tsx | 13 +++++++++-- .../NativeChatStructuredSession.tsx | 23 +++++++++---------- .../NativeChatTopRightControls.tsx | 8 ++++--- 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx index ab301fb84a5e..50d88a513512 100644 --- a/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPaneHeader.tsx @@ -20,7 +20,8 @@ export function NativeChatPaneHeader({ groupId, nativeChat, focused, - colorScheme + colorScheme, + topRightAction }: { tabId: string worktreeId: string @@ -28,6 +29,7 @@ export function NativeChatPaneHeader({ nativeChat: NativeChatTabSession focused: boolean colorScheme: React.CSSProperties['colorScheme'] + topRightAction?: React.ReactNode }): React.JSX.Element { const actions = useStructuredChatPaneActions(tabId, worktreeId, groupId) const theme = useAppStore((state) => state.settings?.theme) @@ -45,6 +47,7 @@ export function NativeChatPaneHeader({ style={HEADER_STYLE} >
+ {topRightAction} {nativeChat.agent && agentForksConversations(nativeChat.agent) ? ( , worktreeId: string | null, - colorScheme: React.CSSProperties['colorScheme'] + colorScheme: React.CSSProperties['colorScheme'], + topRightAction?: React.ReactNode ): React.JSX.Element | null { return view.isVisible && worktreeId && view.groupId && !view.contextMenuActions ? ( ) : null } + +export function nativeChatPaneCaptureHandlers( + ...handlers: React.DOMAttributes[] +): React.DOMAttributes { + return { + onPointerDownCapture: (event) => { + handlers.forEach((handler) => handler.onPointerDownCapture?.(event)) + }, + onKeyDownCapture: (event) => { + handlers.forEach((handler) => handler.onKeyDownCapture?.(event)) + } + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index 5e3719b50a66..e75982d9f4fc 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -9,7 +9,7 @@ import { makeTabGroup } from '@/store/slices/store-test-helpers' import * as cliFork from '../tab-bar/native-chat-cli-fork-run' // @vitest-environment happy-dom -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' @@ -363,7 +363,14 @@ it('gates eligible native header hover and menu switches through capability hydr ) - expect(screen.getByRole('button', { name: 'Split Chat Right', hidden: true })).toBeTruthy() + const actions = screen + .getByRole('button', { name: 'Split Chat Right', hidden: true }) + .closest('.pane-title-actions') + if (!(actions instanceof HTMLElement)) { + throw new Error('Expected the grouped pane actions row') + } + const row = within(actions) + expect(row.getByRole('button', { name: 'Close tab', hidden: true })).toBeTruthy() const transcript = screen.getByTestId('message-list') const legacyName = 'Show as terminal' const expectHidden = () => { @@ -383,6 +390,7 @@ it('gates eligible native header hover and menu switches through capability hydr }) const loadingButtons = screen.getAllByRole('button', { name: 'Switch to CLI', hidden: true }) expect(loadingButtons).toHaveLength(2) + expect(row.getAllByRole('button', { name: 'Switch to CLI', hidden: true })).toHaveLength(1) for (const button of loadingButtons) { expect(button.hasAttribute('disabled')).toBe(true) } @@ -403,6 +411,7 @@ it('gates eligible native header hover and menu switches through capability hydr ) const buttons = screen.getAllByRole('button', { name: 'Switch to CLI', hidden: true }) expect(buttons).toHaveLength(2) + expect(row.getAllByRole('button', { name: 'Switch to CLI', hidden: true })).toHaveLength(1) expect(screen.queryByRole('button', { name: legacyName, hidden: true })).toBeNull() for (const button of buttons) { expect(button.hasAttribute('disabled')).toBe(false) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 53919e0e8fcc..54e16747b0b8 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -51,7 +51,7 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' import { NativeChatStructuredGoalStrip } from './NativeChatStructuredGoalStrip' -import { nativeChatPaneHeader } from './NativeChatPaneHeader' +import { nativeChatPaneHeader, nativeChatPaneCaptureHandlers } from './NativeChatPaneHeader' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -266,21 +266,20 @@ export function NativeChatStructuredSession( if (hostNotice) { notices.push(hostNotice) } + const controls = { find, session: props, working: controller.isWorking } + const paneHeader = nativeChatPaneHeader( + props, + ownerWorktreeId, + appearanceStyle.colorScheme, + !find.isOpen && + ) return (
{ - paneCommands.onPointerDownCapture(event) - rootInput.onPointerDownCapture(event) - }} - onKeyDownCapture={(event) => { - find.onKeyDownCapture(event) - paneCommands.onKeyDownCapture(event) - rootInput.onKeyDownCapture(event) - }} + {...nativeChatPaneCaptureHandlers(find, paneCommands, rootInput)} onContextMenuCapture={paneCommands.onContextMenuCapture} className={cn( NATIVE_CHAT_APPEARANCE_ROOT_CLASS, @@ -289,9 +288,9 @@ export function NativeChatStructuredSession( style={appearanceStyle} data-native-chat-scheme={appearanceStyle.colorScheme} > - {nativeChatPaneHeader(props, ownerWorktreeId, appearanceStyle.colorScheme)} + {paneHeader} - + {(find.isOpen || !paneHeader) && } {viewState.kind === 'loading' || (viewState.kind === 'error' && !readFailure) ? ( historyPhase !== 'unread' && ) : viewState.kind === 'error' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx index 1a8db3af1e9b..5a83ed41b23a 100644 --- a/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTopRightControls.tsx @@ -21,11 +21,13 @@ import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' export function NativeChatTopRightControls({ find, session, - working + working, + inline = false }: { find: NativeChatFind session: Pick working: boolean + inline?: boolean }): React.JSX.Element | null { const worktreeId = useNativeChatTabOwnerWorktreeId(session.tabId) // A primitive, so the selector is stable; the chat's host is where its History row lives. @@ -50,12 +52,12 @@ export function NativeChatTopRightControls({ } const label = translate('components.tab.bar.switch.toCli', 'Switch to CLI') return ( -
+