From 82544cbb3df335dc7f4c2a287c89433e2e83ddf3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:34:13 -0700 Subject: [PATCH 1/3] feat(agent-launch): host-assigned caller identity and a launch record written when the surface exists Step 1 of the agent-launch unification, on main. - The dispatcher stamps every request's caller from what its connection proved (runtime socket: the local CLI; the desktop's IPC: the desktop; a paired socket: its device). Params never set it. - The launch record is written twice: once when the tab exists (what creation settled: on the launch command, a draft, or a submit still `unconfirmed`), and again once the prompt's fate is known. A restart in between finds the running agent instead of answering "unknown". - A replay re-derives its terminal handle from the pane key in the running host, and shows `unconfirmed` only to callers that advertise agent.launch.prompt-unconfirmed.v1. - The record store opens in its own slot, without building the chat host; the chat host is built on that same store. Rebuilt from this PR's own commits (b9adf88da04, 0d7d9b5b30e, c83f44dd724, 39d15d52c0f) onto main, without #24080/#24081. Conflicts: the delivery doc table (main's "line fits" row plus the `unconfirmed` row), and main's journal-database open in install(), which now goes through the record-store slot. --- .../tasks/agent-launch-mobile-replay.test.ts | 12 +- .../mobile-agent-launch-architecture.test.ts | 18 +- .../agent-launch-executor.test.ts | 84 ++- .../agent-launch/agent-launch-executor.ts | 31 +- .../agent-launch-prompt-delivery.ts | 24 +- ...ktop-renderer-runtime-capabilities.test.ts | 15 +- .../desktop-renderer-runtime-capabilities.ts | 10 +- .../runtime-agent-launch-capability.test.ts | 23 + src/main/ipc/runtime.ts | 3 + .../agent-session-record-store-slot.test.ts | 99 ++++ .../agent-session-record-store-slot.ts | 78 +++ .../runtime/orca-runtime-get-worktree-ps.ts | 23 +- src/main/runtime/rpc/core.ts | 3 + .../runtime/rpc/dispatcher-stream-options.ts | 3 + src/main/runtime/rpc/dispatcher.ts | 2 + .../agent-launch-caller-selection.test.ts | 8 +- .../methods/agent-launch-caller-selection.ts | 9 +- .../agent-launch-pane-reservation.test.ts | 8 +- .../agent-launch-prestart-failure.test.ts | 14 +- .../rpc/methods/agent-launch-replay.test.ts | 21 +- .../rpc/methods/agent-launch-replay.ts | 130 ++-- .../agent-launch-restart-replay.test.ts | 558 ++++++++++++++++++ .../agent-launch-session-reservation.test.ts | 8 +- .../rpc/methods/agent-launch.test-fixture.ts | 31 +- src/main/runtime/rpc/methods/agent-launch.ts | 49 +- src/main/runtime/rpc/rpc-caller-identity.ts | 62 ++ .../runtime/rpc/rpc-streaming-dispatcher.ts | 3 + ...ructured-agent-session-held-signal.test.ts | 20 +- ...ured-agent-session-install-failure.test.ts | 21 +- .../structured-agent-session-runtime.ts | 33 +- src/shared/agent-launch-intent.ts | 11 +- src/shared/agent-launch-runtime-capability.ts | 8 +- 32 files changed, 1250 insertions(+), 172 deletions(-) create mode 100644 src/main/runtime/agent-session-record-store-slot.test.ts create mode 100644 src/main/runtime/agent-session-record-store-slot.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts create mode 100644 src/main/runtime/rpc/rpc-caller-identity.ts diff --git a/mobile/src/tasks/agent-launch-mobile-replay.test.ts b/mobile/src/tasks/agent-launch-mobile-replay.test.ts index 515cf353f3b4..51d790b85dd5 100644 --- a/mobile/src/tasks/agent-launch-mobile-replay.test.ts +++ b/mobile/src/tasks/agent-launch-mobile-replay.test.ts @@ -10,13 +10,14 @@ import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../src/shared/agent-session-host-authority' -import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' -import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' +import { + runtimeStub, + setAgentLaunchRecordStore +} from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' import { AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_RUNTIME_CAPABILITY @@ -40,13 +41,12 @@ beforeEach(async () => { createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-mobile-launch-replay-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only deps.store; structured session creation is the injected boundary above. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { vi.restoreAllMocks() - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts index 60f09c83a184..ff595a652bb7 100644 --- a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts +++ b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts @@ -9,9 +9,10 @@ import { DeviceRegistry } from '../../../src/main/runtime/device-registry' import type { AuthenticatedMobileSocket } from '../../../src/main/runtime/rpc/mobile-socket-wiring' import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' import { AgentLaunchFields } from '../../../src/main/runtime/rpc/methods/agent-launch-schemas' -import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' -import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { + runtimeStub, + setAgentLaunchRecordStore +} from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { createStableLogicalRpcClient } from '../transport/stable-logical-rpc-client' @@ -39,12 +40,11 @@ beforeEach(async () => { createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-launch-architecture-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads deps.store; session creation is injected above. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) @@ -243,8 +243,7 @@ describe('mobile launch retry authority', () => { const first = await dispatcher.dispatch(request) expect(first.ok).toBe(true) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) await expect( dispatcher.dispatch({ ...request, method: 'agent.launchReplay' }) ).resolves.toEqual(first) @@ -293,8 +292,7 @@ describe('mobile launch retry authority', () => { } }) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) await expect(dispatcher.dispatch(request)).resolves.toMatchObject({ ok: false, error: { code: 'worktree_create_collision' } diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index b2c50e820379..715996ac81e2 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories' -import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' +import type { AgentLaunchIntent, AgentLaunchResult } from '../../shared/agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const STRUCTURED_PREFERENCE = { @@ -29,6 +29,7 @@ function harness(options: { terminalPromptDelivered?: boolean /** Whether the surface reports that its typed line took the offered prompt. */ lineCarriesPrompt?: boolean + onSurfacePublished?: AgentLaunchExecution['onSurfacePublished'] }) { const calls: string[] = [] const carried = (startupPrompt: string | undefined) => @@ -96,7 +97,8 @@ function harness(options: { deliverStructuredPrompt, deliverTerminalPrompt }, - workspaces: { createWorktree } + workspaces: { createWorktree }, + ...(options.onSurfacePublished ? { onSurfacePublished: options.onSurfacePublished } : {}) }) } } @@ -610,3 +612,81 @@ describe('caller-supplied launch inputs', () => { expect(result.warning).toBeUndefined() }) }) + +describe('the surface is published as the launch stands, before its prompt is delivered', () => { + const PROMPTED_EXISTING: AgentLaunchIntent = { + agent: 'claude', + target: { kind: 'existing', worktree: 'wt-7' }, + prompt: { text: 'fix the build', delivery: 'submit' } + } + + function publishing(options: Parameters[0]) { + const published: AgentLaunchResult[] = [] + const launch = harness({ + ...options, + onSurfacePublished: (surface) => { + launch.calls.push('published') + published.push(surface) + } + }) + return { launch, published } + } + + it('records a prompt still owed as unconfirmed, then delivers it', async () => { + const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: false }) + + const result = await launch.run(PROMPTED_EXISTING) + + expect(launch.calls).toEqual(['createTerminalAgent', 'published', 'deliverTerminalPrompt']) + expect(published).toEqual([ + { + outcome: { kind: 'terminal', handle: 'term_1' }, + worktreeId: 'wt-7', + receipt: result.receipt, + // A host that stops mid-paste cannot say whether it landed, so it must not say "not sent". + prompt: { delivery: 'submit', outcome: 'unconfirmed' } + } + ]) + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + }) + + it('records a draft as not delivered, since the host never delivers one', async () => { + const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: false }) + + const result = await launch.run({ + ...PROMPTED_EXISTING, + prompt: { text: 'fix the build', delivery: 'draft' } + }) + + expect(published[0]?.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' }) + expect(result.prompt).toEqual({ delivery: 'draft', outcome: 'not-delivered' }) + }) + + it('records a prompt the launch command carried as already handed over', async () => { + const { launch, published } = publishing({ settings: {}, lineCarriesPrompt: true }) + + const result = await launch.run(PROMPTED_EXISTING) + + expect(published[0]?.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(published[0]).toEqual(result) + expect(launch.deliverTerminalPrompt).not.toHaveBeenCalled() + }) + + it('records a chat before its first message is committed', async () => { + const { launch, published } = publishing({}) + + const result = await launch.run(PROMPTED_EXISTING) + + expect(launch.calls).toEqual([ + 'createSupport', + 'createStructuredSession', + 'published', + 'deliverStructuredPrompt' + ]) + expect(published[0]).toEqual({ + ...result, + prompt: { delivery: 'submit', outcome: 'unconfirmed' } + }) + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 755bd55e2767..67cb117f9348 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -39,6 +39,7 @@ import { HANDED_TO_TERMINAL, launchCommandPrompt, promptReceipt, + settledAtCreation, settleLaunchPromptDisposal } from './agent-launch-prompt-delivery' import type { TuiAgent } from '../../shared/tui-agent' @@ -75,7 +76,13 @@ export type AgentLaunchExecution = { onSurfacePublished?: (surface: AgentLaunchPublishedSurface) => void } -export type AgentLaunchPublishedSurface = Pick +/** + * The launch as it stands once its surface exists: a complete result whose prompt receipt says only + * what creation itself settled — carried on the launch command, a draft the host never delivers, or + * a submit still `unconfirmed`. Complete so a host that dies during the delivery still leaves a + * truthful answer behind. + */ +export type AgentLaunchPublishedSurface = AgentLaunchResult export async function executeAgentLaunch( execution: AgentLaunchExecution @@ -105,11 +112,12 @@ export async function executeAgentLaunch( if (intent.reuseTerminal) { const reused = published(execution, { outcome: { kind: 'terminal', handle: intent.reuseTerminal.handle }, - worktreeId: existingWorktreeId(intent.target) + worktreeId: existingWorktreeId(intent.target), + receipt: preflight, + ...promptReceipt(intent, settledAtCreation(intent, {})) }) return { ...reused, - receipt: preflight, ...promptReceipt( intent, await deliverTerminalLaunchPrompt(execution, intent.reuseTerminal.handle, { @@ -128,12 +136,13 @@ export async function executeAgentLaunch( handle: placed.startupTerminalHandle, ...(placed.startupTerminalPaneKey ? { paneKey: placed.startupTerminalPaneKey } : {}) }, - worktreeId: placed.worktreeId + worktreeId: placed.worktreeId, + receipt: preflight, + ...(placed.warning ? { warning: placed.warning } : {}), + ...promptReceipt(intent, settledAtCreation(intent, placed)) }) return { ...startup, - receipt: preflight, - ...(placed.warning ? { warning: placed.warning } : {}), ...promptReceipt( intent, placed.promptRodeLaunchCommand @@ -183,11 +192,15 @@ export async function executeAgentLaunch( // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. const warning = combineLaunchWarnings(placed.warning, created.warning) - const surface = published(execution, { outcome: created.outcome, worktreeId: placed.worktreeId }) - return { - ...surface, + const surface = published(execution, { + outcome: created.outcome, + worktreeId: placed.worktreeId, receipt: settled, ...(warning ? { warning } : {}), + ...promptReceipt(intent, settledAtCreation(intent, created)) + }) + return { + ...surface, ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) } } diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index b36519013761..a1e062b8cbce 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -9,6 +9,7 @@ * terminal, line fits -> folded into the command that execs the agent -> handed-to-terminal * terminal, otherwise -> bracketed paste into the live PTY once it is ready -> handed-to-terminal * anything unproven -> -> not-delivered + * host stopped mid-delivery (a replayed record only) -> unconfirmed * * argv has no readiness race, so it is offered wherever the agent's CLI takes a prompt argument * (`agentPromptRidesLaunchCommand`). But that command is TYPED into the user's shell, and a long or @@ -28,6 +29,23 @@ import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factor export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' } const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' } +const UNCONFIRMED: AgentLaunchPromptDisposal = { outcome: 'unconfirmed' } + +/** + * What the record may say before any delivery runs: only what creating the surface already settled. + * A launch command that carried the text has handed it over, and a draft is never delivered by the + * host. A submit still owed is `unconfirmed`: a host that stops mid-delivery cannot say whether the + * paste or the commit landed, and "not delivered" would invite a duplicate turn. + */ +export function settledAtCreation( + intent: Pick, + created: { promptRodeLaunchCommand?: boolean } +): AgentLaunchPromptDisposal { + if (created.promptRodeLaunchCommand) { + return HANDED_TO_TERMINAL + } + return intent.prompt?.delivery === 'submit' ? UNCONFIRMED : NOT_DELIVERED +} /** Each surface delivers its own way, so the disposal is decided where the surface is known. */ export async function settleLaunchPromptDisposal( @@ -127,9 +145,9 @@ export function launchCommandPrompt( * Each arm is a consequence of the act it names, never a write-ahead of it: `journaled` is * reachable only from a committed message id, `handed-to-terminal` only from a launch command that * carried the text or a PTY write that returned, and everything else under-claims as - * `not-delivered`. There is deliberately no arm for "maybe" — a caller holding one could neither - * resend nor drop the text. Dispatch doubt is not this tier's to report: the submission row - * carries it. + * `not-delivered`. A live answer has no "maybe": `unconfirmed` is written only into the record + * before delivery runs (`settledAtCreation`), and is read back only by a replay. Dispatch doubt is + * not this tier's to report: the submission row carries it. */ export function promptReceipt( intent: AgentLaunchIntent, diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 7c9df1e6bc2a..3eedd3c80501 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -22,7 +22,10 @@ import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from '../../shared/agent-launch-runtime-capability' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' @@ -63,6 +66,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY ] +/** Advertised to main and deliberately NOT to a remote host yet. */ +const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ + // Read by the desktop's own launches first; a remote host is told when its launches move over. + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY +] + function missingFrom( source: readonly RuntimeCapability[], other: readonly RuntimeCapability[] @@ -104,9 +113,9 @@ describe('desktop renderer runtime client capabilities', () => { expect(missingFrom(PAIRED_HOST_RECEIVES, DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES)).toEqual( [...REMOTE_ONLY_BY_DECISION].sort() ) - // The same renderer reads structured chats on either host, so it claims nothing only locally. + // The same renderer reads structured chats on either host, so only a recorded decision is local. expect(missingFrom(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES, PAIRED_HOST_RECEIVES)).toEqual( - [] + [...LOCAL_ONLY_BY_DECISION].sort() ) }) }) diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 64cda2148ed6..b7a34a12ac43 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -10,7 +10,10 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' /** @@ -39,5 +42,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer // targeting a remote host is admitted — the asymmetry this constant exists to close. - AGENT_LAUNCH_RUNTIME_CAPABILITY + AGENT_LAUNCH_RUNTIME_CAPABILITY, + // A replay after a restart mid-delivery answers the running agent with an `unconfirmed` prompt, + // which the desktop reads, rather than refusing it as unknown. + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY ] as const diff --git a/src/main/ipc/runtime-agent-launch-capability.test.ts b/src/main/ipc/runtime-agent-launch-capability.test.ts index 6c85455a324d..504b1447ad04 100644 --- a/src/main/ipc/runtime-agent-launch-capability.test.ts +++ b/src/main/ipc/runtime-agent-launch-capability.test.ts @@ -8,10 +8,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeCapability } from '../../shared/protocol-version' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../shared/agent-launch-runtime-capability' +import { + resolveRpcCallerIdentity, + rpcCallerOperationKey, + type RpcCallerIdentity +} from '../runtime/rpc/rpc-caller-identity' type AdvertisedClient = { clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] + caller?: RpcCallerIdentity + pairedDeviceId?: string } const { handlers, advertised } = vi.hoisted( @@ -57,6 +64,7 @@ vi.mock('../runtime/rpc/dispatcher', () => ({ const { registerRuntimeHandlers } = await import('./runtime') const { supportsAgentLaunch } = await import('../runtime/rpc/methods/agent-launch') +const { agentLaunchOperationCallerKey } = await import('../runtime/rpc/methods/agent-launch-replay') function rendererEvent() { const mainFrame = {} @@ -126,4 +134,19 @@ describe('desktop renderer reaching agent.launch on its own main process', () => ) expect(supportsAgentLaunch(streaming)).toBe(true) }) + + it('names the desktop as its caller on both paths, so its launches replay under one identity', () => { + invoke('runtime:call', { method: 'status.get' }) + invoke('runtime:subscribe', { subscriptionId: 'sub-1', method: 'session.tabs.watch' }) + + for (const client of [advertised.unary, advertised.streaming].map(onlyAdvertisedClient)) { + const caller = resolveRpcCallerIdentity(client) + expect(caller).toEqual({ kind: 'desktop' }) + expect(agentLaunchOperationCallerKey({ caller })).toBe('trusted-local:desktop') + } + // Negative control: without the transport's word the same renderer has no identity at all. + const { caller: _named, ...unnamed } = onlyAdvertisedClient(advertised.unary) + expect(resolveRpcCallerIdentity(unnamed)).toBeUndefined() + expect(rpcCallerOperationKey({ kind: 'local-cli' })).toBe('trusted-local:runtime') + }) }) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index e768de463d85..1736cbc6f60f 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -12,6 +12,7 @@ import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-br import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { DESKTOP_RPC_CALLER } from '../runtime/rpc/rpc-caller-identity' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' @@ -78,6 +79,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { }, { clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, clientKind: 'runtime', connectionId: desktopSenders.connectionIdFor(event.sender), clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES @@ -120,6 +122,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { { signal: controller.signal, clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, clientKind: 'runtime', connectionId, clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES diff --git a/src/main/runtime/agent-session-record-store-slot.test.ts b/src/main/runtime/agent-session-record-store-slot.test.ts new file mode 100644 index 000000000000..12be381a787f --- /dev/null +++ b/src/main/runtime/agent-session-record-store-slot.test.ts @@ -0,0 +1,99 @@ +/** + * The record store opened on its own, for launch admission, and the chat host built on it later. + * The store is a single writer, so the property that matters is that there is only ever one. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { openAgentSessionRecordStoreOnce } from './agent-session-record-store-slot' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const HOST_ID = 'local' +let stateDirectory: string + +function location(directory = stateDirectory) { + return { + stateDirectory: directory, + hostId: HOST_ID, + logger: createStructuredAgentSessionLogger() + } +} + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-record-store-slot-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + vi.restoreAllMocks() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +describe('the record store slot', () => { + it('hands every caller the same store', async () => { + const [first, second] = await Promise.all([ + openAgentSessionRecordStoreOnce(location()), + openAgentSessionRecordStoreOnce(location()) + ]) + + expect(second.store).toBe(first.store) + }) + + it('builds a chat host installed after admission on the store admission opened', async () => { + const { store } = await openAgentSessionRecordStoreOnce(location()) + const close = vi.spyOn(JournalHostDatabase.prototype, 'close') + + const host = await ensureStructuredAgentSessionHost({ + ...location(), + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({}) + }) + expect(host.deps.store).toBe(store) + + await stopStructuredAgentSessionRuntime() + // The host's teardown closes the one connection; stop does not close it a second time. + expect(close).toHaveBeenCalledOnce() + }) + + it('closes a store no chat host was built on when the runtime stops', async () => { + const { store } = await openAgentSessionRecordStoreOnce(location()) + const close = vi.spyOn(JournalHostDatabase.prototype, 'close') + + await stopStructuredAgentSessionRuntime() + + expect(close).toHaveBeenCalledOnce() + const reopened = await openAgentSessionRecordStoreOnce(location()) + expect(reopened.store).not.toBe(store) + }) + + it('refuses a second profile rather than opening a second store', async () => { + await openAgentSessionRecordStoreOnce(location()) + const elsewhere = await mkdtemp(join(tmpdir(), 'orca-record-store-slot-other-')) + try { + await expect(openAgentSessionRecordStoreOnce(location(elsewhere))).rejects.toThrow( + 'agent_session_record_store_location_changed' + ) + } finally { + await rm(elsewhere, { recursive: true, force: true }) + } + }) + + it('lets the next caller retry after an open fails', async () => { + const notADirectory = join(stateDirectory, 'profile-file') + await writeFile(notADirectory, 'not a profile') + + await expect(openAgentSessionRecordStoreOnce(location(notADirectory))).rejects.toThrow() + await expect(openAgentSessionRecordStoreOnce(location())).resolves.toMatchObject({ + store: expect.anything() + }) + }) +}) diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts new file mode 100644 index 000000000000..3e74f88282ca --- /dev/null +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -0,0 +1,78 @@ +// The process's one durable agent-session record store, opened on its own. +// +// The launch ledger lives in this store, and admitting a launch is the first thing every replay-safe +// `agent.launch` does — a terminal launch included. Reaching the store used to mean installing the +// whole chat host (provider adapters, the host, the model catalog) first. The store is opened here +// instead and the chat host, when something needs it, is built on this same instance: the store is +// a single writer, so a second copy of it would diverge. +// +// Known edges, both reachable only once quit has begun (stop has no other production caller): a +// stop whose host teardown fails empties the slot while that host still holds its store open, so a +// later admission would open a second one; and a store opened after stop is closed by nothing but +// process exit. + +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' + +export type AgentSessionRecordStoreLocation = { + stateDirectory: string + hostId: string + logger: StructuredAgentSessionLogger +} + +export type OpenedAgentSessionRecordStore = { + journalDatabase: JournalHostDatabase + store: AgentSessionRecordStore +} + +let opening: { stateDirectory: string; opened: Promise } | null = + null + +export function openAgentSessionRecordStoreOnce( + location: AgentSessionRecordStoreLocation +): Promise { + if (opening) { + // Loud rather than a silent second store: one process serves one profile. + if (opening.stateDirectory !== location.stateDirectory) { + return Promise.reject(new Error('agent_session_record_store_location_changed')) + } + return opening.opened + } + const slot = { + stateDirectory: location.stateDirectory, + opened: openRecordStore(location).catch((error: unknown) => { + // A failed open must not poison the slot: the next caller retries. + if (opening === slot) { + opening = null + } + throw error + }) + } + opening = slot + return slot.opened +} + +async function openRecordStore( + location: AgentSessionRecordStoreLocation +): Promise { + const journalDatabase = await openStructuredAgentSessionJournalDatabase(location) + try { + return { + journalDatabase, + store: AgentSessionRecordStore.open({ journalDatabase, hostId: location.hostId }) + } + } catch (error) { + journalDatabase.close() + throw error + } +} + +/** Empties the slot and hands back what it held, for whoever closes its database: the chat host's + * teardown when one was built on it, the caller otherwise. */ +export async function releaseAgentSessionRecordStore(): Promise { + const released = opening + opening = null + return released ? released.opened.catch(() => null) : null +} diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f299e17b82ee..a4e1b6b454a0 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -14,7 +14,12 @@ import { compareWorktreePs } from './runtime-worktree-status-projection' import type { Repo } from '../../shared/repo-types' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger +} from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { openAgentSessionRecordStoreOnce } from './agent-session-record-store-slot' +import type { AgentSessionRecordStore } from './agent-session-record-store' import { maybeAutoRenameWorkspaceOnFirstStructuredTurn } from '../agent-hooks/first-work-structured-session-rename' import { firstWorkRenameDeps } from '../agent-hooks/first-work-rename-runtime' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' @@ -134,11 +139,23 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis }) } + /** The durable record store alone, without the chat host: launch admission needs only its + * ledger. A host installed later is built on this same store. */ + async openAgentSessionRecordStore(): Promise { + const { store } = await openAgentSessionRecordStoreOnce({ + stateDirectory: getProfileUserDataPath(), + hostId: LOCAL_EXECUTION_HOST_ID, + logger: neverThrowingStructuredAgentSessionLogger(createStructuredAgentSessionLogger()) + }) + return store + } + /** * Installs the structured agent-session host on first use. Lazy for the same * reason the orchestration DB is: the profile's user-data path is not final - * until the app is ready, and a runtime nobody drives a chat session on - * should never open the record store. + * until the app is ready, and a runtime nobody drives a chat session on never + * builds the chat host. The record store it sits on may already be open, from + * a launch's admission. */ async ensureStructuredAgentSessionHost(): Promise { await installStructuredAgentSessionHost({ diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 1402f10b2231..006bb7a982d3 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -11,6 +11,7 @@ import type { import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' +import type { RpcCallerIdentity } from './rpc-caller-identity' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -75,6 +76,8 @@ export type RpcContext = { clientId?: string // Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id. pairedDeviceId?: string + // Why: host-assigned from the transport (rpc-caller-identity.ts); absent when the transport could not name its caller. + caller?: RpcCallerIdentity // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index e3151c66b0e7..cca01d83588a 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -1,6 +1,7 @@ import type { RuntimeCapability } from '../../../shared/protocol-version' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { PairingRpcContext } from './core' +import type { RpcCallerIdentity } from './rpc-caller-identity' export type RpcDispatchStreamingOptions = { authenticatedCallerFingerprint?: string @@ -8,6 +9,8 @@ export type RpcDispatchStreamingOptions = { signal?: AbortSignal clientId?: string pairedDeviceId?: string + /** Set by a transport that knows its caller but carries no paired device (the desktop's IPC). */ + caller?: RpcCallerIdentity clientKind?: 'mobile' | 'runtime' clientCapabilities?: readonly RuntimeCapability[] updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 04600fcc4ecd..4c87ceaaf509 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -24,6 +24,7 @@ import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' +import { resolveRpcCallerIdentity } from './rpc-caller-identity' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -116,6 +117,7 @@ export class RpcDispatcher { : undefined, requestId: request.id, clientId: options?.clientId, + caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 584cd4f72e6c..76afbc093349 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -9,8 +9,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' import { CAPABLE_CLIENT, @@ -18,6 +16,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' @@ -209,12 +208,11 @@ describe('a replayed launch', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-caller-')) const store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index a8d913526cb0..2260c1af2f2c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -17,12 +17,11 @@ import type { RpcContext } from '../core' /** The paired client whose view this launch should move, or null when it moves the host's. */ export function agentLaunchCallerNavigationId( target: AgentLaunchTarget, - context: Pick + context: Pick ): string | null { - if (target.kind !== 'existing' || context.clientKind === undefined) { - return null - } - return context.pairedDeviceId?.trim() || null + return target.kind === 'existing' && context.caller?.kind === 'paired-device' + ? context.caller.deviceId + : null } /** Bookkeeping, never a gate: the agent already runs, so a failure here only leaves the view as it was. */ diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts index 3cc0806f680c..e053d64461d4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -14,8 +14,6 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -25,6 +23,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub as RuntimeStub } from './agent-launch.test-fixture' @@ -223,12 +222,11 @@ describe('a live-pane refusal under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-pane-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index 15a8b3a84996..6ea556fecb15 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -12,11 +12,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' -import { methodNamed, runtimeStub, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' +import { + methodNamed, + runtimeStub, + setAgentLaunchRecordStore, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' vi.mock('./structured-agent-session-create', () => ({ createStructuredAgentSessionForWorktree: async () => ({ @@ -64,12 +67,11 @@ describe('a launch whose terminal fails', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 813c76800cd4..7d18b2d2f7c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -31,8 +31,6 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from '../../agent-session-record-store-test-harness' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' @@ -40,6 +38,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub } from './agent-launch.test-fixture' @@ -126,14 +125,12 @@ beforeEach(async () => { createStructuredSession.mockClear() directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-replay-')) store = await openTestAgentSessionRecordStore(directory) - // The launch reaches the ledger through the installed host; nothing else on the host is used, - // because the structured create below it is mocked out. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + // The ledger alone, as admission opens it: no chat host is installed. + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) @@ -290,10 +287,7 @@ describe('a replay answers from the record', () => { const first = await launch(params, runtime) const reopened = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. - setStructuredAgentSessionHost({ - deps: { store: reopened } - } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(reopened) const afterRestart = runtimeStub() expect(await launch(params, afterRestart)).toEqual(first) @@ -504,10 +498,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => await rewriteRecordedLaunch({ outcome: { kind: 'structured' }, worktreeId: 'wt-1' }) const reopened = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. - setStructuredAgentSessionHost({ - deps: { store: reopened } - } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(reopened) expect(reopened.listOperationRows()).toHaveLength(1) const retry = runtimeStub() diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index bb87d8098a9b..ea8d79fad5f2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -16,43 +16,26 @@ */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' +import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { isAgentLaunchResult, type AgentLaunchResult } from '../../../../shared/agent-launch-intent' import type { AgentSessionOperationOutcome, AgentSessionOperationRefusalCode } from '../../../../shared/agent-session-operation-ledger' import { resolveAgentSessionReplayOutcome } from '../../../native-chat/agent-session-wire/structured-agent-session-replay-outcome' -import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' import type { RpcContext } from '../core' +import { rpcCallerOperationKey } from '../rpc-caller-identity' import type { AgentLaunchParams } from './agent-launch-schemas' -/** Remote replay needs the paired-device subject because its bearer credential can rotate. */ -export function agentLaunchOperationCallerKey( - context: Pick -): string { - if (context.clientKind === undefined) { - return 'trusted-local:runtime' - } - const pairedDeviceId = context.pairedDeviceId?.trim() - if (!pairedDeviceId) { - throw new Error('agent_session_identity_required') - } - return pairedDeviceId -} - /** - * The store is owned by the structured session host, so reaching it installs that host — already - * true of any structured launch, which installs it to attach. The change is that a terminal-bound - * launch now opens the record store too, when and only when its caller asked for replay safety. + * The ledger namespace of whoever the transport says is calling. A transport that could not name its + * caller gets no replay safety at all, rather than a namespace shared with strangers. */ -async function requireLaunchOperationStore(context: RpcContext): Promise { - await context.runtime.ensureStructuredAgentSessionHost() - const host = getStructuredAgentSessionHost() - if (!host) { - throw new Error('structured_agent_session_unsupported') +export function agentLaunchOperationCallerKey(context: Pick): string { + if (!context.caller) { + throw new Error('agent_session_identity_required') } - return host.deps.store + return rpcCallerOperationKey(context.caller) } /** @@ -67,6 +50,9 @@ export type AgentLaunchAdmission = /** This caller owns the operation. It alone runs the effect, and it must settle the row. */ | { decision: 'execute' + /** The surface exists: records the launch as it stands, so a restart before `settle` replays + * the running agent instead of refusing an unknown outcome. */ + record: (provisional: AgentLaunchResult) => Promise settle: (result: AgentLaunchResult) => Promise fail: (code: string) => Promise /** Distinct from the launch id: the inner attach reserves in this same ledger. */ @@ -115,6 +101,62 @@ function answerFromRecordedRow( : { decision: 'refuse', refusal: replay.refusal } } +/** The CLI (no declared client) ships with this host; any other caller must say it reads the word. */ +function readsUnconfirmedLaunchPrompt( + context: Pick +): boolean { + return ( + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY) === + true + ) +} + +/** + * The handle this runtime uses for the recorded pane now. Usually that is the recorded handle: the + * daemon and the SSH relay keep each PTY's handle, and a restarted runtime re-adopts it. It does not + * when the PTY's incarnation changed, when it already issued that pane another handle before reading + * the inventory, or when the PTY stored none; the pane key, the durable name, finds it then. A pane + * this runtime no longer knows keeps the recorded handle, which reads as an exited terminal — the + * truth about one that is gone. The handle stays because shipped clients require one. + */ +function withLiveTerminalHandle( + recorded: AgentLaunchResult, + runtime: Pick +): AgentLaunchResult { + const { outcome } = recorded + if (outcome.kind !== 'terminal' || !outcome.paneKey) { + return recorded + } + const handle = runtime.getTerminalHandleForPaneKey(outcome.paneKey) + return handle && handle !== outcome.handle + ? { ...recorded, outcome: { ...outcome, handle } } + : recorded +} + +/** + * A recorded answer as this caller may read it now. A prompt the host stopped delivering is + * `unconfirmed` only to a caller that reads the word; every other caller gets the refusal it got + * before the first write existed, never a `not-delivered` that invites a duplicate turn. + */ +function presentRecordedAnswer( + context: RpcContext, + operationId: string, + answer: AgentLaunchAdmission +): AgentLaunchAdmission { + if (answer.decision !== 'replay') { + return answer + } + if (answer.result.prompt?.outcome === 'unconfirmed' && !readsUnconfirmedLaunchPrompt(context)) { + return refusal( + operationId, + 'agent_session_operation_unknown', + 'started its agent, but whether its prompt arrived is unknown' + ) + } + return { decision: 'replay', result: withLiveTerminalHandle(answer.result, context.runtime) } +} + /** * Admit, then claim, in one durable transaction so a launch writes the ledger once before its effect. * @@ -134,8 +176,9 @@ export async function admitAgentLaunchOperation( if (!attachOperationId) { return refusal(operationId, 'agent_session_operation_invalid', 'is not a durable operation id') } - const store = await requireLaunchOperationStore(context) const callerKey = agentLaunchOperationCallerKey(context) + // The ledger alone: admitting a terminal launch has no use for the chat host. + const store = await context.runtime.openAgentSessionRecordStore() const { decision: admitted, claim } = await store.admitAndClaimOperation( { callerKey, operationId, fingerprint, now }, // A fresh row, or a replayed one no one has answered yet, leaves the right to run open. @@ -150,7 +193,7 @@ export async function admitAgentLaunchOperation( if (admitted.decision === 'replay') { const answer = answerFromRecordedRow(operationId, admitted.row.outcome) if (answer) { - return answer + return presentRecordedAnswer(context, operationId, answer) } } // Unreachable with both steps in one transaction; answered as uncertain rather than run twice. @@ -164,26 +207,29 @@ export async function admitAgentLaunchOperation( if (claim.claim === 'lost') { // The handler joins same-process retries before admission. Reaching a claimed row here means // this runtime did not start it, so treating it as restart uncertainty is the safe answer. - return ( - answerFromRecordedRow(operationId, claim.row.outcome) ?? - refusal(operationId, 'agent_session_operation_unknown', 'is claimed but unsettled') - ) + const answer = answerFromRecordedRow(operationId, claim.row.outcome) + return answer + ? presentRecordedAnswer(context, operationId, answer) + : refusal(operationId, 'agent_session_operation_unknown', 'is claimed but unsettled') } + const succeeded = (result: AgentLaunchResult) => + store.recordOperationOutcome({ + callerKey, + operationId, + outcome: { + status: 'succeeded', + // A terminal surface has a handle, not a session id; `launch` carries whichever it is. + sessionId: result.outcome.kind === 'structured' ? result.outcome.sessionId : '', + launch: result + } + }) return { decision: 'execute', attachOperationId, callerKey, - settle: (result) => - store.recordOperationOutcome({ - callerKey, - operationId, - outcome: { - status: 'succeeded', - // A terminal surface has a handle, not a session id; `launch` carries whichever it is. - sessionId: result.outcome.kind === 'structured' ? result.outcome.sessionId : '', - launch: result - } - }), + // The same row shape twice: a build that predates the first write reads either one. + record: succeeded, + settle: succeeded, fail: (code) => store.recordOperationOutcome({ callerKey, diff --git a/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts new file mode 100644 index 000000000000..16abdd519933 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-restart-replay.test.ts @@ -0,0 +1,558 @@ +/** + * A launch's record across a host restart, against the real durable ledger. + * + * The record is written twice: once when the surface exists and once when the prompt's fate is + * known. A host that dies at any point leaves the replay a truthful answer — the running agent once + * its surface is recorded, an honest "unknown" before — and never a second agent. A "restart" here + * is what a new process sees: the store reopened from disk and a runtime with no in-flight launches. + * Its runtime issues a surviving pane a new handle — the case where the daemon's or relay's handle + * was not re-adopted, the only one in which re-deriving the handle from the pane key changes it. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY +} from '../../../../shared/agent-launch-runtime-capability' +import { isAgentLaunchResult, type AgentLaunchResult } from '../../../../shared/agent-launch-intent' +import type { AgentSessionOperationRow } from '../../../../shared/agent-session-operation-ledger' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import { + methodNamed, + rpcContext, + runtimeStub, + setAgentLaunchRecordStore, + type AgentLaunchRuntimeStub +} from './agent-launch.test-fixture' + +const deliverTerminalPrompt = vi.hoisted(() => + vi.fn(async (_args: { handle: string }): Promise => true) +) +vi.mock('./agent-launch-terminal-prompt', () => ({ + deliverTerminalAgentLaunchPrompt: deliverTerminalPrompt +})) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +// The ledger admits against `Date.now()`, so the id must be dated now. +const OPERATION_ID = `${Date.now()}-000000000000000000000000000000ee` +const PANE_KEY = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d:3f2504e0-4f89-41d3-9a0c-0305e82c3301' +// No structured preference: every launch here is a terminal agent, the surface a restart outlives. +const TERMINAL_ONLY = {} +const PROMPTED_LAUNCH = { + agent: 'claude', + target: { kind: 'existing', worktree: 'id:wt-7' }, + prompt: { text: 'fix the failing test', delivery: 'submit' }, + operationId: OPERATION_ID +} +const PHONE: Partial = { + clientKind: 'mobile', + pairedDeviceId: 'device-1', + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] +} +/** The same paired device on a build that reads an `unconfirmed` prompt. */ +const UPGRADED_PHONE: Partial = { + ...PHONE, + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY + ] +} +/** Exactly what the desktop's `runtime:call` handler hands the dispatcher. */ +const DESKTOP_IPC = { + clientId: 'desktop-renderer', + caller: DESKTOP_RPC_CALLER, + clientKind: 'runtime' as const, + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES +} +/** The handle a restarted host issues a pane that outlived the old one, when it could not + * re-adopt that pane's recorded handle. */ +const ADOPTED_HANDLE = 'term_adopted' + +let directory: string +let store: AgentSessionRecordStore + +function hostRuntime(adoptedPanes?: Record) { + // A phone's launch into an existing workspace also moves the phone's own view to the new tab. + return Object.assign( + // The paste path: the carry rule leaves this prompt for after the agent is ready. + runtimeStub({ + settings: TERMINAL_ONLY, + terminalPaneKey: PANE_KEY, + adoptedPanes, + lineCarriesPrompt: false + }), + { selectCreatedMobileSessionTabForClient: vi.fn(() => true) } + ) +} + +/** The host after a restart, still running the pane the dead one launched. */ +function restartedHostRuntime() { + return hostRuntime({ [PANE_KEY]: ADOPTED_HANDLE }) +} + +const UNCONFIRMED_AGENT = { + outcome: { kind: 'terminal', handle: ADOPTED_HANDLE, paneKey: PANE_KEY }, + worktreeId: 'wt-7', + receipt: expect.objectContaining({ mode: 'terminal' }), + prompt: { delivery: 'submit', outcome: 'unconfirmed' } +} + +function launch( + runtime: AgentLaunchRuntimeStub, + params: unknown = PROMPTED_LAUNCH, + context: Partial = PHONE +): Promise { + return AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse(params), + rpcContext(runtime, context) + ) +} + +/** A new process: the store reread from disk, and nothing in flight. */ +async function restartHost(): Promise { + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +} + +function row(callerKey = 'device-1'): AgentSessionOperationRow | null { + return store.getOperationRow(callerKey, OPERATION_ID) +} + +/** The first write is fired, not awaited, so it lands a moment after the tab is published. */ +async function untilRecorded(status: AgentSessionOperationRow['outcome']['status']): Promise { + const deadline = Date.now() + 2_000 + while (row()?.outcome.status !== status) { + if (Date.now() > deadline) { + throw new Error(`the launch row never reached ${status}`) + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +/** The store commits in order, so a write queued now lands after every write already fired. */ +async function ledgerWritesQueuedBefore(ledger: AgentSessionRecordStore): Promise { + await ledger.recordOperationOutcome({ + callerKey: 'no-such-caller', + operationId: OPERATION_ID, + outcome: { status: 'unknown' } + }) +} + +/** Launches with a paste that never returns: the host dies while it waits for the agent. */ +async function launchUntilPasteStarts(runtime: AgentLaunchRuntimeStub): Promise { + let pasting: () => void = () => {} + const pasteStarted = new Promise((resolve) => { + pasting = resolve + }) + deliverTerminalPrompt.mockImplementationOnce(() => { + pasting() + return new Promise(() => {}) + }) + void launch(runtime) + await pasteStarted + await ledgerWritesQueuedBefore(store) +} + +/** The ledger as the launch reaches it, with the launch's `nth` write (1-based) replaced. */ +function replaceLaunchWrite( + ledger: AgentSessionRecordStore, + nth: number, + write: () => Promise +): void { + const recordOperationOutcome = ledger.recordOperationOutcome.bind(ledger) + let launchWrites = 0 + vi.spyOn(ledger, 'recordOperationOutcome').mockImplementation((input) => { + if (input.operationId === OPERATION_ID && input.callerKey === 'device-1') { + launchWrites += 1 + if (launchWrites === nth) { + return write() + } + } + return recordOperationOutcome(input) + }) +} + +function promptOutcome(outcome: AgentSessionOperationRow['outcome'] | undefined): unknown { + return outcome?.status === 'succeeded' && isAgentLaunchResult(outcome.launch) + ? outcome.launch.prompt?.outcome + : undefined +} + +function dispatcherFor(runtime: AgentLaunchRuntimeStub): RpcDispatcher { + return new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method agent.launch reaches, plus the id the dispatcher stamps on replies. + runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService, + methods: AGENT_LAUNCH_METHODS + }) +} + +beforeEach(async () => { + deliverTerminalPrompt.mockReset() + deliverTerminalPrompt.mockResolvedValue(true) + directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-restart-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +}) + +afterEach(async () => { + setAgentLaunchRecordStore(null) + await rm(directory, { recursive: true, force: true }) +}) + +describe('a host restart mid-launch', () => { + it('finds the running agent, under the handle the new host issued, after a restart mid-paste', async () => { + const dying = hostRuntime() + await launchUntilPasteStarts(dying) + + await restartHost() + const restarted = restartedHostRuntime() + + // The dead host's `term_1` was not re-adopted; the pane is the durable name. + await expect(launch(restarted, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toEqual( + UNCONFIRMED_AGENT + ) + expect(restarted.createTerminal).not.toHaveBeenCalled() + expect(dying.createTerminal).toHaveBeenCalledOnce() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('answers a caller that cannot read "unconfirmed" as before: unknown, never "not sent"', async () => { + await launchUntilPasteStarts(hostRuntime()) + + await restartHost() + const restarted = restartedHostRuntime() + + await expect(launch(restarted)).rejects.toThrow('agent_session_operation_unknown') + // The same row still answers a caller that reads the word: the refusal is presentation only. + await expect(launch(restarted, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toEqual( + UNCONFIRMED_AGENT + ) + expect(restarted.createTerminal).not.toHaveBeenCalled() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('stays unconfirmed when the host died after the paste returned, before the final write', async () => { + let pasted: () => void = () => {} + const pasteReturned = new Promise((resolve) => { + pasted = resolve + }) + deliverTerminalPrompt.mockImplementationOnce(async () => { + pasted() + return true + }) + // The final write never lands: the process is gone by then. + replaceLaunchWrite(store, 2, () => new Promise(() => {})) + void launch(hostRuntime()) + await pasteReturned + await untilRecorded('succeeded') + await ledgerWritesQueuedBefore(store) + + await restartHost() + const restarted = restartedHostRuntime() + + await expect(launch(restarted, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toEqual( + UNCONFIRMED_AGENT + ) + await expect(launch(restarted)).rejects.toThrow('agent_session_operation_unknown') + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('keeps the recorded handle when the restarted host no longer has the pane', async () => { + await launchUntilPasteStarts(hostRuntime()) + + await restartHost() + + await expect(launch(hostRuntime(), PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toMatchObject({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: PANE_KEY } + }) + }) + + it('stays unknown when the host died before the surface was recorded', async () => { + const dying = hostRuntime() + let spawnRequested: () => void = () => {} + const spawning = new Promise((resolve) => { + spawnRequested = resolve + }) + dying.createTerminal.mockImplementationOnce(() => { + spawnRequested() + return new Promise(() => {}) + }) + void launch(dying) + await spawning + + await restartHost() + const restarted = hostRuntime() + + // A pane may exist that nothing recorded; "unknown" is the truthful answer, never a relaunch. + await expect(launch(restarted)).rejects.toThrow('agent_session_operation_unknown') + expect(row()?.outcome.status).toBe('unknown') + expect(restarted.createTerminal).not.toHaveBeenCalled() + }) + + it('replays the final answer when the host died after the prompt landed', async () => { + const first = await launch(hostRuntime()) + expect(first.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + + await restartHost() + const restarted = restartedHostRuntime() + + await expect(launch(restarted)).resolves.toEqual({ + ...first, + outcome: { ...first.outcome, handle: ADOPTED_HANDLE } + }) + expect(restarted.createTerminal).not.toHaveBeenCalled() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('lets the final write replace the first, never the other way round', async () => { + let releasePaste: (pasted: boolean) => void = () => {} + deliverTerminalPrompt.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePaste = resolve + }) + ) + const running = launch(hostRuntime()) + await untilRecorded('succeeded') + releasePaste(true) + await running + + await restartHost() + const outcome = row()?.outcome + expect(outcome?.status === 'succeeded' && outcome.launch).toMatchObject({ + prompt: { outcome: 'handed-to-terminal' } + }) + }) +}) + +describe('a final write that fails', () => { + it('leaves the prompt unconfirmed, or unknown to a caller that cannot read it, never "not sent"', async () => { + replaceLaunchWrite(store, 2, () => Promise.reject(new Error('SQLITE_BUSY'))) + const host = hostRuntime() + + const first = await launch(host) + + // The live answer is the truth the host saw; only the record missed it. + expect(first.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + await expect(launch(host)).rejects.toThrow('agent_session_operation_unknown') + await expect(launch(host, PROMPTED_LAUNCH, UPGRADED_PHONE)).resolves.toMatchObject({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: PANE_KEY }, + prompt: { delivery: 'submit', outcome: 'unconfirmed' } + }) + expect(host.createTerminal).toHaveBeenCalledOnce() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) +}) + +describe('the two writes', () => { + it('land in order when the paste returns before the first write has committed', async () => { + const recorded = vi.spyOn(store, 'recordOperationOutcome') + + await launch(hostRuntime()) + + // Both were queued before either committed; the second is the one left standing. + const launchWrites = recorded.mock.calls.filter(([input]) => input.callerKey === 'device-1') + expect(launchWrites.map(([input]) => promptOutcome(input.outcome))).toEqual([ + 'unconfirmed', + 'handed-to-terminal' + ]) + expect(promptOutcome(row()?.outcome)).toBe('handed-to-terminal') + }) + + it('never lets a failed first write block the launch', async () => { + replaceLaunchWrite(store, 1, () => Promise.reject(new Error('SQLITE_BUSY'))) + + const first = await launch(hostRuntime()) + + expect(first.prompt).toEqual({ delivery: 'submit', outcome: 'handed-to-terminal' }) + expect(promptOutcome(row()?.outcome)).toBe('handed-to-terminal') + }) +}) + +describe('a reply lost three times', () => { + it('starts one agent, and every retry gets its answer', async () => { + const host = hostRuntime() + const first = await launch(host) + // Lost once and twice on the same host, then a third time across a restart. + const second = await launch(host) + const third = await launch(host) + await restartHost() + const afterRestart = hostRuntime() + const fourth = await launch(afterRestart) + + expect([second, third, fourth]).toEqual([first, first, first]) + expect(host.createTerminal).toHaveBeenCalledOnce() + expect(afterRestart.createTerminal).not.toHaveBeenCalled() + expect(deliverTerminalPrompt).toHaveBeenCalledOnce() + }) + + it('starts one agent when three retries arrive while the first is still running', async () => { + let releasePaste: (pasted: boolean) => void = () => {} + deliverTerminalPrompt.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePaste = resolve + }) + ) + const host = hostRuntime() + const attempts = [launch(host), launch(host), launch(host)] + await untilRecorded('succeeded') + releasePaste(true) + const [first, ...retries] = await Promise.all(attempts) + + expect(retries).toEqual([first, first]) + expect(host.createTerminal).toHaveBeenCalledOnce() + }) +}) + +describe('the desktop launches replay-safely', () => { + it('admits the desktop window under its own host-assigned identity and replays its id', async () => { + const host = hostRuntime() + const request = { + id: 'request-1', + authToken: 'desktop-ipc', + method: 'agent.launchReplay', + params: PROMPTED_LAUNCH + } + + const first = await dispatcherFor(host).dispatch(request, DESKTOP_IPC) + const replayed = await dispatcherFor(host).dispatch(request, DESKTOP_IPC) + + expect(first).toMatchObject({ ok: true, result: { outcome: { kind: 'terminal' } } }) + expect(replayed).toMatchObject({ ok: true, result: first.ok ? first.result : null }) + expect(host.createTerminal).toHaveBeenCalledOnce() + expect(row('trusted-local:desktop')?.outcome.status).toBe('succeeded') + }) + + it('reads an unconfirmed prompt after a restart mid-paste, through its own IPC transport', async () => { + const request = { + id: 'request-1', + authToken: 'desktop-ipc', + method: 'agent.launchReplay', + params: PROMPTED_LAUNCH + } + deliverTerminalPrompt.mockImplementationOnce(() => new Promise(() => {})) + void dispatcherFor(hostRuntime()).dispatch(request, DESKTOP_IPC) + const deadline = Date.now() + 2_000 + while (row('trusted-local:desktop')?.outcome.status !== 'succeeded') { + if (Date.now() > deadline) { + throw new Error('the desktop launch was never recorded') + } + await new Promise((resolve) => setTimeout(resolve, 5)) + } + await ledgerWritesQueuedBefore(store) + + await restartHost() + const replayed = await dispatcherFor(restartedHostRuntime()).dispatch(request, DESKTOP_IPC) + + expect(replayed).toMatchObject({ ok: true, result: UNCONFIRMED_AGENT }) + }) + + it('opens the ledger alone, never the chat host, to admit a terminal launch', async () => { + const host = hostRuntime() + + await launch(host) + + expect(host.openAgentSessionRecordStore).toHaveBeenCalled() + expect(host.ensureStructuredAgentSessionHost).not.toHaveBeenCalled() + }) +}) + +describe('a caller cannot claim an identity', () => { + it('keys a paired device by its authenticated subject whatever its params say', async () => { + const host = hostRuntime() + const response = await new Promise((resolve) => { + void dispatcherFor(host).dispatchStreaming( + { + id: 'request-1', + authToken: 'device-token', + method: 'agent.launchReplay', + params: { + ...PROMPTED_LAUNCH, + caller: DESKTOP_RPC_CALLER, + callerKey: 'trusted-local:desktop', + pairedDeviceId: 'device-2' + } + }, + resolve, + { ...PHONE, clientId: 'device-token' } + ) + }) + + expect(JSON.parse(response)).toMatchObject({ ok: true }) + expect(store.listOperationRows().map((entry) => entry.callerKey)).toEqual(['device-1']) + }) + + it('refuses replay safety to a transport that cannot name its caller', async () => { + const host = hostRuntime() + + const response = await dispatcherFor(host).dispatch( + { + id: 'request-1', + authToken: 'token', + method: 'agent.launchReplay', + params: { ...PROMPTED_LAUNCH, caller: DESKTOP_RPC_CALLER } + }, + { clientKind: 'runtime', clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] } + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_identity_required' } + }) + expect(host.createTerminal).not.toHaveBeenCalled() + expect(store.listOperationRows()).toHaveLength(0) + }) +}) + +describe('the ledger stays bounded', () => { + it('keeps one row per launch, retained from admission, across both writes', async () => { + let releasePaste: (pasted: boolean) => void = () => {} + deliverTerminalPrompt.mockImplementationOnce( + () => + new Promise((resolve) => { + releasePaste = resolve + }) + ) + const running = launch(hostRuntime()) + await untilRecorded('succeeded') + const afterFirstWrite = row() + releasePaste(true) + await running + + expect(store.listOperationRows()).toHaveLength(1) + expect(row()?.expiresAt).toBe(afterFirstWrite?.expiresAt) + expect(row()?.recordedAt).toBe(afterFirstWrite?.recordedAt) + }) + + it('holds the desktop to the same per-caller cap as every other caller', async () => { + const now = Date.now() + for (let index = 0; index < 512; index += 1) { + await store.admitOperation({ + callerKey: 'trusted-local:desktop', + operationId: `${now}-${index.toString(16).padStart(32, '0')}`, + fingerprint: 'fp-seeded', + now + }) + } + const host = hostRuntime() + + await expect(launch(host, PROMPTED_LAUNCH, { ...DESKTOP_IPC })).rejects.toThrow( + 'agent_session_operation_capacity' + ) + expect(host.createTerminal).not.toHaveBeenCalled() + // Another caller's namespace is not starved by it. + await expect(launch(host)).resolves.toMatchObject({ outcome: { kind: 'terminal' } }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts index 720f08193838..94f02830447c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts @@ -13,8 +13,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcContext } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -24,6 +22,7 @@ import { methodNamed, rpcContext, runtimeStub, + setAgentLaunchRecordStore, type AgentLaunchRuntimeStub as RuntimeStub } from './agent-launch.test-fixture' @@ -211,12 +210,11 @@ describe('a taken session id under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-session-')) store = await openTestAgentSessionRecordStore(directory) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. - setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) + setAgentLaunchRecordStore(store) }) afterEach(async () => { - setStructuredAgentSessionHost(null) + setAgentLaunchRecordStore(null) await rm(directory, { recursive: true, force: true }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 909118c883fc..c0aeb4436cee 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -9,6 +9,8 @@ import { vi } from 'vitest' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch-pane-already-live' import type { RpcContext } from '../core' +import { resolveRpcCallerIdentity } from '../rpc-caller-identity' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' export const STRUCTURED_PREFERENCE = { experimentalNativeChat: true, @@ -37,6 +39,9 @@ export type AgentLaunchRuntimeStubOptions = { terminalPaneAlreadyLive?: boolean /** What the runtime reports about an offered prompt's typed line; unset reports nothing. */ lineCarriesPrompt?: boolean + /** Panes this runtime found already running, by the handle it issued them: a restarted host + * that could not re-adopt a surviving PTY's handle issues a new one for the same pane. */ + adoptedPanes?: Record } function reportPromptCarry( @@ -49,8 +54,17 @@ function reportPromptCarry( } } +let launchRecordStore: AgentSessionRecordStore | null = null + +/** The ledger every stub's `openAgentSessionRecordStore` opens, as a process opens its one store. */ +export function setAgentLaunchRecordStore(store: AgentSessionRecordStore | null): void { + launchRecordStore = store +} + export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { const worktreeCreateResults = new Map>() + // Only the panes this runtime created or adopted, under the handle it issued them. + const handlesByPaneKey = new Map(Object.entries(options.adoptedPanes ?? {})) const waitForSetupTerminalCompletion = vi.fn( async (_handle: string, _signal?: AbortSignal): Promise<{ exitCode: number | null }> => ({ exitCode: 0 @@ -80,6 +94,9 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { showRepo: vi.fn(async () => ({ id: 'repo-1' })), createManagedWorktree: vi.fn(async (args: Record) => { reportPromptCarry(options, args.onStartupPromptCarry, args.startupPrompt) + if (args.startupAgent && options.startupTerminalPaneKey) { + handlesByPaneKey.set(options.startupTerminalPaneKey, 'term_agent_first') + } return { worktree: { id: 'wt-new' }, startupTerminal: args.startupAgent @@ -98,6 +115,9 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { throw new AgentLaunchPaneAlreadyLiveError() } reportPromptCarry(options, createOptions?.onStartupPromptCarry, createOptions?.startupPrompt) + if (options.terminalPaneKey) { + handlesByPaneKey.set(options.terminalPaneKey, 'term_1') + } return { handle: 'term_1', ...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}), @@ -105,6 +125,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } }), showTerminal: vi.fn(async (handle: string) => ({ handle, worktreeId: 'wt-7' })), + getTerminalHandleForPaneKey: vi.fn((paneKey: string) => handlesByPaneKey.get(paneKey) ?? null), isTerminalRunningAgent: vi.fn(async () => true), showManagedTerminalWorkspace: vi.fn(async (selector: string) => ({ id: selector.replace(/^id:/, '') @@ -119,6 +140,12 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { folderWorkspace: null })), ensureStructuredAgentSessionHost: vi.fn(async () => {}), + openAgentSessionRecordStore: vi.fn(async (): Promise => { + if (!launchRecordStore) { + throw new Error('agent_session_record_store_unavailable') + } + return launchRecordStore + }), waitForSetupTerminalCompletion } } @@ -139,12 +166,14 @@ export function methodNamed ): RpcContext { + const caller = context.caller ?? resolveRpcCallerIdentity(context) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements only the runtime surface these methods reach, so a method it omits throws on call rather than reading a wrong value. - return { runtime, ...context } as unknown as RpcContext + return { runtime, ...context, ...(caller ? { caller } : {}) } as unknown as RpcContext } export const CAPABLE_CLIENT: Partial = { diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index d0d3a905b780..c405caacf75e 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -146,12 +146,21 @@ async function resolveUnlaunchedIntent( return intent } +/** What a launch admitted under an operation id carries into its execution. */ +type ReplaySafeLaunch = { + attachOperationId: string + callerKey: string + terminalSpawn: TerminalSpawnDispatch + /** Records the surface the moment it exists, an owed prompt as `unconfirmed`. Fired, never + * awaited: the ledger's transactions run in order, so the final settle still lands after it, and + * the prompt never waits on bookkeeping. */ + recordSurface: (provisional: AgentLaunchResult) => void +} + async function runAgentLaunch( intent: AgentLaunchIntent, context: RpcContext, - attachOperationId?: string, - operationCallerKey?: string, - terminalSpawn?: TerminalSpawnDispatch + replaySafe?: ReplaySafeLaunch ): Promise { const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) return executeAgentLaunch({ @@ -159,19 +168,19 @@ async function runAgentLaunch( intent, surfaces: agentLaunchSurfaceFactory( context, - attachOperationId, - operationCallerKey, + replaySafe?.attachOperationId, + replaySafe?.callerKey, callerNavigationId !== null, - terminalSpawn + replaySafe?.terminalSpawn ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), // The tab is shown as it is published, not after a prompt that can take a minute to land. - ...(callerNavigationId !== null - ? { - onSurfacePublished: (surface) => - selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) - } - : {}) + onSurfacePublished: (surface) => { + replaySafe?.recordSurface(surface) + if (callerNavigationId !== null) { + selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) + } + } }) } @@ -259,13 +268,12 @@ async function executeReplaySafeAgentLaunch( const terminalSpawn = trackTerminalSpawnDispatch() let result: AgentLaunchResult try { - result = await runAgentLaunch( - intent, - context, - admission.attachOperationId, - admission.callerKey, - terminalSpawn - ) + result = await runAgentLaunch(intent, context, { + attachOperationId: admission.attachOperationId, + callerKey: admission.callerKey, + terminalSpawn, + recordSurface: (provisional) => void settleQuietly(admission.record(provisional)) + }) } catch (error) { const failedWithoutEffects = launchFailureWithoutEffectsCode( error, @@ -277,7 +285,8 @@ async function executeReplaySafeAgentLaunch( } throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null) } - // Settlement is bookkeeping; failure leaves the truthful `unknown` refusal for later retries. + // Bookkeeping: a failure leaves the first write, whose owed prompt replays as `unconfirmed` (or as + // `unknown` to a caller that cannot read it), never as `not-delivered`. await settleQuietly(admission.settle(result)) return result } diff --git a/src/main/runtime/rpc/rpc-caller-identity.ts b/src/main/runtime/rpc/rpc-caller-identity.ts new file mode 100644 index 000000000000..13e5f15e6aa2 --- /dev/null +++ b/src/main/runtime/rpc/rpc-caller-identity.ts @@ -0,0 +1,62 @@ +/** + * Who is calling, as the host's own transport established it. + * + * One identity per caller, stamped by the dispatcher from what the connection proved — the runtime + * socket, the desktop's IPC channel, or a paired device's authenticated socket — and never read + * from request params, so a caller cannot claim to be another. Durable per-caller state (the launch + * ledger's namespace) and per-caller behaviour (whose view a launch moves) both read this. + */ + +import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options' + +export type RpcCallerIdentity = + /** The `orca` CLI over the runtime socket, and the in-process bridges that relay it. */ + | { kind: 'local-cli' } + /** This host's own desktop app over IPC. One identity for every window, so a reload or an app + * restart still names the same caller. */ + | { kind: 'desktop' } + /** A paired phone or remote desktop, by its device subject, which survives credential rotation. */ + | { kind: 'paired-device'; deviceId: string } + +export const DESKTOP_RPC_CALLER: RpcCallerIdentity = { kind: 'desktop' } + +/** + * A transport that names its caller is believed; one that declares no client at all is the + * in-process runtime socket, trusted as it always was; one that declares a client it cannot name has + * no identity, and anything that needs one refuses it. + * + * Temporary: "no declared client" means the local CLI, so the SSH remote CLI bridge and browser + * automation share its namespace, and a new transport that forgets to stamp its caller inherits it. + * Before plugins ship (plan §4) the runtime socket stamps `local-cli` itself and no stamp means no + * identity. + */ +export function resolveRpcCallerIdentity( + transport: + | Pick + | undefined +): RpcCallerIdentity | undefined { + if (transport?.caller) { + return transport.caller + } + const deviceId = transport?.pairedDeviceId?.trim() + if (deviceId) { + return { kind: 'paired-device', deviceId } + } + return transport?.clientKind === undefined ? { kind: 'local-cli' } : undefined +} + +/** + * The caller's namespace in the durable operation ledger. Rows outlive the process, so these strings + * are permanent: the CLI and paired-device keys are the ones rows were written under before this + * identity existed. + */ +export function rpcCallerOperationKey(caller: RpcCallerIdentity): string { + switch (caller.kind) { + case 'local-cli': + return 'trusted-local:runtime' + case 'desktop': + return 'trusted-local:desktop' + case 'paired-device': + return caller.deviceId + } +} diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 07706125c235..7dc5dc0586ac 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -21,6 +21,7 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-browser-routing' import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' +import { resolveRpcCallerIdentity } from './rpc-caller-identity' import { needsOrchestrationCallerResolution, resolveOrchestrationSessionCaller, @@ -138,6 +139,7 @@ export class RpcStreamingDispatcher { subscriptionRegistrationVersion, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, @@ -192,6 +194,7 @@ export class RpcStreamingDispatcher { connectionId: options?.connectionId, clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, + caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 0bf61aa7843c..c0c6a2a8db37 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -1,8 +1,8 @@ import './rpc/unused-default-rpc-methods.test-fixture' /** * "This machine holds a structured chat" against a real host and record store. Session history, - * resume preparation and replay-safe phone launches all build the host for a user who never had a - * chat; only a chat record may turn the signal on, and the first one must turn it on at once. + * resume preparation and replay-safe phone launches all open the record store for a user who never + * had a chat; only a chat record may turn the signal on, and the first one must turn it on at once. */ import { mkdtemp, rm } from 'node:fs/promises' @@ -22,6 +22,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { openAgentSessionRecordStoreOnce } from './agent-session-record-store-slot' vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ ...(await importOriginal()), @@ -54,6 +55,14 @@ const runtime = { ensureStructuredAgentSessionHost: async () => { await installHost() }, + openAgentSessionRecordStore: async () => + ( + await openAgentSessionRecordStoreOnce({ + stateDirectory, + hostId: 'local', + logger: createStructuredAgentSessionLogger() + }) + ).store, listAiVaultSessions: async () => ({ sessions: [], issues: [], @@ -111,7 +120,12 @@ describe('whether this machine holds a structured chat', () => { it('stays false when a replay-safe phone launch records its operation', async () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only these fields. - const context = { runtime, clientKind: 'mobile', pairedDeviceId: 'phone-1' } as RpcContext + const context = { + runtime, + clientKind: 'mobile', + pairedDeviceId: 'phone-1', + caller: { kind: 'paired-device', deviceId: 'phone-1' } + } as RpcContext // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the operation id. const params = { operationId: operationId('a1') } as Parameters< typeof admitAgentLaunchOperation diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index c00e9c6890f2..6628f06c13f3 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -1,5 +1,6 @@ -// An install that fails after it opened the chat journal closes that connection, so the next -// install does not leave a second one open in the same process. +// An install that fails after it opened the chat journal leaves that one connection to the record +// store slot, which launch admission may already be using, and the next install builds on it: the +// process never holds a second connection. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -54,18 +55,20 @@ afterEach(async () => { }) describe('an install that fails after opening the chat journal', () => { - it('closes the journal connection it opened, and the next install opens its own', async () => { + it('keeps the one connection it opened, and the next install builds on it', async () => { const open = vi.spyOn(JournalHostDatabase, 'open') mocks.failWiring.mockReturnValueOnce(true) await expect(install()).rejects.toThrow('model catalog wiring failed') - const failed = await open.mock.results[0]?.value - expect(failed).toBeInstanceOf(JournalHostDatabase) - expect(failed.isClosed).toBe(true) + const opened = await open.mock.results[0]?.value + expect(opened).toBeInstanceOf(JournalHostDatabase) + expect(opened.isClosed).toBe(false) await expect(install()).resolves.toBeDefined() - expect(open).toHaveBeenCalledTimes(2) - const reopened = await open.mock.results[1]?.value - expect(reopened?.isClosed).toBe(false) + expect(open).toHaveBeenCalledOnce() + expect(opened.isClosed).toBe(false) + + await stopStructuredAgentSessionRuntime() + expect(opened.isClosed).toBe(true) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index c347be272708..ed00b6a91c3e 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -31,9 +31,11 @@ import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-sessio import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' -import { AgentSessionRecordStore } from './agent-session-record-store' -import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' +import { + openAgentSessionRecordStoreOnce, + releaseAgentSessionRecordStore, + type OpenedAgentSessionRecordStore +} from './agent-session-record-store-slot' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' @@ -192,6 +194,14 @@ export async function stopStructuredAgentSessionRuntime(options?: { if (installed) { outstanding.push(installed) } + // A store admission opened with no host built on it has no teardown to close its database. + const recordStore = await releaseAgentSessionRecordStore() + if ( + recordStore && + !outstanding.some((runtime) => runtime.journalDatabase === recordStore.journalDatabase) + ) { + recordStore.journalDatabase.close() + } const failures: unknown[] = [] for (const runtime of outstanding) { try { @@ -222,29 +232,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) - const store = AgentSessionRecordStore.open({ - journalDatabase, - hostId: deps.hostId - }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index dad67996d4a9..c809b96b63a0 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -154,6 +154,13 @@ export type AgentLaunchPromptDisposal = | { outcome: 'handed-to-terminal' } /** Not delivered by this call; the caller still owns the text. */ | { outcome: 'not-delivered' } + /** + * Only ever replayed, never a live answer: the host recorded the running agent, then stopped + * before the delivery reported back, so the text may or may not have arrived. The caller must not + * resend. Sent only to a caller advertising `agent.launch.prompt-unconfirmed.v1`; every other + * caller is refused with `agent_session_operation_unknown` instead. + */ + | { outcome: 'unconfirmed' } export type AgentLaunchPromptReceipt = { delivery: AgentLaunchPromptDelivery @@ -244,7 +251,9 @@ function isAgentLaunchPromptReceipt(value: unknown): value is AgentLaunchPromptR } return value.outcome === 'journaled' ? 'messageId' in value && typeof value.messageId === 'string' - : value.outcome === 'handed-to-terminal' || value.outcome === 'not-delivered' + : value.outcome === 'handed-to-terminal' || + value.outcome === 'not-delivered' || + value.outcome === 'unconfirmed' } function isAgentLaunchOutcome(value: unknown): value is AgentLaunchOutcome { diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index 2094f705dc7b..e4853e84f872 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -29,9 +29,15 @@ export const AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY = 'agent.launch.prompt export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY = 'agent.launch.replay-required.v1' as const +// A client that reads `prompt.outcome: 'unconfirmed'` on a replayed launch. Without it, a launch +// whose host stopped mid-delivery replays as `agent_session_operation_unknown`, as it always has. +export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY = + 'agent.launch.prompt-unconfirmed.v1' as const + export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY + AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY ] as const From ea4a899fb2ebd2e27d8cc01f4995532d073c6d05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:38:04 -0700 Subject: [PATCH 2/3] feat(agent-launch): show an agent's tab at once, where the caller asked for it An agent.launch now shows its terminal tab before admission and spawn, in the requested placement (group and/or anchor tab), and the pane attaches to the agent as soon as it runs. A pane whose agent can't start, or whose start can't be confirmed, says so instead of becoming a plain shell, and keeps saying so across restarts. A user's close of the tab or its pane during the launch stops it and answers agent_launch_tab_closed. Whose view moves is unchanged from main for every caller. Rebuilt on main (with #24934) from the previous branch head 8f62858e606. --- .../mobile-existing-agent-launch.test.ts | 23 +- .../session/mobile-existing-agent-launch.ts | 17 +- .../src/session/new-tab-agent-host-launch.ts | 25 + .../session/pending-session-selection.test.ts | 18 + .../src/session/pending-session-selection.ts | 7 +- mobile/src/session/pr-ai-triage-launch.ts | 1 + ...-mobile-session-terminal-create-actions.ts | 1 + ...ssion-terminal-create-agent-launch.test.ts | 48 ++ mobile/src/tasks/agent-launch-request.ts | 6 +- ...mobile-runtime-client-capabilities.test.ts | 15 +- .../mobile-runtime-client-capabilities.ts | 12 +- .../agent-launch-executor.test.ts | 20 + .../agent-launch/agent-launch-executor.ts | 27 +- .../agent-launch-opencode-model.test.ts | 1 + .../agent-launch-pane-attachment.test.ts | 227 +++++ .../agent-launch-pane-attachment.ts | 216 +++++ .../agent-launch-surface-factories.ts | 4 + .../agent-launch/agent-launch-view-mode.ts | 39 + ...ktop-renderer-runtime-capabilities.test.ts | 16 +- .../desktop-renderer-runtime-capabilities.ts | 8 +- .../ipc/pty-agent-launch-pane-attach.test.ts | 339 ++++++++ src/main/ipc/pty/ipc/spawn-begin.ts | 29 + .../pane/agent-launch-pane-evidence.test.ts | 55 ++ .../pty/pane/agent-launch-pane-evidence.ts | 58 ++ .../session-close-terminal-surface.test.ts | 95 +++ src/main/ipc/session.ts | 8 +- .../agent-launch-record-warmup-gate.test.ts | 38 + .../agent-launch-record-warmup-gate.ts | 36 + .../agent-session-operation-admission.ts | 10 +- .../agent-session-record-store-slot.ts | 35 +- .../runtime/agent-session-record-store.ts | 2 +- ...ld-headless-mobile-session-browser-tabs.ts | 3 + ...form-mobile-session-pty-records-refresh.ts | 7 + ...untime-terminal-close-agent-launch.test.ts | 46 ++ .../mobile-session-tabs-part-07.spec.ts | 21 + src/main/runtime/orca-runtime.ts | 61 ++ src/main/runtime/rpc/errors.ts | 2 + .../methods/agent-launch-execution-outcome.ts | 66 ++ .../agent-launch-floating-workspace.test.ts | 7 +- .../methods/agent-launch-instant-tab.test.ts | 780 ++++++++++++++++++ .../methods/agent-launch-intent-resolution.ts | 96 +++ .../rpc/methods/agent-launch-replay.ts | 35 +- .../rpc/methods/agent-launch-surfaces.ts | 22 +- .../methods/agent-launch-tab-publication.ts | 299 +++++++ .../methods/agent-launch-worktree-creation.ts | 1 + .../rpc/methods/agent-launch.test-fixture.ts | 22 +- .../runtime/rpc/methods/agent-launch.test.ts | 3 + src/main/runtime/rpc/methods/agent-launch.ts | 192 ++--- src/main/runtime/runtime-notifier-contract.ts | 11 + .../runtime-rpc/runtime-rpc-lifecycle.ts | 6 +- .../startup/agent-launch-record-warmup.ts | 25 + ...-process-runtime-launch-activation.test.ts | 48 +- .../startup/main-process-runtime-launch.ts | 3 + .../window/agent-launch-tab-publish-relay.ts | 51 ++ src/main/window/runtime-window-lifecycle.ts | 4 + .../ui-bridge-terminal-and-session-tabs.ts | 24 + src/preload/api/ui-command-event-api.ts | 8 + .../terminal-pane/AgentLaunchPaneNotice.tsx | 62 ++ .../terminal-pane/TerminalPaneSurface.tsx | 8 + .../agent-launch-pane-notice-text.ts | 50 ++ .../agent-launch-pane-notice.test.ts | 44 + .../use-terminal-pane-close-actions.ts | 8 + .../use-terminal-pane-projection.ts | 3 + .../use-terminal-pane-title-state.ts | 5 + ...vents-agent-status-window-test-fixtures.ts | 2 + .../ipc-events-close-routing-test-harness.ts | 2 + ...c-events-terminal-create-scenario-types.ts | 1 + ...ts-terminal-create-window-test-fixtures.ts | 2 + .../ipc-events/agent-launch-tab-ipc-bridge.ts | 21 + .../ipc-events/app-lifetime-ipc-bridge.ts | 2 + .../ipc-events/terminal-command-state.ts | 13 +- .../terminal-presentation-ipc-bridge.ts | 8 + ...minal-request-ipc-bridge-placement.test.ts | 108 +++ .../ipc-events/terminal-request-ipc-bridge.ts | 5 +- ...eIpcEvents-agent-launch-closed-tab.test.ts | 64 ++ .../useIpcEvents-browser-tab-create.test.ts | 2 + ...eIpcEvents-cli-worktree-activation.test.ts | 4 + ...Events-close-routing-browser-pages.test.ts | 6 + .../src/hooks/useIpcEvents-lifecycle.test.ts | 4 + ...seIpcEvents-ssh-disconnect-cleanup.test.ts | 2 + .../hooks/useIpcEvents-updater-status.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 8 + src/renderer/src/i18n/locales/es.json | 8 + src/renderer/src/i18n/locales/fr.json | 8 + src/renderer/src/i18n/locales/ja.json | 8 + src/renderer/src/i18n/locales/ko.json | 8 + src/renderer/src/i18n/locales/zh.json | 8 + .../src/lib/agent-launch-pane-closes.ts | 31 + .../src/lib/agent-launch-pane-prompt.ts | 28 + ...nt-launch-pane-verdict-application.test.ts | 129 +++ .../agent-launch-pane-verdict-application.ts | 54 ++ .../lib/agent-launch-tab-publication.test.ts | 278 +++++++ .../src/lib/agent-launch-tab-publication.ts | 148 ++++ .../src/lib/native-chat-initial-view-mode.ts | 79 +- .../native-chat-launch-draft-mirrorability.ts | 25 +- .../lib/native-chat-transcript-readability.ts | 9 +- src/renderer/src/store/slices/terminals.ts | 2 + .../src/store/slices/worktree-helpers.ts | 6 +- .../worktrees/session/set-active-worktree.ts | 55 +- .../src/store/terminals/terminal-actions.ts | 11 +- .../terminal-tab-agent-launch-pane.ts | 32 + .../src/store/terminals/terminal-tab-close.ts | 9 + .../store/terminals/terminal-tab-creation.ts | 4 +- .../src/web/preload-api/web-ui-api.ts | 3 + src/shared/agent-launch-intent.ts | 39 +- src/shared/agent-launch-operation.ts | 3 + src/shared/agent-launch-pane-verdict.ts | 51 ++ src/shared/agent-launch-runtime-capability.ts | 17 +- src/shared/agent-launch-tab-closed.ts | 11 + src/shared/agent-launch-tab-publication.ts | 57 ++ .../agent-session-operation-ledger.test.ts | 45 + src/shared/agent-session-operation-ledger.ts | 42 +- src/shared/native-chat-initial-view-mode.ts | 73 ++ .../native-chat-launch-draft-mirrorability.ts | 21 + .../native-chat-transcript-readability.ts | 8 + .../rpc-contract/agent-launch-params.test.ts | 37 + .../rpc-contract/agent-launch-params.ts | 29 +- src/shared/terminal-tab-types.ts | 9 + src/shared/workspace-session-schema.test.ts | 44 + src/shared/workspace-session-schema.ts | 5 +- 120 files changed, 4852 insertions(+), 297 deletions(-) create mode 100644 src/main/agent-launch/agent-launch-pane-attachment.test.ts create mode 100644 src/main/agent-launch/agent-launch-pane-attachment.ts create mode 100644 src/main/agent-launch/agent-launch-view-mode.ts create mode 100644 src/main/ipc/pty-agent-launch-pane-attach.test.ts create mode 100644 src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts create mode 100644 src/main/ipc/pty/pane/agent-launch-pane-evidence.ts create mode 100644 src/main/ipc/session-close-terminal-surface.test.ts create mode 100644 src/main/runtime/agent-launch-record-warmup-gate.test.ts create mode 100644 src/main/runtime/agent-launch-record-warmup-gate.ts create mode 100644 src/main/runtime/orca-runtime-terminal-close-agent-launch.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts create mode 100644 src/main/runtime/rpc/methods/agent-launch-tab-publication.ts create mode 100644 src/main/startup/agent-launch-record-warmup.ts create mode 100644 src/main/window/agent-launch-tab-publish-relay.ts create mode 100644 src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx create mode 100644 src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts create mode 100644 src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts create mode 100644 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts create mode 100644 src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-closes.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-prompt.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-verdict-application.ts create mode 100644 src/renderer/src/lib/agent-launch-tab-publication.test.ts create mode 100644 src/renderer/src/lib/agent-launch-tab-publication.ts create mode 100644 src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts create mode 100644 src/shared/agent-launch-pane-verdict.ts create mode 100644 src/shared/agent-launch-tab-closed.ts create mode 100644 src/shared/agent-launch-tab-publication.ts create mode 100644 src/shared/native-chat-initial-view-mode.ts create mode 100644 src/shared/native-chat-launch-draft-mirrorability.ts create mode 100644 src/shared/native-chat-transcript-readability.ts diff --git a/mobile/src/session/mobile-existing-agent-launch.test.ts b/mobile/src/session/mobile-existing-agent-launch.test.ts index ac28e851e81d..0e5d289ad511 100644 --- a/mobile/src/session/mobile-existing-agent-launch.test.ts +++ b/mobile/src/session/mobile-existing-agent-launch.test.ts @@ -10,7 +10,8 @@ import { PROMPTED_AGENT_LAUNCH_TIMEOUT_MS, launchAgentInExistingWorkspace, reserveMobileAgentLaunch, - supportsMobileExistingAgentLaunch + supportsMobileExistingAgentLaunch, + AGENT_LAUNCH_TAB_CLOSED_MESSAGE } from './mobile-existing-agent-launch' // A connected client whose only behaviour is the scripted `sendRequest`. @@ -115,6 +116,18 @@ describe('reserveMobileAgentLaunch', () => { }) }) + it('asks for the new tab after the one the user is on', async () => { + const { client, sendRequest } = scriptedClient(launched({})) + await launch(client, { + placement: { afterTabId: 'tab-current' }, + mintOperationId: () => `1790000000000-${'b'.repeat(32)}` + }) + + expect(requestParam(sendRequest.mock.calls[0]![1], 'placement')).toEqual({ + afterTabId: 'tab-current' + }) + }) + it('mints a pane the host adopts even where the runtime has no crypto.randomUUID', () => { const native = Object.getOwnPropertyDescriptor(globalThis.crypto, 'randomUUID') Object.defineProperty(globalThis.crypto, 'randomUUID', { value: undefined, configurable: true }) @@ -237,6 +250,14 @@ describe('launchAgentInExistingWorkspace', () => { }) }) + it('reads a closed tab as the user stopping the launch, not as the launch failing', async () => { + const { client } = scriptedClient(refused('agent_launch_tab_closed')) + await expect(launch(client)).resolves.toEqual({ + kind: 'tab-closed', + message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE + }) + }) + it('reports unknown when no answer arrives at all', async () => { const { client } = scriptedClient({ throws: new Error('Request timed out') }) await expect(launch(client)).resolves.toEqual({ diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index 3531838291b2..3b08bbc54327 100644 --- a/mobile/src/session/mobile-existing-agent-launch.ts +++ b/mobile/src/session/mobile-existing-agent-launch.ts @@ -6,8 +6,13 @@ * every outcome is unit-testable. */ -import type { AgentLaunchPrompt, AgentLaunchResult } from '../../../src/shared/agent-launch-intent' +import type { + AgentLaunchPlacement, + AgentLaunchPrompt, + AgentLaunchResult +} from '../../../src/shared/agent-launch-intent' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../src/shared/agent-launch-tab-closed' import { makePaneKey } from '../../../src/shared/stable-pane-id' import { createStructuredAgentSessionId } from '../../../src/shared/structured-agent-session-create' import type { TuiAgent } from '../../../src/shared/tui-agent' @@ -39,6 +44,9 @@ export const AGENT_LAUNCH_UNCONFIRMED_MESSAGE = // The host started nothing, and the next tap reserves new ids. export const AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE = "Couldn't start the agent. Try again." +// Who closed it, and whether the agent had started, are unknown here: the phone or the computer. +export const AGENT_LAUNCH_TAB_CLOSED_MESSAGE = + "The agent's tab was closed, so the agent was stopped." /** * The tab a launch will create, named by this device before it asks, so it can land there as soon @@ -75,6 +83,8 @@ export type MobileExistingAgentLaunch = | { kind: 'failed'; message: string } /** The agent may or may not be running; the caller must not launch again on its own. */ | { kind: 'unknown'; message: string } + /** A user closed the launch's tab, which stopped it; not a failure of the launch. */ + | { kind: 'tab-closed'; message: string } export function supportsMobileExistingAgentLaunch( hostCapabilities: readonly string[] | null | undefined @@ -91,6 +101,7 @@ export async function launchAgentInExistingWorkspace(args: { prompt?: AgentLaunchPrompt launchSource?: string reservation?: MobileAgentLaunchReservation + placement?: AgentLaunchPlacement // Injected in tests; each call is one new operation, so a later tap never replays this one. mintOperationId?: () => string }): Promise { @@ -103,6 +114,7 @@ export async function launchAgentInExistingWorkspace(args: { operationId: (args.mintOperationId ?? structuredSessionOperationId)(), ...(args.prompt ? { prompt: args.prompt } : {}), ...(args.launchSource ? { launchSource: args.launchSource } : {}), + ...(args.placement ? { placement: args.placement } : {}), ...(args.reservation ? { paneKey: makePaneKey(args.reservation.pane.tabId, args.reservation.pane.leafId), @@ -159,6 +171,9 @@ function classifyLaunchRefusal( if (isAgentLaunchReservationTakenRefusal(error)) { return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE } } + if (error.code === AGENT_LAUNCH_TAB_CLOSED_CODE) { + return { kind: 'tab-closed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE } + } const message = error.message?.trim() return { kind: 'failed', message: message || "Couldn't start the agent." } } diff --git a/mobile/src/session/new-tab-agent-host-launch.ts b/mobile/src/session/new-tab-agent-host-launch.ts index 71326c48f78e..6e3051ef3a3b 100644 --- a/mobile/src/session/new-tab-agent-host-launch.ts +++ b/mobile/src/session/new-tab-agent-host-launch.ts @@ -1,6 +1,7 @@ import type { MobileQuickCommandLaunch } from '../terminal/quick-commands' import type { MobileSessionTab } from './mobile-session-route-types' import type { TuiAgent } from '../../../src/shared/tui-agent' +import { AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability' import type { RpcClient } from '../transport/rpc-client' import { triggerError, triggerSuccess } from '../platform/haptics' import { @@ -32,6 +33,18 @@ export function launchesThroughHost(options: NewTabAgentLaunchOptions | undefine return options?.startupCommand === undefined && options?.enter !== false } +/** The host's id for a session tab: a terminal pane is listed under its parent tab. */ +function hostTabIdOf( + tabs: readonly MobileSessionTab[], + sessionTabId: string | null | undefined +): string | undefined { + const tab = sessionTabId ? tabs.find((candidate) => candidate.id === sessionTabId) : undefined + if (!tab) { + return undefined + } + return tab.type === 'terminal' ? tab.parentTabId : tab.id +} + /** * The "+" menu's agent start through `agent.launch`. The phone lands on the tab when it is listed, * which for a paste-after-start agent is long before the reply; the reply only reports delivery. @@ -45,6 +58,8 @@ export async function launchNewTabAgentThroughHost(args: { options: NewTabAgentLaunchOptions | undefined /** Names this launch's pending selection, as it names the "+" lock. */ lock: string + /** The session tab the user is on; the new tab follows it, as the plain "+" terminal does. */ + activeSessionTabId?: string | null pendingSelectionRef: { current: PendingSessionSelection | null } fetchSessionTabs: () => Promise getSessionTabs: () => readonly MobileSessionTab[] @@ -55,6 +70,10 @@ export async function launchNewTabAgentThroughHost(args: { const { options, pendingSelectionRef, showToast } = args const prompt = options?.agentPrompt ?? options?.initialPrompt const reservation = reserveMobileAgentLaunch(args.agent) + // An older host would drop it anyway; asking only a host that reads it keeps the request as before. + const afterTabId = args.hostCapabilities?.includes(AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY) + ? hostTabIdOf(args.getSessionTabs(), args.activeSessionTabId) + : undefined // Why: armed before asking, because the reply waits for prompt delivery while the tab is listed // as soon as it exists. A tab the user picks meanwhile replaces this. pendingSelectionRef.current = launchedSelection(args.lock, reservation, null) @@ -64,6 +83,7 @@ export async function launchNewTabAgentThroughHost(args: { worktreeId: args.worktreeId, agent: args.agent, reservation, + ...(afterTabId ? { placement: { afterTabId } } : {}), ...(prompt?.trim() ? { prompt: { text: prompt, delivery: 'submit' as const } } : {}), ...(options?.agentPrompt ? { launchSource: 'quick_command' } @@ -79,6 +99,11 @@ export async function launchNewTabAgentThroughHost(args: { args.reportCreateFailure(launched.message) return true } + if (launched.kind === 'tab-closed') { + // Why silent: the tab this "+" opened is gone, which is the answer; the close was a user's own. + pendingSelectionRef.current = withoutUnansweredLaunch(pendingSelectionRef.current, args.lock) + return true + } if (launched.kind === 'unknown') { // Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent. if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) { diff --git a/mobile/src/session/pending-session-selection.test.ts b/mobile/src/session/pending-session-selection.test.ts index 40af8f77d4e7..9a69bf53e623 100644 --- a/mobile/src/session/pending-session-selection.test.ts +++ b/mobile/src/session/pending-session-selection.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { MobileSessionTab } from './mobile-session-route-types' import { LAUNCHED_SELECTION_SNAPSHOT_BUDGET, + isLaunchedSurfaceListed, launchedSelection, pendingSelectionHandle, pendingSelectionTabId, @@ -165,3 +166,20 @@ describe('the halves of a pick', () => { ) }) }) + +describe('isLaunchedSurfaceListed', () => { + it('reads a listed terminal as a started agent', () => { + expect(isLaunchedSurfaceListed([reservedTerminalTab('term_1')], { pane: PANE })).toBe(true) + }) + + it('does not read a tab the host showed before its agent existed as a start', () => { + expect(isLaunchedSurfaceListed([reservedTerminalTab(null)], { pane: PANE })).toBe(false) + }) + + it('still lands the selection on that tab, so the phone shows it at once', () => { + const resolved = resolveLaunchedSelection(launchedSelection('lock', { pane: PANE }, null), [ + reservedTerminalTab(null) + ]) + expect(resolved.landedTabId).toBe(`${PANE.tabId}::${PANE.leafId}`) + }) +}) diff --git a/mobile/src/session/pending-session-selection.ts b/mobile/src/session/pending-session-selection.ts index 25323e696856..e2e1ba932095 100644 --- a/mobile/src/session/pending-session-selection.ts +++ b/mobile/src/session/pending-session-selection.ts @@ -135,13 +135,16 @@ export function resolveLaunchedSelection( } } -/** Whether the host lists a launch's tab, which proves the agent started. */ +/** Whether the host lists a launch's running surface, which proves the agent started. */ export function isLaunchedSurfaceListed( tabs: readonly MobileSessionTab[], surface: Partial ): boolean { const known: LaunchedSurface = { pane: null, sessionId: null, handle: null, ...surface } - return tabs.some((tab) => isLaunchedTab(tab, known)) + // A host may list a launch's terminal tab before its agent exists; only a terminal proves a start. + return tabs.some( + (tab) => isLaunchedTab(tab, known) && (tab.type !== 'terminal' || tab.terminal !== null) + ) } function isLaunchedTab(tab: MobileSessionTab, surface: LaunchedSurface): boolean { diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 61c160b224ec..c425cb255cda 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -85,6 +85,7 @@ export async function launchAgentWithPrompt(args: { case 'unsupported': return { kind: 'not-started', message: AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE } case 'failed': + case 'tab-closed': return { kind: 'not-started', message: launched.message } case 'unknown': return { kind: 'unconfirmed', message: launched.message } diff --git a/mobile/src/session/use-mobile-session-terminal-create-actions.ts b/mobile/src/session/use-mobile-session-terminal-create-actions.ts index 064dd46fbd12..f6aac4bb3f44 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-actions.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-actions.ts @@ -95,6 +95,7 @@ export function useMobileSessionTerminalCreateActions(scope: MobileSessionAttach agent, options, lock: clientMutationId, + activeSessionTabId, pendingSelectionRef, fetchSessionTabs, getSessionTabs: () => sessionTabsRef.current, diff --git a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts index 129bf1edf9b9..3e0691c720a4 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts @@ -1,4 +1,5 @@ import { createElement } from 'react' +import { AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import type { @@ -25,6 +26,7 @@ import { releaseTerminalCreateLock } from './terminal-create-lock' import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions' vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn(), triggerError: vi.fn() })) +const { triggerError } = await import('../platform/haptics') const LAUNCH_CAPABILITIES = [ 'agent.launch.v2', @@ -198,6 +200,39 @@ describe('the + menu', () => { expect(state.scheduleDelayedAction).not.toHaveBeenCalled() }) + it('asks a host that places tabs to put the new one after the tab the user is on', async () => { + const { client, sendRequest } = scriptedClient( + launchReply({ kind: 'terminal', handle: 'term_7' }) + ) + const state = scope(client, [...LAUNCH_CAPABILITIES, AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY]) + state.sessionTabsRef.current = [ + { + type: 'terminal', + id: 'existing-tab', + parentTabId: 'host-tab-1', + leafId: 'leaf-1', + title: 'Terminal', + terminal: 'term_1', + isActive: true + } + ] + + await create_(state, 'claude') + + // A terminal pane is listed under its parent tab; that is the id the host places by. + expect(launchParams(sendRequest)).toMatchObject({ placement: { afterTabId: 'host-tab-1' } }) + }) + + it('sends no placement to a host that would not read it', async () => { + const { client, sendRequest } = scriptedClient( + launchReply({ kind: 'terminal', handle: 'term_7' }) + ) + + await create_(scope(client), 'claude') + + expect(launchParams(sendRequest)).not.toHaveProperty('placement') + }) + it('waits for a chat by its session id, not a predicted tab id', async () => { const { client } = scriptedClient( launchReply({ kind: 'structured', sessionId: 'claude_s1', handle: 'h' }) @@ -323,6 +358,19 @@ describe('the + menu', () => { expect(methods(sendRequest)).toEqual(['agent.launchReplay', 'session.tabs.createTerminal']) }) + it('says nothing when a user closed the tab the launch opened: the tab going is the answer', async () => { + const { client } = scriptedClient(refusal('agent_launch_tab_closed')) + const state = scope(client) + vi.mocked(triggerError).mockClear() + + await create_(state, 'claude') + + expect(state.showToast).not.toHaveBeenCalled() + expect(state.setCreateError).not.toHaveBeenCalledWith(expect.stringMatching(/./)) + expect(triggerError).not.toHaveBeenCalled() + expect(state.pendingSelectionRef.current).toBeNull() + }) + it("shows the host's refusal even when the session already has tabs", async () => { const { client, sendRequest } = scriptedClient({ id: 'x', diff --git a/mobile/src/tasks/agent-launch-request.ts b/mobile/src/tasks/agent-launch-request.ts index b6d730d6e2ee..8e825b5c7c4f 100644 --- a/mobile/src/tasks/agent-launch-request.ts +++ b/mobile/src/tasks/agent-launch-request.ts @@ -18,6 +18,7 @@ import { withoutReservedAgentCreateFields, + type AgentLaunchPlacement, type AgentLaunchPrompt, type AgentLaunchResult } from '../../../src/shared/agent-launch-intent' @@ -84,6 +85,7 @@ export function agentLaunchExistingParams(args: { launchSource?: string paneKey?: string sessionId?: string + placement?: AgentLaunchPlacement }): RpcSendParams<'agent.launchReplay'> { return { agent: args.agent, @@ -92,7 +94,9 @@ export function agentLaunchExistingParams(args: { ...(args.prompt ? { prompt: args.prompt } : {}), ...(args.launchSource ? { launchSource: args.launchSource } : {}), ...(args.paneKey ? { paneKey: args.paneKey } : {}), - ...(args.sessionId ? { sessionId: args.sessionId } : {}) + ...(args.sessionId ? { sessionId: args.sessionId } : {}), + // An older host drops it and the tab lands in its active group, as before. + ...(args.placement ? { placement: args.placement } : {}) } } diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts index 45502e1f028e..1c69c9973d31 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.test.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.test.ts @@ -6,7 +6,11 @@ import { STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY +} from '../../../src/shared/agent-launch-runtime-capability' import { MOBILE_RUNTIME_CLIENT_CAPABILITIES } from './mobile-runtime-client-capabilities' /** Mirrors the host's `parseRuntimeClientCapabilities`, which returns an EMPTY list — silently @@ -34,6 +38,15 @@ describe('mobile runtime client capabilities', () => { expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toContain(AGENT_LAUNCH_RUNTIME_CAPABILITY) }) + it('reads an early launch tab as not started and a closed one as stopped', () => { + expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES).toEqual( + expect.arrayContaining([ + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY + ]) + ) + }) + it('stays inside the bounds the host parses, which fail closed to no capabilities at all', () => { expect(MOBILE_RUNTIME_CLIENT_CAPABILITIES.length).toBeLessThanOrEqual(HOST_CAPABILITY_LIMIT) for (const capability of MOBILE_RUNTIME_CLIENT_CAPABILITIES) { diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 32dc88a6280e..c3e3b5fc9c1b 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -6,7 +6,11 @@ import { STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' -import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY +} from '../../../src/shared/agent-launch-runtime-capability' import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ @@ -20,7 +24,11 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Mobile renders either launch outcome — a structured chat or a terminal agent — so it may ask // the host to pick. Without this the host refuses `agent.launch` and every mobile create with an // agent stays a PTY. - AGENT_LAUNCH_RUNTIME_CAPABILITY + AGENT_LAUNCH_RUNTIME_CAPABILITY, + // Reads a listed launch tab with no terminal yet as not started, so the host may show it early. + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY, + // Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer. + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 715996ac81e2..8cf32e5f1e6c 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -43,6 +43,7 @@ function harness(options: { calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`) return { worktreeId: 'wt-new', + connectionId: null, startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined, ...carried(args.startupPrompt) } @@ -690,3 +691,22 @@ describe('the surface is published as the launch stands, before its prompt is de expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' }) }) }) + +describe('a new local worktree whose startup terminal did not come up', () => { + it('opens its agent in the view a local workspace allows, as an existing one would', async () => { + const h = harness({ + settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + }) + h.createWorktree.mockImplementationOnce(async () => ({ + worktreeId: 'wt-new', + connectionId: null, + startupTerminalHandle: undefined + })) + + await h.run({ ...CREATE_INTENT, agent: 'opencode' }) + + expect(h.createTerminalAgent).toHaveBeenCalledWith( + expect.objectContaining({ viewMode: 'chat' }) + ) + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 67cb117f9348..4abd4ca0ed00 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -48,6 +48,7 @@ import { type WorkspaceLaunchKind } from '../../shared/workspace-launch-kind' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import { deriveAgentLaunchTerminalViewMode } from './agent-launch-view-mode' import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal' import { decideAgentLaunchMode, @@ -166,7 +167,7 @@ export async function executeAgentLaunch( execution.onStage?.('surface_create') let created: CreatedSurface try { - created = await createSurface(execution, placed.worktreeId, settled) + created = await createSurface(execution, placed, settled) } catch (error) { // The structured create path distinguishes a definitive pre-commit refusal from an unknown // outcome. Only the former is safe to replace with a terminal in the same workspace; retrying @@ -179,7 +180,7 @@ export async function executeAgentLaunch( throw error } settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary) - created = await createTerminalSurface(execution, placed.worktreeId) + created = await createTerminalSurface(execution, placed) } // Both CAN be set, so neither may be dropped. The create warns precisely when it produced no // startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same @@ -230,6 +231,7 @@ async function resolveWorkspace( preflight: AgentLaunchModeReceipt ): Promise<{ worktreeId: string + connectionId: string | null | undefined startupTerminalHandle: string | undefined startupTerminalPaneKey?: string warning?: string @@ -239,7 +241,8 @@ async function resolveWorkspace( const { intent } = execution if (intent.target.kind === 'existing') { // Nothing was created, so there is no create warning to carry. - return { worktreeId: intent.target.worktree, startupTerminalHandle: undefined } + const { worktree: worktreeId, connectionId } = intent.target + return { worktreeId, connectionId, startupTerminalHandle: undefined } } const workspaces = execution.workspaces if (!workspaces) { @@ -276,7 +279,7 @@ export type CreatedSurface = { async function createSurface( execution: AgentLaunchExecution, - worktreeId: string, + workspace: { worktreeId: string; connectionId: string | null | undefined }, settled: AgentLaunchModeReceipt ): Promise { const { intent, surfaces } = execution @@ -284,7 +287,7 @@ async function createSurface( // One reservation serves either route: the tab half of the reserved pane is the chat's tab. const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined const session = await surfaces.createStructuredSession({ - worktreeId, + worktreeId: workspace.worktreeId, agent: intent.agent, ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}), ...(intent.sessionId ? { sessionId: intent.sessionId } : {}), @@ -301,7 +304,7 @@ async function createSurface( ...ignoredStructuredAgentArgsWarning(intent) } } - return createTerminalSurface(execution, worktreeId) + return createTerminalSurface(execution, workspace) } /** @@ -346,15 +349,21 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) { */ async function createTerminalSurface( execution: AgentLaunchExecution, - worktreeId: string + workspace: { worktreeId: string; connectionId: string | null | undefined } ): Promise { const { intent, surfaces } = execution const startupPrompt = argvLaunchPrompt(intent) const terminal = await surfaces.createTerminalAgent({ - worktreeId, + worktreeId: workspace.worktreeId, agent: intent.agent, ...(startupPrompt ? { startupPrompt } : {}), - ...terminalLaunchInputs(intent) + ...terminalLaunchInputs(intent), + viewMode: deriveAgentLaunchTerminalViewMode({ + settings: readAgentLaunchModeSettings(execution.runtime), + agent: intent.agent, + ...(intent.prompt ? { prompt: intent.prompt } : {}), + connectionId: workspace.connectionId + }) }) return { outcome: { diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts index 39906f8c7791..78446330fddd 100644 --- a/src/main/agent-launch/agent-launch-opencode-model.test.ts +++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts @@ -13,6 +13,7 @@ function harness() { const deliverPrompt = vi.fn(async () => true) const createWorktree = vi.fn(async () => ({ worktreeId: 'wt_new', + connectionId: null, startupTerminalHandle: 'term_new' })) return { diff --git a/src/main/agent-launch/agent-launch-pane-attachment.test.ts b/src/main/agent-launch/agent-launch-pane-attachment.test.ts new file mode 100644 index 000000000000..f55317471c99 --- /dev/null +++ b/src/main/agent-launch/agent-launch-pane-attachment.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentLaunchResult } from '../../shared/agent-launch-intent' +import { + pendingAgentSessionOperationRow, + type AgentSessionOperationOutcome, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + markAgentLaunchesClosedByUser, + resetAgentLaunchPanesForTests, + resolveAgentLaunchPaneVerdict, + trackRunningAgentLaunchPane, + type AgentLaunchPaneEvidence +} from './agent-launch-pane-attachment' + +const PANE = { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-1' } +const NOW = Date.now() +let rowCounter = 0 + +function row( + outcome: AgentSessionOperationOutcome, + overrides: Partial = {} +): AgentSessionOperationRow { + rowCounter += 1 + return { + ...pendingAgentSessionOperationRow({ + callerKey: 'caller', + operationId: `${NOW}-${rowCounter.toString(16).padStart(32, '0')}`, + fingerprint: 'fp', + now: NOW + }), + outcome, + ownedPane: PANE, + ...overrides + } +} + +function terminalLaunch(paneKey: string): AgentLaunchResult { + return { + outcome: { kind: 'terminal', handle: 'term_1', paneKey }, + worktreeId: 'wt-1', + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' } + } +} + +const SUCCEEDED_HERE: AgentSessionOperationOutcome = { + status: 'succeeded', + sessionId: '', + launch: terminalLaunch(PANE.paneKey) +} + +function evidence( + rows: AgentSessionOperationRow[] | null, + overrides: Partial = {} +): AgentLaunchPaneEvidence { + return { + isPaneLive: () => false, + openedRows: () => rows, + launchPaneOnTab: () => null, + openRows: async () => rows ?? [], + now: () => NOW, + ...overrides + } +} + +async function verdictFor(found: AgentLaunchPaneEvidence) { + return (await resolveAgentLaunchPaneVerdict(PANE, found)) ?? { kind: 'no-check' } +} + +describe('a pane an agent launch laid out', () => { + afterEach(() => { + resetAgentLaunchPanesForTests() + }) + + it('costs a pane nothing launches into not even a wait', () => { + expect(resolveAgentLaunchPaneVerdict(PANE, evidence([]))).toBeNull() + expect( + resolveAgentLaunchPaneVerdict(PANE, evidence([row({ status: 'failed', code: 'x' })])) + ).not.toBeNull() + expect(resolveAgentLaunchPaneVerdict(PANE, evidence(null))).toBeNull() + }) + + it('waits for the launch running in this process, then reads how it ended off the record', async () => { + const running = trackRunningAgentLaunchPane(PANE) + const rows: AgentSessionOperationRow[] = [] + const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence(rows)) + expect(verdict).not.toBeNull() + + rows.push(row({ status: 'failed', code: 'agent_session_exited_during_start' })) + running.finish({ tabTakenBack: false }) + + await expect(verdict).resolves.toEqual({ + kind: 'not-started', + code: 'agent_session_exited_during_start' + }) + }) + + it("hears the user's close only while the pane's launch is running", () => { + const tabId = 'tab-closing' + const leafId = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + markAgentLaunchesClosedByUser('wt-1', { kind: 'tab', tabId }) + const running = trackRunningAgentLaunchPane({ + worktreeId: 'wt-1', + paneKey: `${tabId}:${leafId}` + }) + expect(running.closedByUser()).toBe(false) + markAgentLaunchesClosedByUser('wt-1', { kind: 'tab', tabId: 'tab-other' }) + markAgentLaunchesClosedByUser('wt-1', { + kind: 'pane', + tabId, + leafId: '6fa459ea-ee8a-4ca4-894e-db77e160355e' + }) + expect(running.closedByUser()).toBe(false) + markAgentLaunchesClosedByUser('wt-1', { kind: 'pane', tabId, leafId }) + expect(running.closedByUser()).toBe(true) + }) + + it('never offers a shell in a tab the host is taking back', async () => { + const running = trackRunningAgentLaunchPane(PANE) + const verdict = resolveAgentLaunchPaneVerdict(PANE, evidence([])) + running.finish({ tabTakenBack: true }) + await expect(verdict).resolves.toEqual({ kind: 'withdrawn' }) + }) + + it('attaches to a process that holds the pane, whatever the record says', async () => { + const live = { isPaneLive: () => true } + await expect(verdictFor(evidence([row({ status: 'unknown' })], live))).resolves.toEqual({ + kind: 'proceed' + }) + await expect( + verdictFor(evidence([row({ status: 'failed', code: 'x' })], live)) + ).resolves.toEqual({ kind: 'proceed' }) + }) + + it.each([ + [ + 'a failed launch: not started, with its reason', + [row({ status: 'failed', code: 'boom' })], + { kind: 'not-started', code: 'boom' } + ], + [ + 'an outcome nobody knows: unconfirmed, never a failure', + [row({ status: 'unknown' })], + { kind: 'unconfirmed' } + ], + [ + 'a launch that ran into another surface: withdrawn', + [row({ status: 'succeeded', sessionId: 's-1', launch: terminalLaunch('other:pane') })], + { kind: 'withdrawn' } + ], + [ + 'an agent that ran here and is gone: an ordinary terminal', + [row(SUCCEEDED_HERE)], + { kind: 'proceed' } + ], + [ + 'an expired row owns nothing', + [row({ status: 'failed', code: 'boom' }, { expiresAt: NOW - 1 })], + { kind: 'no-check' } + ], + [ + 'a refused second launch never overrides the one that ran here', + [ + row(SUCCEEDED_HERE, { recordedAt: NOW }), + row({ status: 'failed', code: 'agent_launch_pane_already_live' }, { recordedAt: NOW + 1 }) + ], + { kind: 'proceed' } + ], + [ + 'another pane of the same tab is not owned', + [ + row( + { status: 'failed', code: 'boom' }, + { ownedPane: { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-2' } } + ) + ], + { kind: 'no-check' } + ] + ] as const)('%s', async (_name, rows, expected) => { + await expect(verdictFor(evidence([...rows]))).resolves.toEqual(expected) + }) + + it('after a restart, opens the record for a pane whose launch was still open', async () => { + const restarted = evidence(null, { + launchPaneOnTab: () => ({}), + openRows: async () => [row({ status: 'unknown' })] + }) + await expect(verdictFor(restarted)).resolves.toEqual({ kind: 'unconfirmed' }) + }) + + it("answers from the tab once its pane's fate is final, without the record and past its expiry", async () => { + const openRows = vi.fn(async () => []) + const restarted = evidence(null, { + launchPaneOnTab: () => ({ outcome: { kind: 'not-started', code: 'boom' } }), + openRows + }) + await expect(verdictFor(restarted)).resolves.toEqual({ kind: 'not-started', code: 'boom' }) + expect(openRows).not.toHaveBeenCalled() + }) + + it('adopts a process its persisted binding names, even over a final "couldn\'t confirm"', async () => { + const survived = evidence(null, { + isPaneLive: () => true, + launchPaneOnTab: () => ({ outcome: { kind: 'unconfirmed' } }) + }) + await expect(verdictFor(survived)).resolves.toEqual({ kind: 'proceed' }) + }) + + it('settles a pane no record names as an ordinary terminal, so its tab can forget the launch', async () => { + const orphaned = evidence(null, { launchPaneOnTab: () => ({}), openRows: async () => [] }) + await expect(verdictFor(orphaned)).resolves.toEqual({ kind: 'proceed' }) + }) + + it('reads nothing for a pane whose tab keeps nothing and no record row names', () => { + const openRows = vi.fn(async () => []) + expect(resolveAgentLaunchPaneVerdict(PANE, evidence(null, { openRows }))).toBeNull() + expect(openRows).not.toHaveBeenCalled() + }) + + it('leaves an ordinary terminal when the record cannot be read: bookkeeping never gates the pane', async () => { + const unreadable = evidence(null, { + launchPaneOnTab: () => ({}), + openRows: () => Promise.reject(new Error('journal_open_refused')) + }) + await expect(verdictFor(unreadable)).resolves.toEqual({ kind: 'proceed' }) + }) +}) diff --git a/src/main/agent-launch/agent-launch-pane-attachment.ts b/src/main/agent-launch/agent-launch-pane-attachment.ts new file mode 100644 index 000000000000..25eef063db59 --- /dev/null +++ b/src/main/agent-launch/agent-launch-pane-attachment.ts @@ -0,0 +1,216 @@ +/** + * What a pane may do when an `agent.launch` showed it before its agent existed. + * + * Derived on every spawn. While the launch's fate is open, the launch record (the operation ledger) + * names the launch that owns the pane and how it ended; the runtime, and the pane's persisted + * binding, say whether a process holds it. Once the fate is final for the pane, the tab itself keeps + * it (`agentLaunchPane.outcome`) for the tab's life, so the pane no longer reads the record. The one + * in-memory fact is a launch still running in this process, which dies with that launch. Its pane + * attaches as soon as its agent runs; a user's close of its tab or pane, committed to main, stops it. + */ + +import { + listAgentSessionOperationRowsOwningPane, + type AgentSessionOperationOwnedPane, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { isAgentLaunchResult } from '../../shared/agent-launch-intent' +import type { + AgentLaunchPaneOutcome, + AgentLaunchPaneVerdict +} from '../../shared/agent-launch-pane-verdict' +import { parsePaneKey } from '../../shared/stable-pane-id' +import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' + +type RunningLaunch = { + pane: AgentSessionOperationOwnedPane + /** The launch's agent holds the pane, or the launch is over: whichever comes first. */ + settled: Promise<{ tabTakenBack: boolean }> + finished: Promise<{ tabTakenBack: boolean }> + /** The user closed the launch's tab or pane while it ran; set by main's commit of that close. */ + closedByUser: boolean +} + +const runningLaunchesByPane = new Map() + +function paneKeyOf(pane: AgentSessionOperationOwnedPane): string { + return JSON.stringify([pane.worktreeId, pane.paneKey]) +} + +export type RunningAgentLaunchPane = { + /** The launch's agent runs in the pane: a waiting spawn attaches now, not once the prompt lands. */ + agentBound(): void + /** The launch is over; its record says how. `tabTakenBack`: the host is closing the tab. */ + finish(outcome: { tabTakenBack: boolean }): void + /** The user closed this launch's tab or pane: the launch must not run, or must stop. */ + closedByUser(): boolean +} + +/** Registered before the window hears of the tab, so a pane that mounts at once already waits. */ +export function trackRunningAgentLaunchPane( + pane: AgentSessionOperationOwnedPane +): RunningAgentLaunchPane { + const key = paneKeyOf(pane) + let resolve!: (outcome: { tabTakenBack: boolean }) => void + let settle!: (outcome: { tabTakenBack: boolean }) => void + const running: RunningLaunch = { + pane, + settled: new Promise((done) => { + settle = done + }), + finished: new Promise((done) => { + resolve = done + }), + closedByUser: false + } + runningLaunchesByPane.set(key, running) + let finished = false + return { + agentBound: () => settle({ tabTakenBack: false }), + finish: (outcome) => { + if (finished) { + return + } + finished = true + if (runningLaunchesByPane.get(key) === running) { + runningLaunchesByPane.delete(key) + } + settle(outcome) + resolve(outcome) + }, + closedByUser: () => running.closedByUser + } +} + +function runningLaunchesIn( + worktreeId: string, + target: TerminalSurfaceCloseTarget +): RunningLaunch[] { + return [...runningLaunchesByPane.values()].filter((running) => { + const pane = running.pane.worktreeId === worktreeId ? parsePaneKey(running.pane.paneKey) : null + return pane?.tabId === target.tabId && (target.kind === 'tab' || pane.leafId === target.leafId) + }) +} + +/** The user closed a tab, or one pane of it: every launch still running there stops. */ +export function markAgentLaunchesClosedByUser( + worktreeId: string, + target: TerminalSurfaceCloseTarget +): void { + for (const running of runningLaunchesIn(worktreeId, target)) { + running.closedByUser = true + } +} + +/** A launch still owns this pane: only its own spawn may put a process there. */ +export function isAgentLaunchRunningIn( + worktreeId: string, + target: TerminalSurfaceCloseTarget +): boolean { + return runningLaunchesIn(worktreeId, target).length > 0 +} + +function launchRanInPane(row: AgentSessionOperationRow, paneKey: string): boolean { + if (row.outcome.status !== 'succeeded' || !isAgentLaunchResult(row.outcome.launch)) { + return false + } + const { outcome } = row.outcome.launch + return outcome.kind === 'terminal' && outcome.paneKey === paneKey +} + +/** The pane's fate as the record tells it. Exported for the record-only cases a test pins. */ +export function agentLaunchPaneVerdictFromRecord( + owning: readonly AgentSessionOperationRow[], + paneKey: string +): AgentLaunchPaneVerdict { + if (owning.length === 0 || owning.some((row) => launchRanInPane(row, paneKey))) { + // Nothing owns it, or an agent ran here and is gone: the pane is an ordinary terminal again. + return { kind: 'proceed' } + } + const latest = owning.reduce((a, b) => (b.recordedAt > a.recordedAt ? b : a)) + switch (latest.outcome.status) { + case 'failed': + return { kind: 'not-started', code: latest.outcome.code } + case 'succeeded': + // A chat, or another pane: nothing ran here. + return { kind: 'withdrawn' } + case 'unknown': + case 'pending': + return { kind: 'unconfirmed' } + } +} + +export type AgentLaunchPaneEvidence = { + /** A process holds the pane, live or by its persisted binding: the spawn adopts it, whatever the + * record says. */ + isPaneLive(paneKey: string): boolean + /** The record's rows when the store is already open; null when it is not. */ + openedRows(): Iterable | null + /** What the pane's tab keeps about its launch: null when no launch laid it out, no outcome while + * the fate is open, the outcome once it is final. */ + launchPaneOnTab(): { outcome?: AgentLaunchPaneOutcome } | null + openRows(): Promise> + now(): number +} + +async function settleVerdict( + pane: AgentSessionOperationOwnedPane, + evidence: AgentLaunchPaneEvidence +): Promise { + let waitedForLaunch = false + for (;;) { + const running = runningLaunchesByPane.get(paneKeyOf(pane)) + if (!running) { + break + } + waitedForLaunch = true + if ((await running.settled).tabTakenBack) { + return { kind: 'withdrawn' } + } + if (evidence.isPaneLive(pane.paneKey)) { + return { kind: 'proceed' } + } + if ((await running.finished).tabTakenBack) { + return { kind: 'withdrawn' } + } + } + if (evidence.isPaneLive(pane.paneKey)) { + return { kind: 'proceed' } + } + // A launch that just settled here wrote the record; the tab's saved outcome may be an earlier + // launch's, not yet replaced on disk. + const final = waitedForLaunch ? undefined : evidence.launchPaneOnTab()?.outcome + if (final) { + return final + } + // Bookkeeping never gates the user: a record that cannot be read leaves an ordinary terminal. + const rows = evidence.openedRows() ?? (await evidence.openRows().catch(() => null)) + return rows + ? agentLaunchPaneVerdictFromRecord( + listAgentSessionOperationRowsOwningPane(rows, pane, evidence.now()), + pane.paneKey + ) + : { kind: 'proceed' } +} + +/** + * Null when nothing can own the pane — no launch running for it, its tab keeping nothing about a + * launch, and no record row naming it — so every other spawn keeps its timing and reads nothing. + * Otherwise the verdict, once any running launch is over. + */ +export function resolveAgentLaunchPaneVerdict( + pane: AgentSessionOperationOwnedPane, + evidence: AgentLaunchPaneEvidence +): Promise | null { + if (!runningLaunchesByPane.has(paneKeyOf(pane)) && evidence.launchPaneOnTab() === null) { + const rows = evidence.openedRows() + if (!rows || listAgentSessionOperationRowsOwningPane(rows, pane, evidence.now()).length === 0) { + return null + } + } + return settleVerdict(pane, evidence) +} + +export function resetAgentLaunchPanesForTests(): void { + runningLaunchesByPane.clear() +} diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index e7d36f05ea1c..cf8736408610 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -35,6 +35,8 @@ export type AgentLaunchSurfaceFactory = { launchSource?: string /** The caller-minted pane to create; refused with `AgentLaunchPaneAlreadyLiveError` if live. */ paneKey?: string + /** The tab's first view, derived on the host by the window's own rule. */ + viewMode?: 'terminal' | 'chat' }): Promise<{ handle: string /** The pane this create minted; a factory whose runtime reports none omits it, never invents. */ @@ -116,6 +118,8 @@ export type AgentLaunchWorkspaceFactory = { options?: Readonly> }): Promise<{ worktreeId: string + /** The new workspace's SSH connection; `null` is local. Decides what its agent tab can show. */ + connectionId: string | null startupTerminalHandle: string | undefined /** The pane minted with the startup terminal, when the runtime reported one. */ startupTerminalPaneKey?: string diff --git a/src/main/agent-launch/agent-launch-view-mode.ts b/src/main/agent-launch/agent-launch-view-mode.ts new file mode 100644 index 000000000000..f7e6d565b38b --- /dev/null +++ b/src/main/agent-launch/agent-launch-view-mode.ts @@ -0,0 +1,39 @@ +/** + * Which view a terminal agent tab opens in — the terminal itself or its chat view — decided on the + * host by the rule the window applies to a tab it creates, so a launch from the phone, the CLI or the + * window opens the same way. Never a caller field. + */ + +import type { AgentLaunchPrompt } from '../../shared/agent-launch-intent' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { decideInitialAgentTabViewMode } from '../../shared/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '../../shared/native-chat-transcript-readability' +import type { TuiAgent } from '../../shared/tui-agent' + +export type AgentLaunchTerminalViewMode = 'terminal' | 'chat' + +/** Always explicit: an absent view lets the window re-derive it without the prompt's delivery. */ +export function deriveAgentLaunchTerminalViewMode(args: { + settings: Partial< + Pick + > | null + agent: TuiAgent + prompt?: AgentLaunchPrompt + /** The workspace's SSH connection; `null` is local. */ + connectionId: string | null | undefined +}): AgentLaunchTerminalViewMode { + const viewMode = decideInitialAgentTabViewMode({ + experimentalNativeChat: args.settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: args.settings?.openAgentTabsInChatByDefault, + agent: args.agent, + ...(args.prompt + ? { + promptDelivery: args.prompt.delivery === 'draft' ? 'draft' : 'auto-submit', + launchDraftText: args.prompt.text + } + : {}), + nativeChatTranscriptIsLocalReadable: + args.connectionId !== undefined && isNativeChatTranscriptLocalReadable(args.connectionId) + }) + return viewMode === 'chat' ? 'chat' : 'terminal' +} diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts index 3eedd3c80501..96f182b45435 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.test.ts @@ -24,11 +24,13 @@ import { } from '../../shared/protocol-version' import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_RUNTIME_CAPABILITY + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY } from '../../shared/agent-launch-runtime-capability' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { remoteRuntimeClientCapabilities } from '../../shared/remote-runtime-client-capabilities' import { supportsAgentLaunch } from '../runtime/rpc/methods/agent-launch' +import { readsAgentLaunchTabClosed } from '../runtime/rpc/methods/agent-launch-replay' import { createSupportFollowsHostSetting } from '../runtime/rpc/methods/structured-agent-session-policy' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from './desktop-renderer-runtime-capabilities' @@ -69,7 +71,8 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ /** Advertised to main and deliberately NOT to a remote host yet. */ const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [ // Read by the desktop's own launches first; a remote host is told when its launches move over. - AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY ] function missingFrom( @@ -97,6 +100,15 @@ describe('desktop renderer runtime client capabilities', () => { ).toBe(false) }) + it('is told a launch ended because the user closed its tab, not that its outcome is unknown', () => { + expect( + readsAgentLaunchTabClosed({ + clientKind: 'runtime', + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES + }) + ).toBe(true) + }) + // The desktop routes a launch on its own settings. A paired host answering createSupport from its // own setting would fail a chat the user asked for; main shares the desktop's setting, so locally // this only lets Retry on an existing chat relaunch after the setting is turned off. diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index b7a34a12ac43..c622f5a24e19 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -12,7 +12,8 @@ import { } from '../../shared/protocol-version' import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, - AGENT_LAUNCH_RUNTIME_CAPABILITY + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY } from '../../shared/agent-launch-runtime-capability' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from '../../shared/agent-session-background-task-child-views-capability' @@ -45,5 +46,8 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_LAUNCH_RUNTIME_CAPABILITY, // A replay after a restart mid-delivery answers the running agent with an `unconfirmed` prompt, // which the desktop reads, rather than refusing it as unknown. - AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + // A launch whose tab the user closed is answered as exactly that, which the desktop stays silent + // on, rather than as unknown, which it would report as a failure. + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY ] as const diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts new file mode 100644 index 000000000000..9f624dda4406 --- /dev/null +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -0,0 +1,339 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { makePaneKey } from '../../shared/stable-pane-id' +import { registerPtyHandlers, setLocalPtyProvider } from './pty' +import { + resetAgentLaunchPanesForTests, + trackRunningAgentLaunchPane +} from '../agent-launch/agent-launch-pane-attachment' +import { + pendingAgentSessionOperationRow, + type AgentSessionOperationOutcome, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + AGENT_LAUNCH_PANE_REFUSED_CODE, + type AgentLaunchPaneOutcome +} from '../../shared/agent-launch-pane-verdict' + +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()) +) + +function localProvider(spawn: ReturnType): never { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a test double carrying every provider member the spawn path calls; an unexpected call throws on the missing method. + return { + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never +} + +describe('a pane whose process belongs to an agent launch', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + const tabId = 'tab-agent-launch-early' + const leafId = '55555555-5555-4555-8555-555555555555' + const worktreeId = 'repo-1::/tmp/agent-launch-early' + const paneKey = makePaneKey(tabId, leafId) + const pane = { worktreeId, paneKey } + + afterEach(() => { + resetAgentLaunchPanesForTests() + }) + + function recorded(outcome: AgentSessionOperationOutcome): AgentSessionOperationRow { + return { + ...pendingAgentSessionOperationRow({ + callerKey: 'caller', + operationId: `${Date.now()}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp', + now: Date.now() + }), + outcome, + ownedPane: pane + } + } + + function mountPane(): Promise { + return Promise.resolve( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp/agent-launch-early', + worktreeId, + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_WORKTREE_ID: worktreeId } + }) + ) + } + + type LaunchPaneOnTab = { leafId: string; outcome?: AgentLaunchPaneOutcome } + + /** `rows` is the launch record; null models a process that has not opened it yet. `onTab` is + * what the restored tab keeps about its pane's launch. Returns the runtime the spawn reports to. */ + function registerWithRuntime( + providerSpawn: ReturnType, + record: { rows: AgentSessionOperationRow[] | null; onTab?: LaunchPaneOnTab } + ) { + setLocalPtyProvider(localProvider(providerSpawn)) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_early'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + hasLiveTerminalForPaneKey: vi.fn(() => false), + openedAgentSessionRecordStore: vi.fn(() => + record.rows ? { listOperationRows: () => record.rows } : null + ), + openAgentSessionRecordStore: vi.fn(async () => ({ + listOperationRows: () => record.rows ?? [recorded({ status: 'unknown' })] + })), + reportAgentLaunchPaneVerdict: vi.fn() + } + // The persisted session as a restart restores it. + const store = record.onTab + ? { + getWorkspaceSession: () => ({ + tabsByWorktree: { [worktreeId]: [{ id: tabId, agentLaunchPane: record.onTab }] } + }) + } + : undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window double, carrying the members a renderer spawn reaches. + const window = mainWindow as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a runtime carrying only the members a renderer spawn reaches before it is refused or proceeds. + const spawnRuntime = runtime as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a store carrying only the persisted session the launch pane's spawn reads. + const spawnStore = store as never + registerPtyHandlers(window, spawnRuntime, undefined, undefined, undefined, spawnStore) + return runtime + } + + const PANE_ADDRESS = { worktreeId, tabId, leafId } + + it('waits for the launch instead of spawning while it runs, then tells the window it settled', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-after-launch' })) + const rows: AgentSessionOperationRow[] = [] + const runtime = registerWithRuntime(providerSpawn, { rows }) + const running = trackRunningAgentLaunchPane(pane) + + const mounted = mountPane() + // An unblocked spawn settles well inside this window. + const early = await Promise.race([ + mounted.then(() => 'settled'), + new Promise((resolve) => setTimeout(() => resolve('waiting'), 1000)) + ]) + expect(early).toBe('waiting') + expect(providerSpawn).not.toHaveBeenCalled() + + rows.push( + recorded({ + status: 'succeeded', + sessionId: '', + launch: { + outcome: { kind: 'terminal', handle: 'term_1', paneKey }, + worktreeId, + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' } + } + }) + ) + running.finish({ tabTakenBack: false }) + await expect(mounted).resolves.toMatchObject({ id: 'pty-after-launch' }) + // Settled: the tab forgets the launch, so a restart never opens the record for this pane. + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'proceed' + }) + }) + + it('tells the window the launch failed, typed, instead of starting a shell', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) + const rows: AgentSessionOperationRow[] = [] + const runtime = registerWithRuntime(providerSpawn, { rows }) + const running = trackRunningAgentLaunchPane(pane) + + const mounted = mountPane() + await new Promise((resolve) => setImmediate(resolve)) + rows.push(recorded({ status: 'failed', code: 'agent_session_exited_during_start' })) + running.finish({ tabTakenBack: false }) + + await expect(mounted).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'not-started', + code: 'agent_session_exited_during_start' + }) + }) + + it('still refuses a shell to a pane that mounts after the launch failed', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) + registerWithRuntime(providerSpawn, { + rows: [recorded({ status: 'failed', code: 'agent_not_installed' })] + }) + + await expect(mountPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + expect(providerSpawn).not.toHaveBeenCalled() + }) + + it('after a restart, a pane whose launch was still open reads the record before it starts anything', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) + const runtime = registerWithRuntime(providerSpawn, { rows: null, onTab: { leafId } }) + + await expect(mountPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'unconfirmed' + }) + }) + + it('after a restart, a pane whose fate the tab keeps says it again without opening the record', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) + const runtime = registerWithRuntime(providerSpawn, { + rows: null, + onTab: { leafId, outcome: { kind: 'not-started', code: 'agent_not_installed' } } + }) + + await expect(mountPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + }) + + it('a new launch into a pane that showed "couldn\'t confirm" lets it attach to the new agent', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-new-agent' })) + const rows: AgentSessionOperationRow[] = [] + // The saved tab still says an earlier launch could not be confirmed. + const runtime = registerWithRuntime(providerSpawn, { + rows, + onTab: { leafId, outcome: { kind: 'unconfirmed' } } + }) + const running = trackRunningAgentLaunchPane(pane) + + // The remounted pane spawns while the new launch runs: it waits, it does not show the old notice. + const mounted = mountPane() + rows.push( + recorded({ + status: 'succeeded', + sessionId: '', + launch: { + outcome: { kind: 'terminal', handle: 'term_1', paneKey }, + worktreeId, + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: '' } + } + }) + ) + running.finish({ tabTakenBack: false }) + + // Past the verdict the ordinary spawn path runs (this suite's store double saves nothing). + await mounted.catch(() => {}) + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'proceed' + }) + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'unconfirmed' + }) + expect(providerSpawn).toHaveBeenCalled() + }) + + it("attaches once the launch's agent holds the pane, not once its prompt lands", async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) + const runtime = registerWithRuntime(providerSpawn, { rows: [] }) + const running = trackRunningAgentLaunchPane(pane) + + const mounted = mountPane() + // The agent runs here; its prompt is still being delivered. + runtime.hasLiveTerminalForPaneKey.mockReturnValue(true) + running.agentBound() + + await mounted.catch(() => {}) + expect(providerSpawn).toHaveBeenCalled() + // Settled for the window: a later close reaches the launch through main's commit of it. + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'proceed' + }) + running.finish({ tabTakenBack: false }) + }) + + it('after a restart, a settled launch pane spawns as any pane does, without opening the record', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-ordinary' })) + const runtime = registerWithRuntime(providerSpawn, { rows: null }) + + await expect(mountPane()).resolves.toMatchObject({ id: 'pty-ordinary' }) + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + }) + + it('never offers a shell in a tab the host is taking back', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-plain-shell' })) + const runtime = registerWithRuntime(providerSpawn, { rows: [] }) + const running = trackRunningAgentLaunchPane(pane) + + const mounted = mountPane() + running.finish({ tabTakenBack: true }) + + await expect(mounted).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'withdrawn' + }) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index a7ae10bc0363..c47ec56caf1a 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -1,4 +1,7 @@ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { resolveAgentLaunchPaneVerdict } from '../../../agent-launch/agent-launch-pane-attachment' +import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' +import { agentLaunchPaneEvidence } from '../pane/agent-launch-pane-evidence' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import type { PtySpawnResult } from '../../../providers/types' import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' @@ -38,6 +41,32 @@ 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. + const launchPaneKey = ctx.paneSpawnReservation ? null : resolveEarlyPaneKey(args) + const launchVerdict = + launchPaneKey && args.worktreeId && args.tabId && args.leafId + ? resolveAgentLaunchPaneVerdict( + { worktreeId: args.worktreeId, paneKey: launchPaneKey }, + agentLaunchPaneEvidence(ctx.deps, { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId, + connectionId: args.connectionId + }) + ) + : null + if (launchVerdict && args.worktreeId && args.tabId && args.leafId) { + const verdict = await launchVerdict + // The window keeps a final verdict on the tab, clears a settled one, takes a withdrawn pane back. + ctx.deps.runtime?.reportAgentLaunchPaneVerdict?.( + { worktreeId: args.worktreeId, tabId: args.tabId, leafId: args.leafId }, + verdict + ) + if (verdict.kind !== 'proceed') { + throw new Error(AGENT_LAUNCH_PANE_REFUSED_CODE) + } + } ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined ctx.codexHomeLaunchStartedSequence = !args.connectionId ? allocatePtyLifecycleSequence() diff --git a/src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts b/src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts new file mode 100644 index 000000000000..9d8a782d9f84 --- /dev/null +++ b/src/main/ipc/pty/pane/agent-launch-pane-evidence.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { agentLaunchPaneEvidence } from './agent-launch-pane-evidence' + +const WT = 'repo-1::/tmp/evidence' +const TAB = 'tab-evidence' +const LEAF = '66666666-6666-4666-8666-666666666666' + +function storeWith(session: Record): never { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a store carrying only the persisted session these readers use. + return { getWorkspaceSession: () => session } as never +} + +const launchTab = { + id: TAB, + worktreeId: WT, + ptyId: null, + agentLaunchPane: { leafId: LEAF, outcome: { kind: 'unconfirmed' } } +} + +describe('what a launch pane spawn reads', () => { + it("counts a restored pane's persisted binding as a process that holds it", () => { + const evidence = agentLaunchPaneEvidence( + { + store: storeWith({ + tabsByWorktree: { [WT]: [launchTab] }, + terminalLayoutsByTabId: { [TAB]: { ptyIdsByLeafId: { [LEAF]: 'pty-survived' } } } + }) + }, + { worktreeId: WT, tabId: TAB, leafId: LEAF } + ) + expect(evidence.isPaneLive(makePaneKey(TAB, LEAF))).toBe(true) + }) + + it('reads the launch the tab keeps for this leaf only', () => { + const store = storeWith({ tabsByWorktree: { [WT]: [launchTab] } }) + expect( + agentLaunchPaneEvidence( + { store }, + { worktreeId: WT, tabId: TAB, leafId: LEAF } + ).launchPaneOnTab() + ).toEqual({ leafId: LEAF, outcome: { kind: 'unconfirmed' } }) + expect( + agentLaunchPaneEvidence( + { store }, + { worktreeId: WT, tabId: TAB, leafId: '77777777-7777-4777-8777-777777777777' } + ).launchPaneOnTab() + ).toBeNull() + expect( + agentLaunchPaneEvidence({ store }, { worktreeId: WT, tabId: TAB, leafId: LEAF }).isPaneLive( + makePaneKey(TAB, LEAF) + ) + ).toBe(false) + }) +}) diff --git a/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts b/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts new file mode 100644 index 000000000000..98c0307b2a74 --- /dev/null +++ b/src/main/ipc/pty/pane/agent-launch-pane-evidence.ts @@ -0,0 +1,58 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { AgentLaunchPaneEvidence } from '../../../agent-launch/agent-launch-pane-attachment' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { resolveStablePaneOwner } from './stable-owner' + +/** Reading the record is bookkeeping: a read that fails must leave the spawn as it was, never stop it. */ +function orElse(read: () => T, fallback: T): T { + try { + return read() + } catch { + return fallback + } +} + +/** What a pane's spawn reads to learn whether an agent launch owns it. */ +export function agentLaunchPaneEvidence( + deps: { runtime?: OrcaRuntimeService; store?: Store }, + pane: { worktreeId: string; tabId: string; leafId: string; connectionId?: string | null } +): AgentLaunchPaneEvidence { + const { runtime, store } = deps + const paneKey = makePaneKey(pane.tabId, pane.leafId) + return { + // The persisted binding counts: a restored pane whose agent survived in the daemon is adopted + // by the spawn below, never called unconfirmed. + isPaneLive: () => + orElse( + () => + (runtime?.hasLiveTerminalForPaneKey(paneKey) ?? false) || + resolveStablePaneOwner(runtime, store, paneKey, pane.worktreeId, pane.connectionId) !== + null, + false + ), + openedRows: () => + orElse(() => runtime?.openedAgentSessionRecordStore()?.listOperationRows() ?? null, null), + launchPaneOnTab: () => + orElse(() => { + if (!store || typeof store.getWorkspaceSession !== 'function') { + return null + } + const session = store.getWorkspaceSession( + pane.connectionId ? toSshExecutionHostId(pane.connectionId) : undefined + ) + const launchPane = session.tabsByWorktree?.[pane.worktreeId]?.find( + (candidate) => candidate.id === pane.tabId + )?.agentLaunchPane + return launchPane?.leafId === pane.leafId ? launchPane : null + }, null), + openRows: async () => { + if (!runtime) { + throw new Error('runtime_unavailable') + } + return (await runtime.openAgentSessionRecordStore()).listOperationRows() + }, + now: () => Date.now() + } +} diff --git a/src/main/ipc/session-close-terminal-surface.test.ts b/src/main/ipc/session-close-terminal-surface.test.ts new file mode 100644 index 000000000000..909c9c43c651 --- /dev/null +++ b/src/main/ipc/session-close-terminal-surface.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { + resetAgentLaunchPanesForTests, + trackRunningAgentLaunchPane +} from '../agent-launch/agent-launch-pane-attachment' + +const mocks = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, callback: (...args: unknown[]) => unknown) => + mocks.handlers.set(name, callback), + on: vi.fn() + } +})) +import { registerSessionHandlers } from './session' + +const WT = 'repo1::/tmp/feature' +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const OTHER_LEAF = '6fa459ea-ee8a-4ca4-894e-db77e160355e' + +const closeTerminalSurfaceFromRenderer = vi.fn(async () => {}) + +function closeSurface(args: unknown): unknown { + return mocks.handlers.get('session:close-terminal-surface')?.({}, args) +} + +function launchIn(leafId: string) { + return trackRunningAgentLaunchPane({ worktreeId: WT, paneKey: makePaneKey(TAB, leafId) }) +} + +beforeEach(() => { + mocks.handlers.clear() + closeTerminalSurfaceFromRenderer.mockClear() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler under test reaches only the runtime's renderer close. + const runtime = { closeTerminalSurfaceFromRenderer } as never + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close handler never touches the store. + registerSessionHandlers({} as never, runtime) +}) + +afterEach(() => { + resetAgentLaunchPanesForTests() +}) + +describe("the user's close of a launch's tab or pane", () => { + it('stops a launch still running in the tab the user closed', async () => { + const running = launchIn(LEAF) + + await closeSurface({ worktreeId: WT, target: { kind: 'tab', tabId: TAB }, reason: 'user' }) + + expect(running.closedByUser()).toBe(true) + expect(closeTerminalSurfaceFromRenderer).toHaveBeenCalledWith({ + worktreeId: WT, + target: { kind: 'tab', tabId: TAB }, + reason: 'user' + }) + }) + + it('stops only the launch in the pane the user closed, not a split beside it', async () => { + const closed = launchIn(LEAF) + const kept = launchIn(OTHER_LEAF) + + await closeSurface({ + worktreeId: WT, + target: { kind: 'pane', tabId: TAB, leafId: LEAF }, + reason: 'user' + }) + + expect(closed.closedByUser()).toBe(true) + expect(kept.closedByUser()).toBe(false) + }) + + it('is not the user stopping it when the host tidies the tab away', async () => { + const running = launchIn(LEAF) + + await closeSurface({ worktreeId: WT, target: { kind: 'tab', tabId: TAB }, reason: 'cleanup' }) + + expect(running.closedByUser()).toBe(false) + }) + + it("never touches another workspace's launch", async () => { + const running = launchIn(LEAF) + + await closeSurface({ + worktreeId: 'repo1::/tmp/other', + target: { kind: 'tab', tabId: TAB }, + reason: 'user' + }) + + expect(running.closedByUser()).toBe(false) + }) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index f1f0c1b14e15..6c26bff46703 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -2,6 +2,7 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' +import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' import type { WorkspaceSessionPatch, WorkspaceSessionState @@ -39,10 +40,15 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic throw new Error('invalid_terminal_surface') } // Why only these two: main alone closes a tab for its process exit. + const reason = args.reason === 'cleanup' ? 'cleanup' : 'user' + if (reason === 'user') { + // A launch still starting or delivering in what the user closed stops, and says so. + markAgentLaunchesClosedByUser(args.worktreeId, target) + } return runtime.closeTerminalSurfaceFromRenderer({ worktreeId: args.worktreeId, target, - reason: args.reason === 'cleanup' ? 'cleanup' : 'user' + reason }) } ) diff --git a/src/main/runtime/agent-launch-record-warmup-gate.test.ts b/src/main/runtime/agent-launch-record-warmup-gate.test.ts new file mode 100644 index 000000000000..d2d1588b1446 --- /dev/null +++ b/src/main/runtime/agent-launch-record-warmup-gate.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' + +function gate(isOpen = false) { + const open = vi.fn(async () => {}) + return { open, gate: createAgentLaunchRecordWarmupGate({ isOpen: () => isOpen, open }) } +} + +describe('opening the launch record ahead of a launch', () => { + it('waits for both startup to settle and a client that can launch', () => { + const { open, gate: warmup } = gate() + warmup.startupSettled() + expect(open).not.toHaveBeenCalled() + warmup.launchClientReady() + expect(open).toHaveBeenCalledOnce() + }) + + it('never opens it during startup, even when a phone connects first', () => { + const { open, gate: warmup } = gate() + warmup.launchClientReady() + expect(open).not.toHaveBeenCalled() + warmup.startupSettled() + expect(open).toHaveBeenCalledOnce() + }) + + it('never opens it for a profile no launching client uses', () => { + const { open, gate: warmup } = gate() + warmup.startupSettled() + expect(open).not.toHaveBeenCalled() + }) + + it('does nothing when it is already open', () => { + const { open, gate: warmup } = gate(true) + warmup.startupSettled() + warmup.launchClientReady() + expect(open).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/agent-launch-record-warmup-gate.ts b/src/main/runtime/agent-launch-record-warmup-gate.ts new file mode 100644 index 000000000000..dd37570ea84b --- /dev/null +++ b/src/main/runtime/agent-launch-record-warmup-gate.ts @@ -0,0 +1,36 @@ +/** + * When to open the launch record ahead of a launch: once startup has settled AND a client that can + * call `agent.launch` is connected. A profile no launching client uses never pays the open, and it + * never runs during startup. Admission still opens it on demand. + */ +export type AgentLaunchRecordWarmupGate = { + startupSettled(): void + launchClientReady(): void +} + +export function createAgentLaunchRecordWarmupGate(args: { + isOpen: () => boolean + open: () => Promise +}): AgentLaunchRecordWarmupGate { + let startupSettled = false + let launchClientReady = false + const warmIfUseful = (): void => { + if (!startupSettled || !launchClientReady || args.isOpen()) { + return + } + args.open().catch((error: unknown) => { + // A failed warm-up costs only the first launch, which opens it itself. + console.warn('[agent-launch] could not open the launch record ahead of a launch', error) + }) + } + return { + startupSettled: () => { + startupSettled = true + warmIfUseful() + }, + launchClientReady: () => { + launchClientReady = true + warmIfUseful() + } + } +} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index c83c6d9f0067..6cc192adbe12 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -11,6 +11,7 @@ import { type AgentSessionOperationClaim, type AgentSessionOperationDecision, type AgentSessionOperationOutcome, + type AgentSessionOperationOwnedPane, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { @@ -186,7 +187,7 @@ export function admitAgentSessionGlobalOperationInto( export function claimAgentSessionOperationInto( state: { operations: Map }, - args: { callerKey: string; operationId: string } + args: { callerKey: string; operationId: string; ownedPane?: AgentSessionOperationOwnedPane } ): AgentSessionOperationClaim { const claimed = claimAgentSessionOperation(state.operations, args) state.operations = claimed.rows @@ -196,11 +197,16 @@ export function claimAgentSessionOperationInto( /** Whether an admission left the right to run open, so the same transaction should claim it. */ export type ClaimAfterAdmission = (decision: AgentSessionOperationDecision) => boolean +/** An admission whose claimant laid out a pane before its effect; recorded only if the claim wins. */ +export type AgentSessionOperationClaimingAdmission = AgentSessionOperationAdmission & { + ownedPane?: AgentSessionOperationOwnedPane +} + /** Admission and, when `claimAfter` says so, the claim, in one transaction: the same swap as * `claimAgentSessionOperationInto`, with one durable write instead of two. */ export function admitAndClaimAgentSessionOperationInto( state: { operations: Map }, - args: AgentSessionOperationAdmission, + args: AgentSessionOperationClaimingAdmission, claimAfter: ClaimAfterAdmission ): { decision: AgentSessionOperationDecision; claim: AgentSessionOperationClaim | null } { const decision = admitAgentSessionOperationInto(state, args) diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index 3e74f88282ca..e40ddbd19b06 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -27,8 +27,14 @@ export type OpenedAgentSessionRecordStore = { store: AgentSessionRecordStore } -let opening: { stateDirectory: string; opened: Promise } | null = - null +type RecordStoreSlot = { + stateDirectory: string + opened: Promise + /** Set once `opened` resolves, so a reader that must not wait can ask without awaiting. */ + store?: AgentSessionRecordStore +} + +let opening: RecordStoreSlot | null = null export function openAgentSessionRecordStoreOnce( location: AgentSessionRecordStoreLocation @@ -40,20 +46,31 @@ export function openAgentSessionRecordStoreOnce( } return opening.opened } - const slot = { + const slot: RecordStoreSlot = { stateDirectory: location.stateDirectory, - opened: openRecordStore(location).catch((error: unknown) => { - // A failed open must not poison the slot: the next caller retries. - if (opening === slot) { - opening = null + opened: openRecordStore(location).then( + (opened) => { + slot.store = opened.store + return opened + }, + (error: unknown) => { + // A failed open must not poison the slot: the next caller retries. + if (opening === slot) { + opening = null + } + throw error } - throw error - }) + ) } opening = slot return slot.opened } +/** The store when it is already open; null while it opens or before anything asked for it. */ +export function peekOpenedAgentSessionRecordStore(): AgentSessionRecordStore | null { + return opening?.store ?? null +} + async function openRecordStore( location: AgentSessionRecordStoreLocation ): Promise { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index eed772bcf302..03ae545b8b8d 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -300,7 +300,7 @@ export class AgentSessionRecordStore { /** Admission and, when `claimAfter` allows, the claim: one durable write before the effect. */ admitAndClaimOperation = ( - args: AgentSessionOperationAdmission, + args: Parameters[1], claimAfter: ClaimAfterAdmission ) => this.transact((draft) => admitAndClaimAgentSessionOperationInto(draft, args, claimAfter)) diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 35f9e7991a5f..59c220539d86 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -25,6 +25,7 @@ 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 class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -190,6 +191,8 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu worktreeId: string, target: TerminalPaneCloseTarget ): Promise { + // An explicit close (a phone's, the CLI's): a launch still starting or delivering there stops. + markAgentLaunchesClosedByUser(worktreeId, target) try { await this.closeTerminalSurface(worktreeId, target, { allowMissing: true }) } catch (error) { diff --git a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts index 8101145b2c2c..3218ddfbf312 100644 --- a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs } from './orca-runtime-build-headless-mobile-session-browser-tabs' import type { PtyControllerInventory } from './runtime-pty-controller-contract' +import { isAgentLaunchRunningIn } from '../agent-launch/agent-launch-pane-attachment' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TabActivationIntent } from '../../shared/tab-activation-intent' @@ -145,6 +146,12 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu // (#11598), so only a background probe may be refused for one. (!isAutomaticTabActivation(opts.intent) || !this.isDeliberatelyParkedPane(worktreeId, tab)) && + // Why: a launch's early tab is listed before its agent spawns; that launch's spawn fills it. + !isAgentLaunchRunningIn(worktreeId, { + kind: 'pane', + tabId: tab.parentTabId, + leafId: tab.leafId + }) && (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) diff --git a/src/main/runtime/orca-runtime-terminal-close-agent-launch.test.ts b/src/main/runtime/orca-runtime-terminal-close-agent-launch.test.ts new file mode 100644 index 000000000000..fa7f0a94743b --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-close-agent-launch.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { + resetAgentLaunchPanesForTests, + trackRunningAgentLaunchPane +} from '../agent-launch/agent-launch-pane-attachment' +import { + LEAF_ID, + PTY_ID, + SIBLING_LEAF_ID, + TAB_ID, + WORKTREE_ID, + createHarness +} from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' + +afterEach(() => { + resetAgentLaunchPanesForTests() +}) + +function launchIn(leafId: string) { + return trackRunningAgentLaunchPane({ + worktreeId: WORKTREE_ID, + paneKey: makePaneKey(TAB_ID, leafId) + }) +} + +describe("a pane close main starts (a phone's, the CLI's)", () => { + it('stops a launch still running in that pane, and only that one', async () => { + const harness = createHarness({ publishMobileSurface: true }) + harness.syncSplitFixtureGraph() + const closed = launchIn(LEAF_ID) + const sibling = launchIn(SIBLING_LEAF_ID) + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + ) + if (!terminal) { + throw new Error('fixture pane has no terminal handle') + } + + await harness.runtime.closeTerminal(terminal.handle) + + expect(harness.closeTerminalPane).toHaveBeenCalledWith(TAB_ID, LEAF_ID) + expect(closed.closedByUser()).toBe(true) + expect(sibling.closedByUser()).toBe(false) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts index 31a50724203d..b20afa3296e0 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-07.spec.ts @@ -16,6 +16,8 @@ import { store } from '../orca-runtime-test-fixtures.spec' import { makePendingAgentTabActivationRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { trackRunningAgentLaunchPane } from '../../agent-launch/agent-launch-pane-attachment' +import { makePaneKey } from '../../../shared/stable-pane-id' describe('OrcaRuntimeService', () => { it('briefly preserves abnormal SSH exits for paired pane recovery', async () => { @@ -624,4 +626,23 @@ describe('OrcaRuntimeService', () => { status: 'ready' }) }) + // The launch's early tab is listed before its agent spawns; a phone landing on it must not race it. + it('leaves a pending agent tab to the launch still starting in it', async () => { + const { runtime, spawn } = makePendingAgentTabActivationRuntime() + const running = trackRunningAgentLaunchPane({ + worktreeId: TEST_WORKTREE_ID, + paneKey: makePaneKey('host-tab', HEADLESS_LEAF_ID) + }) + try { + await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_LEAF_ID}`, + undefined, + { notifyClients: false } + ) + expect(spawn).not.toHaveBeenCalled() + } finally { + running.finish({ tabTakenBack: false }) + } + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 287285c8ea70..c4078cb29486 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,7 +1,18 @@ import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' +import type { + AgentLaunchTabPublished, + AgentLaunchTabPublishRequest +} from '../../shared/agent-launch-tab-publication' +import type { + AgentLaunchPaneAddress, + AgentLaunchPaneVerdict +} from '../../shared/agent-launch-pane-verdict' import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invalidators' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' +import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { @@ -13,6 +24,56 @@ class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter { registerDetectedWorktreeScanInvalidation() registerWorktreeChangeInvalidator((repoId) => this.invalidateWorktreeCatalog(repoId)) } + + /** Whether a window owns the layout and can show a launch's tab ahead of its process. */ + canPublishAgentLaunchTab(): boolean { + return Boolean(this.notifier?.publishAgentLaunchTab && this.getAvailableAuthoritativeWindow()) + } + + /** Shows an agent launch's tab before its process exists, in the window that owns the layout; + * null when no window does, and the launch's tab then appears when it spawns, as before. */ + publishAgentLaunchTab( + request: Omit + ): Promise | null { + if (!this.notifier?.publishAgentLaunchTab || !this.getAvailableAuthoritativeWindow()) { + return null + } + return this.notifier.publishAgentLaunchTab(request) + } + + /** Tells the window a launch pane's fate: it keeps a final one on the tab, clears a settled one, + * and takes a withdrawn pane back (the pane alone when the user split the tab). */ + reportAgentLaunchPaneVerdict( + pane: AgentLaunchPaneAddress, + verdict: AgentLaunchPaneVerdict + ): void { + this.notifier?.agentLaunchPaneVerdict?.({ ...pane, verdict }) + } + + private readonly agentLaunchRecordWarmup = createAgentLaunchRecordWarmupGate({ + isOpen: () => peekOpenedAgentSessionRecordStore() !== null, + open: () => this.openAgentSessionRecordStore() + }) + + /** Startup is done; the launch record may open once a client that can launch is here too. */ + noteAgentLaunchStartupSettled(): void { + this.agentLaunchRecordWarmup.startupSettled() + } + + /** A client that can call `agent.launch` connected; its first launch should not open the record. */ + noteAgentLaunchClientReady(): void { + this.agentLaunchRecordWarmup.launchClientReady() + } + + /** Whether a running process holds this pane now: such a pane is attached to, never launched into. */ + hasLiveTerminalForPaneKey(paneKey: string): boolean { + return this.getPtyRecordForPaneKey(paneKey)?.connected === true + } + + /** The launch record when it is already open, for a reader that must not wait for it. */ + openedAgentSessionRecordStore(): AgentSessionRecordStore | null { + return peekOpenedAgentSessionRecordStore() + } } type OrcaRuntimeServiceExport = RuntimeCommandSurfaceHost const OrcaRuntimeServiceExport = OrcaRuntimeService as unknown as { diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 6d122c02d453..6be04e9feba9 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -33,6 +33,7 @@ import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker import { WORKTREE_CREATE_COLLISION_CODE } from '../../../shared/new-workspace/worktree-create-collision' import { AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE } from '../../../shared/agent-launch-pane-already-live' import { AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE } from '../../../shared/agent-launch-session-already-exists' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { return { @@ -66,6 +67,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ WORKTREE_CREATE_COLLISION_CODE, AGENT_LAUNCH_PANE_ALREADY_LIVE_CODE, AGENT_LAUNCH_SESSION_ALREADY_EXISTS_CODE, + AGENT_LAUNCH_TAB_CLOSED_CODE, 'agent_launch_replay_unsupported', 'runtime_unavailable', 'selector_not_found', diff --git a/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts new file mode 100644 index 000000000000..be02a5acd338 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-execution-outcome.ts @@ -0,0 +1,66 @@ +/** + * How a launch's execution ends for its caller and its record: the error that carries whether it + * provably ran nothing, the bookkeeping that never fails a launch, and the answer for a launch whose + * tab the user closed while it started. + */ + +import { + AGENT_LAUNCH_TAB_CLOSED_CODE, + AgentLaunchTabClosedError +} from '../../../../shared/agent-launch-tab-closed' +import type { RpcContext } from '../core' +import { readsAgentLaunchTabClosed } from './agent-launch-replay' +import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' + +export class AgentLaunchExecutionError extends Error { + constructor( + cause: unknown, + /** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */ + readonly failedWithoutEffects: boolean + ) { + super('agent_session_operation_unknown', { cause }) + } +} + +export function settleQuietly(settlement: Promise): Promise { + return settlement.catch((error: unknown) => { + console.warn('[agent-launch] the launch settled, its operation row did not', error) + }) +} + +/** Runs a launch whose tab may already be showing. Its pane reads how the launch ended off the + * launch record, which every path below settles before this returns. */ +export async function withEarlyTab( + early: EarlyAgentLaunchTab | null, + run: () => Promise +): Promise { + try { + return await run() + } finally { + early?.finish() + } +} + +/** What a caller hears for a launch whose tab the user closed: the definite answer when it reads + * that word, the uncertain one it always got otherwise. */ +export function agentLaunchTabClosedAnswer(context: RpcContext): Error { + return readsAgentLaunchTabClosed(context) + ? Object.assign(new Error(AGENT_LAUNCH_TAB_CLOSED_CODE), { code: AGENT_LAUNCH_TAB_CLOSED_CODE }) + : new Error('agent_session_operation_unknown') +} + +/** A launch whose tab the user closed while it started ends as exactly that: its agent, if one + * spawned, is stopped, and the record answers `agent_launch_tab_closed` to this caller and every + * retry. Throws that answer. */ +export async function settleLaunchWhoseTabWasClosed( + context: RpcContext, + early: EarlyAgentLaunchTab, + admission: { fail: (code: string) => Promise } +): Promise { + const handle = context.runtime.getTerminalHandleForPaneKey(early.paneKey) + if (handle) { + await context.runtime.closeTerminal(handle).catch(() => {}) + } + await settleQuietly(admission.fail(AGENT_LAUNCH_TAB_CLOSED_CODE)) + throw new AgentLaunchExecutionError(new AgentLaunchTabClosedError(), true) +} diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 1df2c1f7433a..917ee079450d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -64,7 +64,12 @@ describe('agent.launch with the real floating workspace resolver', () => { expect(structuredHost).not.toHaveBeenCalled() expect(createTerminal).toHaveBeenCalledExactlyOnceWith( `id:${FLOATING_TERMINAL_WORKTREE_ID}`, - { startupAgent: 'claude', onPtySpawnDispatched: expect.any(Function) } + { + startupAgent: 'claude', + // Derived on the host by the window's rule, from the same chat-view setting. + viewMode: structuredPreference ? 'chat' : 'terminal', + onPtySpawnDispatched: expect.any(Function) + } ) expect(result).toMatchObject({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts new file mode 100644 index 000000000000..7d75ebcc56b7 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -0,0 +1,780 @@ +/** + * The tab of an `agent.launch` appears before the launch is admitted, at the requested place, and + * the spawn lands in that tab's pane. Driven through the method's handler with the shared runtime + * stub and the real durable ledger, so "before admission" is read off the order the host asked for + * the tab and opened the launch record. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY +} from '../../../../shared/agent-launch-runtime-capability' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../../shared/agent-launch-tab-closed' +import { classifyAgentLaunchReplayRefusal } from '../../../../shared/agent-launch-replay-refusal' +import { mapRuntimeError } from '../errors' +import type { AgentLaunchResult } from '../../../../shared/agent-launch-intent' +import type { + AgentLaunchTabPublished, + AgentLaunchTabPublishRequest +} from '../../../../shared/agent-launch-tab-publication' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { + markAgentLaunchesClosedByUser, + resetAgentLaunchPanesForTests, + resolveAgentLaunchPaneVerdict, + type AgentLaunchPaneEvidence +} from '../../../agent-launch/agent-launch-pane-attachment' +import type { AgentLaunchPaneVerdict } from '../../../../shared/agent-launch-pane-verdict' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import type { RpcContext } from '../core' +import { DESKTOP_RPC_CALLER } from '../rpc-caller-identity' +import { + STRUCTURED_PREFERENCE, + methodNamed, + rpcContext, + runtimeStub, + setAgentLaunchRecordStore, + type AgentLaunchRuntimeStubOptions +} from './agent-launch.test-fixture' + +const deliverTerminalAgentLaunchPrompt = vi.hoisted(() => vi.fn(async () => true)) +vi.mock('./agent-launch-terminal-prompt', () => ({ deliverTerminalAgentLaunchPrompt })) + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') +const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') + +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const TERMINAL_ONLY = {} +const LAUNCH = { agent: 'claude', target: { kind: 'existing', worktree: 'id:wt-7' } } +const CLI: Partial = {} +const DESKTOP: Partial = { + caller: DESKTOP_RPC_CALLER, + clientKind: 'runtime', + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] +} +const OLD_PHONE: Partial = { + clientKind: 'mobile', + pairedDeviceId: 'device-1', + clientCapabilities: [AGENT_LAUNCH_RUNTIME_CAPABILITY] +} +const PHONE: Partial = { + ...OLD_PHONE, + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY + ] +} + +const PHONE_READING_TAB_CLOSED: Partial = { + ...OLD_PHONE, + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY + ] +} + +let directory: string +let store: AgentSessionRecordStore +let operationCounter = 0 + +function nextOperationId(): string { + operationCounter += 1 + return `${Date.now()}-${operationCounter.toString(16).padStart(32, '0')}` +} + +beforeEach(async () => { + deliverTerminalAgentLaunchPrompt.mockClear() + directory = await mkdtemp(join(tmpdir(), 'agent-launch-instant-tab-')) + store = await openTestAgentSessionRecordStore(directory) + setAgentLaunchRecordStore(store) +}) + +afterEach(async () => { + setAgentLaunchRecordStore(null) + resetAgentLaunchPanesForTests() + await rm(directory, { recursive: true, force: true }) +}) + +/** A host with a window that shows the tab, recording what it was asked and in what order. */ +function hostWithWindow( + options: AgentLaunchRuntimeStubOptions & { + reply?: Partial | Error + } = {} +) { + const events: string[] = [] + const published: Omit[] = [] + const runtime = runtimeStub({ + settings: TERMINAL_ONLY, + ...options, + publishAgentLaunchTab: (request) => { + events.push('tab') + published.push(request) + return options.reply instanceof Error + ? Promise.reject(options.reply) + : Promise.resolve({ + tabId: request.tabId, + created: true, + placement: { groupId: 'group-1' }, + ...options.reply + }) + } + }) + const openStore = runtime.openAgentSessionRecordStore.getMockImplementation()! + runtime.openAgentSessionRecordStore.mockImplementation(async () => { + events.push('admission') + return openStore() + }) + const createTerminal = runtime.createTerminal.getMockImplementation()! + runtime.createTerminal.mockImplementation(async (selector, createOptions) => { + events.push('spawn') + return createTerminal(selector, createOptions) + }) + return Object.assign(runtime, { + events, + published, + selectCreatedMobileSessionTabForClient: vi.fn(() => true) + }) +} + +type Host = ReturnType + +function replayLaunch( + runtime: Host, + params: Record, + context: Partial +): Promise { + return AGENT_LAUNCH_REPLAY.handler( + AGENT_LAUNCH_REPLAY.params.parse({ ...LAUNCH, operationId: nextOperationId(), ...params }), + rpcContext(runtime, context) + ) +} + +function plainLaunch( + runtime: Host, + params: Record, + context: Partial +): Promise { + return AGENT_LAUNCH.handler( + AGENT_LAUNCH.params.parse({ ...LAUNCH, ...params }), + rpcContext(runtime, context) + ) +} + +function terminalOptions(runtime: Host): Record { + return runtime.createTerminal.mock.calls[0]?.[1] ?? {} +} + +/** What the pane reads when it mounts: this host's runtime, and the launch record on disk. */ +function paneEvidence( + runtime: Host, + record: AgentSessionRecordStore = store +): AgentLaunchPaneEvidence { + return { + isPaneLive: (paneKey) => runtime.hasLiveTerminalForPaneKey(paneKey), + openedRows: () => record.listOperationRows(), + launchPaneOnTab: () => ({}), + openRows: async () => record.listOperationRows(), + now: () => Date.now() + } +} + +async function paneVerdict( + runtime: Host, + record?: AgentSessionRecordStore, + paneKey = PANE_KEY +): Promise { + return ( + (await resolveAgentLaunchPaneVerdict( + { worktreeId: 'wt-7', paneKey }, + paneEvidence(runtime, record) + )) ?? 'unowned' + ) +} + +/** The record not open yet when the request arrives, as on the first launch after a restart. */ +function coldRecord(runtime: Host): Host { + // Only at the publish: admission opens it. + runtime.openedAgentSessionRecordStore.mockReturnValueOnce(null) + return runtime +} + +/** A spawn that fails before the daemon was asked for a process: nothing can be running. */ +function failBeforeDispatch(runtime: Host, message: string): void { + runtime.createTerminal.mockRejectedValueOnce(new Error(message)) +} + +/** A spawn the daemon was asked for, whose answer was lost: the process may exist. */ +function failAfterDispatch(runtime: Host, message: string): void { + runtime.createTerminal.mockImplementationOnce(async (_selector, createOptions) => { + const dispatched = createOptions?.onPtySpawnDispatched + if (typeof dispatched === 'function') { + dispatched() + } + throw new Error(message) + }) +} + +describe('the instant tab', () => { + it('is asked for before the launch is admitted, and the spawn lands in its pane', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + + await replayLaunch(runtime, { paneKey: PANE_KEY }, CLI) + + expect(runtime.events.indexOf('tab')).toBeLessThan(runtime.events.indexOf('admission')) + expect(runtime.published[0]).toMatchObject({ + worktreeId: 'wt-7', + tabId: TAB_ID, + leafId: LEAF_ID + }) + expect(terminalOptions(runtime)).toMatchObject({ tabId: TAB_ID, leafId: LEAF_ID }) + }) + + it('names a pane the host minted when the caller sent none, and spawns into that pane', async () => { + const runtime = hostWithWindow() + + await replayLaunch(runtime, {}, CLI) + + const request = runtime.published[0]! + expect(terminalOptions(runtime)).toMatchObject({ + tabId: request.tabId, + leafId: request.leafId, + requireFreshPane: true + }) + }) + + it('binds the spawn to the shown tab without moving anyone a second time', async () => { + const runtime = hostWithWindow() + + await replayLaunch(runtime, { presentation: 'focused' }, CLI) + + expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false }) + }) + + it('reveals the spawn as before when the window never said it showed the tab', async () => { + const runtime = hostWithWindow({ reply: new Error('renderer_unavailable') }) + + const result = await replayLaunch(runtime, {}, CLI) + + expect(result.outcome.kind).toBe('terminal') + expect(result.placement).toBeUndefined() + expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner') + }) + + it('records the pane it showed with the launch, and lets the pane attach once the agent runs', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + + await replayLaunch(runtime, { paneKey: PANE_KEY }, CLI) + + expect(store.listOperationRows()[0]?.ownedPane).toEqual({ + worktreeId: 'wt-7', + paneKey: PANE_KEY + }) + await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'proceed' }) + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + }) + + it('is not shown early for a chat-mode launch, whose tab is the session', async () => { + const runtime = hostWithWindow({ settings: STRUCTURED_PREFERENCE }) + + await replayLaunch(runtime, {}, CLI) + + expect(runtime.published).toEqual([]) + }) + + it('is not shown early to a phone that would read a listed tab as a started agent', async () => { + const runtime = hostWithWindow() + + await replayLaunch(runtime, { paneKey: PANE_KEY }, OLD_PHONE) + + expect(runtime.published).toEqual([]) + }) + + it('is not shown early without an operation id: no record could tell its pane how it ended', async () => { + const runtime = hostWithWindow() + + await plainLaunch(runtime, {}, CLI) + + expect(runtime.published).toEqual([]) + }) +}) + +describe('the pane while its launch delivers the prompt', () => { + function launchDeliveringSlowly(runtime: Host): { + launched: Promise + deliver: (delivered: boolean) => void + } { + let deliver!: (delivered: boolean) => void + deliverTerminalAgentLaunchPrompt.mockImplementationOnce( + () => new Promise((resolve) => (deliver = resolve)) + ) + const launched = replayLaunch( + runtime, + { paneKey: PANE_KEY, prompt: { text: 'fix the build', delivery: 'submit' } }, + CLI + ) + return { launched, deliver: (delivered) => deliver(delivered) } + } + + it('attaches to the agent as soon as it exists, not when the prompt is delivered', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY, lineCarriesPrompt: false }) + const { launched, deliver } = launchDeliveringSlowly(runtime) + await vi.waitFor(() => expect(deliverTerminalAgentLaunchPrompt).toHaveBeenCalled()) + + const verdict = await Promise.race([ + paneVerdict(runtime), + new Promise<'still waiting'>((resolve) => setTimeout(() => resolve('still waiting'), 200)) + ]) + expect(verdict).toEqual({ kind: 'proceed' }) + + deliver(true) + await expect(launched).resolves.toMatchObject({ outcome: { kind: 'terminal' } }) + // The pane's own spawn settles its tab; the launch reports nothing for a pane it ran in. + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + }) + + it('still stops the launch as closed when its attached tab is closed before the prompt lands', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY, lineCarriesPrompt: false }) + const { launched, deliver } = launchDeliveringSlowly(runtime) + await vi.waitFor(() => expect(deliverTerminalAgentLaunchPrompt).toHaveBeenCalled()) + await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'proceed' }) + + // Main's commit of the user's tab close, as the window sends it. + markAgentLaunchesClosedByUser('wt-7', { kind: 'tab', tabId: TAB_ID }) + deliver(true) + + await expect(launched).rejects.toMatchObject({ code: AGENT_LAUNCH_TAB_CLOSED_CODE }) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_1') + }) +}) + +describe('the pane after its launch', () => { + it('says a failed spawn did not start, keeps the tab, and says it again after a restart', async () => { + const runtime = hostWithWindow() + failBeforeDispatch(runtime, 'spawn claude ENOENT') + + await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toThrow('ENOENT') + + await expect(paneVerdict(runtime)).resolves.toEqual({ + kind: 'not-started', + code: 'spawn claude ENOENT' + }) + // Kept, and told: the tab records the outcome even if its pane never mounts. + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalledWith(expect.anything(), { + kind: 'withdrawn' + }) + await vi.waitFor(() => + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { kind: 'not-started', code: 'spawn claude ENOENT' } + ) + ) + // A restarted host: no launch in memory, the record read back from disk. + resetAgentLaunchPanesForTests() + const reopened = await openTestAgentSessionRecordStore(directory) + await expect(paneVerdict(hostWithWindow(), reopened)).resolves.toMatchObject({ + kind: 'not-started' + }) + }) + + it('says it cannot confirm a spawn whose outcome is unknown, and attaches if the agent turns up', async () => { + const runtime = hostWithWindow() + failAfterDispatch(runtime, 'daemon create timed out') + + await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toThrow( + 'agent_session_operation_unknown' + ) + + await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'unconfirmed' }) + runtime.hasLiveTerminalForPaneKey.mockReturnValue(true) + await expect(paneVerdict(runtime)).resolves.toEqual({ kind: 'proceed' }) + }) + + it("never touches a running agent's pane when a second launch names it", async () => { + const first = hostWithWindow({ terminalPaneKey: PANE_KEY }) + await replayLaunch(first, { paneKey: PANE_KEY }, CLI) + + const second = hostWithWindow({ + adoptedPanes: { [PANE_KEY]: 'term_1' }, + terminalPaneAlreadyLive: true + }) + await expect(replayLaunch(second, { paneKey: PANE_KEY }, CLI)).rejects.toThrow( + 'agent_launch_pane_already_live' + ) + + expect(second.published).toEqual([]) + expect(second.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + // Its agent exits and the pane remounts: still the first launch's pane, never "couldn't start". + second.hasLiveTerminalForPaneKey.mockReturnValue(false) + await expect(paneVerdict(second)).resolves.toEqual({ kind: 'proceed' }) + }) + + it('takes back a tab nothing ran into without letting its waiting pane start a shell', async () => { + const operationId = nextOperationId() + await replayLaunch(hostWithWindow({ terminalPaneKey: PANE_KEY }), { operationId }, CLI) + + const verdicts: Promise[] = [] + const refused = coldRecord(hostWithWindow()) + const publish = refused.publishAgentLaunchTab.getMockImplementation()! + refused.publishAgentLaunchTab.mockImplementation((request) => { + const published = publish(request) + // The pane mounts as soon as the tab exists. + verdicts.push(paneVerdict(refused, store, `${request.tabId}:${request.leafId}`)) + return published + }) + // The same id with other params: a conflict, so admission refuses and nothing runs. + await expect( + replayLaunch( + refused, + { operationId, paneKey: PANE_KEY, prompt: { text: 'other', delivery: 'submit' } }, + CLI + ) + ).rejects.toThrow('agent_session_operation_conflict') + + await expect(verdicts[0]).resolves.toEqual({ kind: 'withdrawn' }) + await vi.waitFor(() => + expect(refused.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-7' }), + { kind: 'withdrawn' } + ) + ) + }) + + it('ends every shown pane nothing spawned into in a verdict, never a blank pane', async () => { + // A tab the window already had (created: false) whose launch failed before its spawn. + const runtime = hostWithWindow({ reply: { created: false } }) + failBeforeDispatch(runtime, 'spawn claude ENOENT') + + await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toThrow('ENOENT') + + await vi.waitFor(() => + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { kind: 'not-started', code: 'spawn claude ENOENT' } + ) + ) + }) +}) + +describe('retries', () => { + it('a retry of a recorded launch shows no tab: no flash, no reset, no one moved', async () => { + const operationId = nextOperationId() + await replayLaunch( + hostWithWindow({ terminalPaneKey: PANE_KEY }), + { operationId, paneKey: PANE_KEY }, + CLI + ) + + for (const replayed of [ + hostWithWindow({ adoptedPanes: { [PANE_KEY]: 'term_1' } }), + hostWithWindow({ reply: { created: false } }), + hostWithWindow() + ]) { + await replayLaunch(replayed, { paneKey: PANE_KEY, operationId }, CLI) + expect(replayed.published).toEqual([]) + expect(replayed.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + expect(replayed.createTerminal).not.toHaveBeenCalled() + } + }) + + it('a refusal that brings a fresh pane flashes nothing when the record already holds its id', async () => { + const operationId = nextOperationId() + await replayLaunch(hostWithWindow({ terminalPaneKey: PANE_KEY }), { operationId }, CLI) + + const refused = hostWithWindow() + await expect( + replayLaunch( + refused, + { operationId, paneKey: PANE_KEY, prompt: { text: 'other', delivery: 'submit' } }, + CLI + ) + ).rejects.toThrow('agent_session_operation_conflict') + expect(refused.published).toEqual([]) + }) + + it('with the record not yet open: a tab still shown keeps what its launch came to', async () => { + const operationId = nextOperationId() + const first = hostWithWindow() + failAfterDispatch(first, 'daemon create timed out') + await expect(replayLaunch(first, { paneKey: PANE_KEY, operationId }, CLI)).rejects.toThrow( + 'agent_session_operation_unknown' + ) + + // After a restart: the retry publishes onto the restored tab, which the window keeps as is. + const retried = coldRecord(hostWithWindow({ reply: { created: false } })) + await expect(replayLaunch(retried, { paneKey: PANE_KEY, operationId }, CLI)).rejects.toThrow( + 'agent_session_operation_unknown' + ) + expect(retried.published[0]?.operationId).toBe(operationId) + await vi.waitFor(() => + expect(retried.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { kind: 'unconfirmed' } + ) + ) + }) + + it('with the record not yet open: a tab remade for nothing goes, one an agent survived in stays', async () => { + const operationId = nextOperationId() + await replayLaunch( + hostWithWindow({ terminalPaneKey: PANE_KEY }), + { paneKey: PANE_KEY, operationId }, + CLI + ) + + const remadeForNothing = coldRecord(hostWithWindow()) + await replayLaunch(remadeForNothing, { paneKey: PANE_KEY, operationId }, CLI) + await vi.waitFor(() => + expect(remadeForNothing.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { kind: 'withdrawn' } + ) + ) + + const survived = coldRecord(hostWithWindow()) + // The agent's pane comes back while the replay runs (the daemon kept it across a crash). + survived.publishAgentLaunchTab.mockImplementationOnce(async (request) => { + survived.hasLiveTerminalForPaneKey.mockReturnValue(true) + return { tabId: request.tabId, created: true, placement: { groupId: 'group-1' } } + }) + await replayLaunch(survived, { paneKey: PANE_KEY, operationId }, CLI) + await vi.waitFor(() => + expect(survived.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith( + { worktreeId: 'wt-7', tabId: TAB_ID, leafId: LEAF_ID }, + { kind: 'proceed' } + ) + ) + expect(survived.reportAgentLaunchPaneVerdict).not.toHaveBeenCalledWith(expect.anything(), { + kind: 'withdrawn' + }) + }) +}) + +describe('the user closing the tab while it starts', () => { + function closeTabWhenShown(runtime: Host): void { + const publish = runtime.publishAgentLaunchTab.getMockImplementation()! + runtime.publishAgentLaunchTab.mockImplementation((request) => { + const published = publish(request) + markAgentLaunchesClosedByUser('wt-7', { kind: 'tab', tabId: request.tabId }) + return published + }) + } + + it('stops the launch before its agent spawns, and that is its answer, now and on every retry', async () => { + const operationId = nextOperationId() + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + closeTabWhenShown(runtime) + + await expect( + replayLaunch(runtime, { paneKey: PANE_KEY, operationId }, CLI) + ).rejects.toMatchObject({ + code: AGENT_LAUNCH_TAB_CLOSED_CODE + }) + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(store.listOperationRows()[0]?.outcome).toEqual({ + status: 'failed', + code: AGENT_LAUNCH_TAB_CLOSED_CODE + }) + + const retried = hostWithWindow() + await expect(replayLaunch(retried, { paneKey: PANE_KEY, operationId }, CLI)).rejects.toThrow( + AGENT_LAUNCH_TAB_CLOSED_CODE + ) + expect(retried.published).toEqual([]) + }) + + it('stops an agent whose tab was closed while its terminal was created, before its prompt is pasted', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY, lineCarriesPrompt: false }) + const spawn = runtime.createTerminal.getMockImplementation()! + runtime.createTerminal.mockImplementationOnce(async (selector, createOptions) => { + markAgentLaunchesClosedByUser('wt-7', { kind: 'tab', tabId: TAB_ID }) + return spawn(selector, createOptions) + }) + + await expect( + replayLaunch( + runtime, + { paneKey: PANE_KEY, prompt: { text: 'fix the build', delivery: 'submit' } }, + CLI + ) + ).rejects.toMatchObject({ code: AGENT_LAUNCH_TAB_CLOSED_CODE }) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_1') + expect(deliverTerminalAgentLaunchPrompt).not.toHaveBeenCalled() + }) + + it('stops an agent that already spawned', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + const spawn = runtime.createTerminal.getMockImplementation()! + runtime.createTerminal.mockImplementationOnce(async (selector, createOptions) => { + const created = await spawn(selector, createOptions) + markAgentLaunchesClosedByUser('wt-7', { kind: 'tab', tabId: TAB_ID }) + return created + }) + + await expect(replayLaunch(runtime, { paneKey: PANE_KEY }, CLI)).rejects.toMatchObject({ + code: AGENT_LAUNCH_TAB_CLOSED_CODE + }) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_1') + }) + + it('reaches a caller that reads the word as its own code on the wire, which the phone reads as failed', async () => { + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + closeTabWhenShown(runtime) + + const thrown: unknown = await replayLaunch( + runtime, + { paneKey: PANE_KEY }, + PHONE_READING_TAB_CLOSED + ).catch((error: unknown) => error) + const failure = mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, thrown) + + expect(failure.error).toMatchObject({ code: AGENT_LAUNCH_TAB_CLOSED_CODE }) + // What the phone's launch switches on: a definite failure, worded by its own copy for this code. + expect(classifyAgentLaunchReplayRefusal(failure.error, false)).toBe('failed') + }) + + it('tells a caller that cannot read the word what it always heard', async () => { + const operationId = nextOperationId() + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + closeTabWhenShown(runtime) + + await expect(replayLaunch(runtime, { paneKey: PANE_KEY, operationId }, PHONE)).rejects.toThrow( + 'agent_session_operation_unknown' + ) + await expect( + replayLaunch(hostWithWindow(), { paneKey: PANE_KEY, operationId }, PHONE) + ).rejects.toThrow('agent_session_operation_unknown') + await expect( + replayLaunch(hostWithWindow(), { paneKey: PANE_KEY, operationId }, PHONE_READING_TAB_CLOSED) + ).rejects.toThrow(AGENT_LAUNCH_TAB_CLOSED_CODE) + }) +}) + +describe('placement', () => { + it('passes the requested place to the window and reports where the tab landed', async () => { + const runtime = hostWithWindow({ + reply: { placement: { groupId: 'group-anchor', fallback: 'anchor-group' } } + }) + + const result = await replayLaunch( + runtime, + { placement: { groupId: 'group-closed', afterTabId: 'tab-anchor' } }, + CLI + ) + + expect(runtime.published[0]?.placement).toEqual({ + groupId: 'group-closed', + afterTabId: 'tab-anchor' + }) + expect(result.placement).toEqual({ groupId: 'group-anchor', fallback: 'anchor-group' }) + }) + + it("hands a CLI's focused launch to the window with its group, to land there as the window is moved", async () => { + const runtime = hostWithWindow() + + await replayLaunch( + runtime, + { placement: { groupId: 'group-split' }, presentation: 'focused' }, + CLI + ) + + expect(runtime.published[0]).toMatchObject({ + placement: { groupId: 'group-split' }, + viewer: 'focus-window' + }) + }) + + it('is not part of what a retry must repeat', async () => { + const operationId = nextOperationId() + const runtime = hostWithWindow({ terminalPaneKey: PANE_KEY }) + await replayLaunch( + runtime, + { paneKey: PANE_KEY, operationId, placement: { groupId: 'g-1' } }, + CLI + ) + + await expect( + replayLaunch( + hostWithWindow({ reply: { created: false } }), + { paneKey: PANE_KEY, operationId, placement: { groupId: 'g-2' } }, + CLI + ) + ).resolves.toMatchObject({ outcome: { kind: 'terminal' } }) + }) +}) + +describe('whose view moves', () => { + it.each([ + ['a local caller asking for focus moves the window', CLI, 'focused', 'focus-window'], + ['a local caller by default reveals the workspace as before', CLI, undefined, 'reveal-owner'], + [ + 'the desktop stays in its workspace if you moved on', + DESKTOP, + undefined, + 'focus-in-workspace' + ], + ['a phone reveals the workspace on the desktop, as before', PHONE, 'focused', 'reveal-owner'], + ['a phone by default reveals it too', PHONE, undefined, 'reveal-owner'], + ['background moves nobody', CLI, 'background', 'none'] + ] as const)('%s', async (_name, context, presentation, viewer) => { + const runtime = hostWithWindow() + + await replayLaunch(runtime, presentation ? { presentation } : {}, context) + + expect(runtime.published[0]?.viewer).toBe(viewer) + }) + + it("moves the phone's own selection unless it asked for the background", async () => { + const focused = hostWithWindow({ terminalPaneKey: PANE_KEY }) + await replayLaunch(focused, { paneKey: PANE_KEY }, PHONE) + expect(focused.selectCreatedMobileSessionTabForClient).toHaveBeenCalledOnce() + + const background = hostWithWindow({ terminalPaneKey: PANE_KEY }) + await replayLaunch(background, { paneKey: PANE_KEY, presentation: 'background' }, PHONE) + expect(background.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() + }) +}) + +describe('the view the tab opens in', () => { + const CHAT_VIEW = { experimentalNativeChat: true, openAgentTabsInChatByDefault: true } + + it('is derived on the host, the same for the shown tab and the spawn', async () => { + const runtime = hostWithWindow({ settings: CHAT_VIEW }) + + await replayLaunch(runtime, {}, CLI) + + expect(runtime.published[0]?.viewMode).toBe('chat') + expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'chat' }) + }) + + it('stays the terminal for a draft the chat view cannot mirror', async () => { + const runtime = hostWithWindow({ settings: CHAT_VIEW }) + + await replayLaunch(runtime, { prompt: { text: ' ', delivery: 'draft' } }, CLI) + + expect(runtime.published[0]?.viewMode).toBe('terminal') + expect(terminalOptions(runtime)).toMatchObject({ viewMode: 'terminal' }) + }) +}) + +it('hands the window the prompt, so a pane whose agent did not start can offer to copy it', async () => { + const runtime = hostWithWindow() + await replayLaunch(runtime, { prompt: { text: 'fix the build', delivery: 'submit' } }, CLI) + expect(runtime.published[0]?.prompt).toBe('fix the build') +}) + +it('parses the minted pane key the window was given', async () => { + const runtime = hostWithWindow() + await replayLaunch(runtime, {}, CLI) + const request = runtime.published[0]! + expect(parsePaneKey(`${request.tabId}:${request.leafId}`)).not.toBeNull() +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts new file mode 100644 index 000000000000..b172a1b62b8f --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts @@ -0,0 +1,96 @@ +/** + * Turning `agent.launch` params into the intent the executor runs: the caller's workspace selector + * resolved to an id, and a reused terminal checked. Nothing here creates anything. + */ + +import type { AgentLaunchIntent, AgentLaunchTarget } from '../../../../shared/agent-launch-intent' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan' +import type { AgentLaunchParams } from './agent-launch-schemas' +import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' + +/** + * A client addresses a workspace by selector, but the result's `worktreeId` is an id and every + * step below the executor re-prefixes it as `id:`. Resolving here is what keeps a + * caller's `id:wt-7` from reaching the runtime as `id:id:wt-7`. + * + * The launch *scope* is what is asked for, because the id below is the only thing read off it. The + * git-worktree record is the narrower answer — it does not exist for the floating workspace, so + * asking for one refused a launch this method can perfectly well run, on a workspace whose id it + * had already resolved. A folder workspace survived that only because the resolver fabricates a + * worktree row for it; the scope is the answer that is real for all three kinds. + */ +async function agentLaunchTarget( + params: AgentLaunchParams, + runtime: Pick +): Promise { + if (params.target.kind === 'create-worktree') { + return { kind: 'create-worktree', create: { ...params.target.create } } + } + const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) + return { + kind: 'existing', + worktree: workspace.id, + workspacePath: workspace.path, + connectionId: workspace.connectionId + } +} + +async function agentLaunchIntent( + params: AgentLaunchParams, + runtime: OrcaRuntimeService, + /** The pane of a tab already shown for this launch; the spawn must land in it. */ + publishedPaneKey: string | undefined +): Promise { + const paneKey = publishedPaneKey ?? params.paneKey + return { + agent: params.agent, + target: await agentLaunchTarget(params, runtime), + ...(params.prompt ? { prompt: params.prompt } : {}), + ...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}), + ...(params.reuseTerminal ? { reuseTerminal: params.reuseTerminal } : {}), + // `null` means "no arguments" and must survive; only absence falls back to the settings default. + ...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.launchSource ? { launchSource: params.launchSource } : {}), + ...(paneKey ? { paneKey } : {}), + ...(params.sessionId ? { sessionId: params.sessionId } : {}) + } +} + +async function validateReusedTerminal( + intent: AgentLaunchIntent, + runtime: Pick +): Promise { + if (!intent.reuseTerminal) { + return + } + if (intent.target.kind !== 'existing') { + throw new Error('agent_launch_reuse_requires_existing_workspace') + } + const terminal = await runtime.showTerminal(intent.reuseTerminal.handle) + if (terminal.worktreeId !== intent.target.worktree) { + throw new Error('agent_launch_terminal_worktree_mismatch') + } + if (!(await runtime.isTerminalRunningAgent(intent.reuseTerminal.handle))) { + throw new Error('agent_launch_terminal_not_running_agent') + } +} + +/** + * The half before anything is created: resolve the caller's selector, then check a reused terminal. + * A throw from here proves no surface was built, which is what lets the ledger record a launch that + * failed in it as `failed` rather than `unknown`. + */ +export async function resolveUnlaunchedIntent( + params: AgentLaunchParams, + runtime: OrcaRuntimeService, + early: EarlyAgentLaunchTab | null +): Promise { + if (params.reuseTerminal || params.target.kind === 'create-worktree') { + assertOpenCodeModelLaunchPreferencesAbsent(params.agent, params.sessionOptions) + } + const intent = await agentLaunchIntent(params, runtime, early?.paneKey) + await validateReusedTerminal(intent, runtime) + return intent +} diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.ts b/src/main/runtime/rpc/methods/agent-launch-replay.ts index ea8d79fad5f2..a64d0a0cd90a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.ts @@ -16,10 +16,15 @@ */ import { deriveAgentLaunchChildOperationId } from '../../../../shared/agent-launch-operation' -import { AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY +} from '../../../../shared/agent-launch-runtime-capability' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../../shared/agent-launch-tab-closed' import { isAgentLaunchResult, type AgentLaunchResult } from '../../../../shared/agent-launch-intent' import type { AgentSessionOperationOutcome, + AgentSessionOperationOwnedPane, AgentSessionOperationRefusalCode } from '../../../../shared/agent-session-operation-ledger' import { resolveAgentSessionReplayOutcome } from '../../../native-chat/agent-session-wire/structured-agent-session-replay-outcome' @@ -101,6 +106,16 @@ function answerFromRecordedRow( : { decision: 'refuse', refusal: replay.refusal } } +/** A launch whose tab the user closed answers with its own word only to a caller that reads it. */ +export function readsAgentLaunchTabClosed( + context: Pick +): boolean { + return ( + context.clientKind === undefined || + context.clientCapabilities?.includes(AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY) === true + ) +} + /** The CLI (no declared client) ships with this host; any other caller must say it reads the word. */ function readsUnconfirmedLaunchPrompt( context: Pick @@ -144,6 +159,17 @@ function presentRecordedAnswer( operationId: string, answer: AgentLaunchAdmission ): AgentLaunchAdmission { + if ( + answer.decision === 'refuse' && + answer.refusal.code === AGENT_LAUNCH_TAB_CLOSED_CODE && + !readsAgentLaunchTabClosed(context) + ) { + return refusal( + operationId, + 'agent_session_operation_unknown', + 'was stopped; its tab was closed' + ) + } if (answer.decision !== 'replay') { return answer } @@ -169,7 +195,10 @@ export async function admitAgentLaunchOperation( context: RpcContext, params: AgentLaunchParams & { operationId: string }, fingerprint: string, - now: number = Date.now() + now: number = Date.now(), + // The pane the window already shows for this launch: the record names it so the pane can read + // how the launch ended, across a restart too. Written only if this request wins the claim. + ownedPane?: AgentSessionOperationOwnedPane ): Promise { const operationId = params.operationId const attachOperationId = deriveAgentLaunchChildOperationId(operationId) @@ -180,7 +209,7 @@ export async function admitAgentLaunchOperation( // The ledger alone: admitting a terminal launch has no use for the chat host. const store = await context.runtime.openAgentSessionRecordStore() const { decision: admitted, claim } = await store.admitAndClaimOperation( - { callerKey, operationId, fingerprint, now }, + { callerKey, operationId, fingerprint, now, ...(ownedPane ? { ownedPane } : {}) }, // A fresh row, or a replayed one no one has answered yet, leaves the right to run open. (decision) => decision.decision === 'admit' || diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 7be7fe8034b9..d25fa7acd28d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -36,6 +36,8 @@ import { trackTerminalSpawnDispatch, type TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' +import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' +import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( @@ -44,7 +46,10 @@ export function agentLaunchSurfaceFactory( operationCallerKey?: string, // True when the launch shows its surface to the paired caller itself rather than to everyone. callerPresentsSurface = false, - terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch() + terminalSpawn: TerminalSpawnDispatch = trackTerminalSpawnDispatch(), + // The tab shown before the launch ran; asked at spawn, since the window's answer and the user's + // close can both land after admission. + earlyTab: Pick | null = null ): AgentLaunchSurfaceFactory { return { createStructuredSession: async ({ @@ -118,10 +123,15 @@ export function agentLaunchSurfaceFactory( cwd, launchSource, paneKey, - options + options, + viewMode }) => { const launchPreferences = toAgentLaunchPreferences(options) let promptRodeLaunchCommand = false + if (earlyTab?.closedByUser()) { + // The user closed its tab while it waited: nothing is spawned, and that is the answer. + terminalSpawn.rethrow(new AgentLaunchTabClosedError()) + } const created = context.runtime.createTerminal(`id:${worktreeId}`, { // The agent id is not a shell command — `cursor` is the desktop app, its CLI is // `cursor-agent` — so the runtime builds the configured launcher. @@ -143,9 +153,17 @@ export function agentLaunchSurfaceFactory( // A live reserved pane would be attached, not launched into, so the runtime refuses it. ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...(launchSource ? { launchSource } : {}), + ...(viewMode ? { viewMode } : {}), + ...(earlyTab?.windowShowsTab() ? { surfaceOwner: false as const } : {}), onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) const terminal = await created.catch(terminalSpawn.rethrow) + if (earlyTab?.closedByUser()) { + // Closed while its terminal was being created: stopped before any prompt is pasted. A prompt + // that rode the command line reached the agent, which is stopped a moment after it starts. + await context.runtime.closeTerminal(terminal.handle).catch(() => {}) + throw new AgentLaunchTabClosedError() + } return { handle: terminal.handle, // The runtime already minted this pane and baked it into the PTY's env and its own reveal; diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts new file mode 100644 index 000000000000..c27e2edf8539 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -0,0 +1,299 @@ +/** + * The tab of an `agent.launch`, shown before the launch is admitted. + * + * Admission, workspace resolution, the mode decision and the daemon spawn each have a cold cost (a + * Windows window still booting its first PowerShell measured ~0.55 s and ~1 s), and the tab used to + * wait for all of them. It now waits for none: the host asks the window for the tab first, under ids + * the spawn will bake into the agent's PTY, and the pane attaches to that process when it exists. + * + * Only a launch with an operation id gets one: the launch record is what tells the pane, then and + * after a restart, which launch owns it and how it ended (`agent-launch-pane-attachment`). + * + * Best-effort by construction. Anything this cannot decide cheaply — a workspace it cannot resolve, + * a chat-mode default, no window owning the layout, a pane already running something — skips the + * early tab, and the launch's tab appears when it spawns, exactly as before. + */ + +import { randomUUID } from 'node:crypto' +import type { + AgentLaunchPlacementReceipt, + AgentLaunchResult +} from '../../../../shared/agent-launch-intent' +import { AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import type { + AgentLaunchTabPublished, + AgentLaunchTabViewerRule +} from '../../../../shared/agent-launch-tab-publication' +import { + listAgentSessionOperationRowsOwningPane, + type AgentSessionOperationOwnedPane +} from '../../../../shared/agent-session-operation-ledger' +import { + navigationTargetsHost, + resolveRuntimeNavigationTarget +} from '../../../../shared/runtime-navigation' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { workspaceKindForWorktreeId } from '../../../../shared/workspace-launch-kind' +import { + agentLaunchPaneVerdictFromRecord, + trackRunningAgentLaunchPane +} from '../../../agent-launch/agent-launch-pane-attachment' +import type { AgentLaunchPaneVerdict } from '../../../../shared/agent-launch-pane-verdict' +import { + decideAgentLaunchMode, + readAgentLaunchModeSettings +} from '../../../agent-launch/agent-launch-mode' +import { deriveAgentLaunchTerminalViewMode } from '../../../agent-launch/agent-launch-view-mode' +import type { RpcContext } from '../core' +import type { AgentLaunchParams } from './agent-launch-schemas' +import { agentLaunchOperationCallerKey } from './agent-launch-replay' + +/** + * Whose screen moves. `presentation` is `terminal.create`'s field with its meaning (focused: the + * host selects the tab; background: nothing moves). The desktop's own view is the host window, + * which follows only while it still shows that workspace. A caller whose navigation is elsewhere (a + * paired device) gets the window's reveal it always got, as well as its own selection. + */ +export function agentLaunchTabViewerRule( + context: Pick, + presentation: AgentLaunchParams['presentation'] +): AgentLaunchTabViewerRule { + if (presentation === 'background') { + return 'none' + } + if (context.caller?.kind === 'desktop') { + return 'focus-in-workspace' + } + if (!navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind }))) { + return 'reveal-owner' + } + return presentation === 'focused' ? 'focus-window' : 'reveal-owner' +} + +/** + * A paired caller that does not advertise the capability reads "my reserved tab is listed" as proof + * its agent started; a tab listed before the spawn would make a lost reply read as a started agent. + */ +function readsEarlyLaunchTab(context: Pick): boolean { + return ( + context.caller?.kind !== 'paired-device' || + context.clientCapabilities?.includes(AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY) === true + ) +} + +export type EarlyAgentLaunchTab = { + /** The pane the launch must create: the caller's, or one minted here. */ + paneKey: string + /** What the launch record names as this launch's pane. */ + ownedPane: AgentSessionOperationOwnedPane + /** Admission let this request run, so the record now names the pane and tells it how it ended. */ + executing(): void + /** The surface exists; an agent in this pane lets the pane's waiting spawn attach to it now. */ + surfacePublished(result: AgentLaunchResult): void + /** The window has said it shows the tab, so the spawn must bind it without a second reveal. */ + windowShowsTab(): boolean + /** The user closed the tab or pane: the launch must not start its agent, or must stop it. */ + closedByUser(): boolean + /** The launch is over, however it ended. A tab this request made goes when nothing will ever run + * in it: never admitted, or its surface landed elsewhere. A failed launch's tab stays to say why. */ + finish(): void + /** Where the window placed the tab, once it has said. */ + placement(): AgentLaunchPlacementReceipt | undefined +} + +/** The view half of a launch: the tab shown before it ran, and whether the caller's view moves. */ +export type AgentLaunchView = { + early: EarlyAgentLaunchTab | null + presentation: AgentLaunchParams['presentation'] +} + +export function withPlacement(result: AgentLaunchResult, view: AgentLaunchView): AgentLaunchResult { + const placement = view.early?.placement() + return placement ? { ...result, placement } : result +} + +/** The record already holds this operation: whatever it answers (a replay, a conflict, a recorded + * failure), nothing new runs, so no tab is shown for it and no one moves. */ +function isRecordedOperation(params: AgentLaunchParams, context: RpcContext): boolean { + if (!params.operationId || !context.caller) { + return false + } + const store = context.runtime.openedAgentSessionRecordStore() + return ( + store !== null && + store.getOperationRow(agentLaunchOperationCallerKey(context), params.operationId) !== null + ) +} + +/** Never throws: the early tab is a view, and a launch must not fail over one. */ +export function publishEarlyTab( + params: AgentLaunchParams, + context: RpcContext +): Promise { + if (isRecordedOperation(params, context)) { + return Promise.resolve(null) + } + return publishAgentLaunchTabEarly(params, context).catch((error: unknown) => { + console.warn('[agent-launch] could not show the launch tab early', error) + return null + }) +} + +export async function publishAgentLaunchTabEarly( + params: AgentLaunchParams, + context: RpcContext +): Promise { + const runtime = context.runtime + if ( + params.target.kind !== 'existing' || + params.reuseTerminal || + !readsEarlyLaunchTab(context) || + !runtime.canPublishAgentLaunchTab() || + // A running pane is attached to, never launched into; this launch is refused and must not + // touch the pane another launch's agent is running in. + (params.paneKey !== undefined && runtime.hasLiveTerminalForPaneKey(params.paneKey)) + ) { + return null + } + let workspace: Awaited> + try { + workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) + } catch { + // The launch resolves it again after admission and answers for it there. + return null + } + const settings = readAgentLaunchModeSettings(runtime) + const preflight = decideAgentLaunchMode({ + placement: { + agent: params.agent, + workspaceKind: workspaceKindForWorktreeId(workspace.id), + workspacePath: workspace.path, + ...(params.cwd ? { cwd: params.cwd } : {}) + }, + settings + }) + // A chat's tab is the session's; it appears when the session is created. + if (preflight.mode !== 'terminal') { + return null + } + const paneKey = params.paneKey ?? makePaneKey(randomUUID(), randomUUID()) + const pane = parsePaneKey(paneKey) + if (!pane) { + return null + } + const ownedPane = { worktreeId: workspace.id, paneKey } + // Before the window hears of the tab, so a pane that mounts at once already waits. + const running = trackRunningAgentLaunchPane(ownedPane) + let publishing: ReturnType + try { + publishing = runtime.publishAgentLaunchTab({ + worktreeId: workspace.id, + tabId: pane.tabId, + leafId: pane.leafId, + launchAgent: params.agent, + viewMode: deriveAgentLaunchTerminalViewMode({ + settings, + agent: params.agent, + ...(params.prompt ? { prompt: params.prompt } : {}), + connectionId: workspace.connectionId + }), + ...(params.placement ? { placement: params.placement } : {}), + viewer: agentLaunchTabViewerRule(context, params.presentation), + ...(params.prompt?.text ? { prompt: params.prompt.text } : {}), + ...(params.operationId ? { operationId: params.operationId } : {}) + }) + } catch (error) { + running.finish({ tabTakenBack: false }) + throw error + } + if (!publishing) { + running.finish({ tabTakenBack: false }) + return null + } + return trackEarlyAgentLaunchTab({ + paneKey, + ownedPane, + publishing, + finishRunning: (tabTakenBack) => running.finish({ tabTakenBack }), + agentBound: () => running.agentBound(), + paneIsLive: () => runtime.hasLiveTerminalForPaneKey(paneKey), + closedByUser: () => running.closedByUser(), + report: (verdict) => + runtime.reportAgentLaunchPaneVerdict( + { worktreeId: workspace.id, tabId: pane.tabId, leafId: pane.leafId }, + verdict + ), + // What the record now says for a pane nothing spawned into, so it never stays blank. + unspawnedVerdict: () => + runtime.hasLiveTerminalForPaneKey(paneKey) + ? { kind: 'proceed' } + : agentLaunchPaneVerdictFromRecord( + listAgentSessionOperationRowsOwningPane( + runtime.openedAgentSessionRecordStore()?.listOperationRows() ?? [], + ownedPane, + Date.now() + ), + paneKey + ) + }) +} + +function trackEarlyAgentLaunchTab(args: { + paneKey: string + ownedPane: AgentSessionOperationOwnedPane + publishing: Promise + finishRunning: (tabTakenBack: boolean) => void + agentBound: () => void + paneIsLive: () => boolean + closedByUser: () => boolean + report: (verdict: AgentLaunchPaneVerdict) => void + unspawnedVerdict: () => AgentLaunchPaneVerdict +}): EarlyAgentLaunchTab { + let reply: AgentLaunchTabPublished | null = null + let executing = false + let ranHere: boolean | null = null + const published = args.publishing.then( + (answer) => { + reply = answer + return answer + }, + (error: unknown) => { + // The tab did not appear; the launch still runs and its tab appears when it spawns. + console.warn('[agent-launch] the window did not show the launch tab early', error) + return null + } + ) + return { + paneKey: args.paneKey, + ownedPane: args.ownedPane, + executing: () => { + executing = true + }, + surfacePublished: (result) => { + ranHere = result.outcome.kind === 'terminal' && result.outcome.paneKey === args.paneKey + if (ranHere) { + // Before its prompt is delivered, which can take a minute: the pane shows the agent now. + args.agentBound() + } + }, + windowShowsTab: () => reply !== null, + closedByUser: args.closedByUser, + finish: () => { + if (ranHere === true) { + // The agent's spawn bound the pane; its own spawn settles the tab. + args.finishRunning(false) + return + } + void published.then((answer) => { + // Only a tab this request made goes, only when nothing will ever run in it, and never one + // a running agent holds (a replay can remake a tab whose agent survived). + const nothingWillRunHere = !executing || ranHere === false + const takeBack = answer?.created === true && nothingWillRunHere && !args.paneIsLive() + args.finishRunning(takeBack) + // Every shown pane nothing spawned into ends in a verdict: taken back, or what the record says. + args.report(takeBack ? { kind: 'withdrawn' } : args.unspawnedVerdict()) + }) + }, + placement: () => reply?.placement + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index d9c9c40e4696..72cc48c3bc3e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -107,6 +107,7 @@ export function agentLaunchWorkspaceFactory( finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest) return { worktreeId: result.worktree.id, + connectionId: repo.connectionId ?? null, startupTerminalHandle: result.startupTerminal?.handle, ...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}), ...(result.startupTerminal?.paneKey diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index c0aeb4436cee..d41f250ad969 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -11,6 +11,10 @@ import { AgentLaunchPaneAlreadyLiveError } from '../../../../shared/agent-launch import type { RpcContext } from '../core' import { resolveRpcCallerIdentity } from '../rpc-caller-identity' import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { + AgentLaunchTabPublished, + AgentLaunchTabPublishRequest +} from '../../../../shared/agent-launch-tab-publication' export const STRUCTURED_PREFERENCE = { experimentalNativeChat: true, @@ -42,6 +46,10 @@ export type AgentLaunchRuntimeStubOptions = { /** Panes this runtime found already running, by the handle it issued them: a restarted host * that could not re-adopt a surviving PTY's handle issues a new one for the same pane. */ adoptedPanes?: Record + /** A window owning the layout, answering an early tab publish; absent models a host with none. */ + publishAgentLaunchTab?: ( + request: Omit + ) => Promise | null } function reportPromptCarry( @@ -146,7 +154,19 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) { } return launchRecordStore }), - waitForSetupTerminalCompletion + waitForSetupTerminalCompletion, + canPublishAgentLaunchTab: vi.fn(() => options.publishAgentLaunchTab !== undefined), + publishAgentLaunchTab: vi.fn( + (request: Omit) => + options.publishAgentLaunchTab?.(request) ?? null + ), + reportAgentLaunchPaneVerdict: vi.fn( + (_pane: { worktreeId: string; tabId: string; leafId: string }, _verdict: unknown) => {} + ), + // A pane this runtime created or adopted is running its process. + hasLiveTerminalForPaneKey: vi.fn((paneKey: string) => handlesByPaneKey.has(paneKey)), + openedAgentSessionRecordStore: vi.fn((): AgentSessionRecordStore | null => launchRecordStore), + closeTerminal: vi.fn(async (_handle: string) => ({})) } } diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index e939ed081106..910aeadc1966 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -480,6 +480,8 @@ describe('the terminal factory', () => { expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-new', { startupAgent: 'claude', + // The host derives the tab's first view by the window's rule; chat view is on by default here. + viewMode: 'chat', onPtySpawnDispatched: expect.any(Function) }) expect(createStructuredSession).not.toHaveBeenCalled() @@ -503,6 +505,7 @@ describe('the terminal factory', () => { // runtime as `id:id:wt-7`. expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { startupAgent: 'grok', + viewMode: 'chat', onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index c405caacf75e..f65833c42006 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -20,18 +20,14 @@ */ import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' -import type { - AgentLaunchIntent, - AgentLaunchResult, - AgentLaunchTarget -} from '../../../../shared/agent-launch-intent' +import type { AgentLaunchIntent, AgentLaunchResult } from '../../../../shared/agent-launch-intent' import { agentSessionOperationKey } from '../../../../shared/agent-session-operation-ledger' import { WorktreeCreateCollisionError, WORKTREE_CREATE_COLLISION_CODE } from '../../../../shared/new-workspace/worktree-create-collision' -import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan' import { executeAgentLaunch } from '../../../agent-launch/agent-launch-executor' import { trackTerminalSpawnDispatch, @@ -40,6 +36,13 @@ import { import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcContext } from '../core' import { admitAgentLaunchOperation, agentLaunchOperationCallerKey } from './agent-launch-replay' +import { + AgentLaunchExecutionError, + agentLaunchTabClosedAnswer, + settleLaunchWhoseTabWasClosed, + settleQuietly, + withEarlyTab +} from './agent-launch-execution-outcome' import { AgentLaunch, AgentLaunchReplay, type AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchSurfaceFactory } from './agent-launch-surfaces' import { @@ -51,6 +54,8 @@ import { selectAgentLaunchTabForCaller } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' +import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution' +import { publishEarlyTab, withPlacement, type AgentLaunchView } from './agent-launch-tab-publication' /** * Advertising `agent.launch.v2` is a client's statement that it understands EITHER outcome — a @@ -69,83 +74,6 @@ export function supportsAgentLaunch( ) } -/** - * A client addresses a workspace by selector, but the result's `worktreeId` is an id and every - * step below the executor re-prefixes it as `id:`. Resolving here is what keeps a - * caller's `id:wt-7` from reaching the runtime as `id:id:wt-7`. - * - * The launch *scope* is what is asked for, because the id below is the only thing read off it. The - * git-worktree record is the narrower answer — it does not exist for the floating workspace, so - * asking for one refused a launch this method can perfectly well run, on a workspace whose id it - * had already resolved. A folder workspace survived that only because the resolver fabricates a - * worktree row for it; the scope is the answer that is real for all three kinds. - */ -async function agentLaunchTarget( - params: AgentLaunchParams, - runtime: Pick -): Promise { - if (params.target.kind === 'create-worktree') { - return { kind: 'create-worktree', create: { ...params.target.create } } - } - const workspace = await runtime.showTerminalWorkspaceLaunchScope(params.target.worktree) - return { kind: 'existing', worktree: workspace.id, workspacePath: workspace.path } -} - -async function agentLaunchIntent( - params: AgentLaunchParams, - runtime: OrcaRuntimeService -): Promise { - return { - agent: params.agent, - target: await agentLaunchTarget(params, runtime), - ...(params.prompt ? { prompt: params.prompt } : {}), - ...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}), - ...(params.reuseTerminal ? { reuseTerminal: params.reuseTerminal } : {}), - // `null` means "no arguments" and must survive; only absence falls back to the settings default. - ...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {}), - ...(params.cwd ? { cwd: params.cwd } : {}), - ...(params.launchSource ? { launchSource: params.launchSource } : {}), - ...(params.paneKey ? { paneKey: params.paneKey } : {}), - ...(params.sessionId ? { sessionId: params.sessionId } : {}) - } -} - -async function validateReusedTerminal( - intent: AgentLaunchIntent, - runtime: Pick -): Promise { - if (!intent.reuseTerminal) { - return - } - if (intent.target.kind !== 'existing') { - throw new Error('agent_launch_reuse_requires_existing_workspace') - } - const terminal = await runtime.showTerminal(intent.reuseTerminal.handle) - if (terminal.worktreeId !== intent.target.worktree) { - throw new Error('agent_launch_terminal_worktree_mismatch') - } - if (!(await runtime.isTerminalRunningAgent(intent.reuseTerminal.handle))) { - throw new Error('agent_launch_terminal_not_running_agent') - } -} - -/** - * The half before anything is created: resolve the caller's selector, then check a reused terminal. - * A throw from here proves no surface was built, which is what lets the ledger record a launch that - * failed in it as `failed` rather than `unknown`. - */ -async function resolveUnlaunchedIntent( - params: AgentLaunchParams, - runtime: OrcaRuntimeService -): Promise { - if (params.reuseTerminal || params.target.kind === 'create-worktree') { - assertOpenCodeModelLaunchPreferencesAbsent(params.agent, params.sessionOptions) - } - const intent = await agentLaunchIntent(params, runtime) - await validateReusedTerminal(intent, runtime) - return intent -} - /** What a launch admitted under an operation id carries into its execution. */ type ReplaySafeLaunch = { attachOperationId: string @@ -160,10 +88,11 @@ type ReplaySafeLaunch = { async function runAgentLaunch( intent: AgentLaunchIntent, context: RpcContext, + view: AgentLaunchView, replaySafe?: ReplaySafeLaunch ): Promise { const callerNavigationId = agentLaunchCallerNavigationId(intent.target, context) - return executeAgentLaunch({ + const result = await executeAgentLaunch({ runtime: context.runtime, intent, surfaces: agentLaunchSurfaceFactory( @@ -171,17 +100,20 @@ async function runAgentLaunch( replaySafe?.attachOperationId, replaySafe?.callerKey, callerNavigationId !== null, - replaySafe?.terminalSpawn + replaySafe?.terminalSpawn, + view.early ), workspaces: agentLaunchWorkspaceFactory(context, intent.agent), // The tab is shown as it is published, not after a prompt that can take a minute to land. onSurfacePublished: (surface) => { - replaySafe?.recordSurface(surface) - if (callerNavigationId !== null) { + view.early?.surfacePublished(surface) + replaySafe?.recordSurface(withPlacement(surface, view)) + if (callerNavigationId !== null && view.presentation !== 'background') { selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) } } }) + return withPlacement(result, view) } /** @@ -198,8 +130,12 @@ function runLegacyAgentLaunch( params: AgentLaunchParams, context: RpcContext ): Promise { + // No early tab: without a launch record its pane could not learn how the launch ended. const execute = async () => - runAgentLaunch(await resolveUnlaunchedIntent(params, context.runtime), context) + runAgentLaunch(await resolveUnlaunchedIntent(params, context.runtime, null), context, { + early: null, + presentation: params.presentation + }) if (params.target.kind === 'create-worktree' && params.target.create.clientMutationId) { return context.runtime.dedupeWorktreeCreate( params.target.create.repo, @@ -210,27 +146,11 @@ function runLegacyAgentLaunch( return execute() } -function settleQuietly(settlement: Promise): Promise { - return settlement.catch((error: unknown) => { - console.warn('[agent-launch] the launch settled, its operation row did not', error) - }) -} - type ActiveAgentLaunch = { fingerprint: string promise: Promise } -class AgentLaunchExecutionError extends Error { - constructor( - cause: unknown, - /** Decided once, by the launch that ran; a later reader cannot re-derive it from the error. */ - readonly failedWithoutEffects: boolean - ) { - super('agent_session_operation_unknown', { cause }) - } -} - const activeAgentLaunchesByRuntime = new WeakMap< OrcaRuntimeService, Map @@ -251,16 +171,51 @@ async function executeReplaySafeAgentLaunch( context: RpcContext, fingerprint: string ): Promise { - const admission = await admitAgentLaunchOperation(context, params, fingerprint) - if (admission.decision === 'refuse') { - throw Object.assign(new Error(admission.refusal.code), { code: admission.refusal.code }) + // The tab is the host's first act: admission has a cold cost the user should not watch. + const early = await publishEarlyTab(params, context) + let admission: Awaited> + try { + admission = await admitAgentLaunchOperation( + context, + params, + fingerprint, + Date.now(), + early?.ownedPane + ) + } catch (error) { + early?.finish() + throw error } - if (admission.decision === 'replay') { + if (admission.decision !== 'execute') { + // Nothing runs under this request. A tab it made goes, unless an agent still runs in its pane + // (a replay can remake the tab of an agent that survived). + early?.finish() + if (admission.decision === 'refuse') { + throw Object.assign(new Error(admission.refusal.code), { code: admission.refusal.code }) + } return admission.result } + early?.executing() + return withEarlyTab(early, () => + executeAdmittedAgentLaunch(params, context, admission, { + early, + presentation: params.presentation + }) + ) +} + +async function executeAdmittedAgentLaunch( + params: AgentLaunchParams & { operationId: string }, + context: RpcContext, + admission: Extract< + Awaited>, + { decision: 'execute' } + >, + view: AgentLaunchView +): Promise { let intent: AgentLaunchIntent try { - intent = await resolveUnlaunchedIntent(params, context.runtime) + intent = await resolveUnlaunchedIntent(params, context.runtime, view.early) } catch (error) { await settleQuietly(admission.fail(agentLaunchFailureCode(error))) throw error @@ -268,13 +223,16 @@ async function executeReplaySafeAgentLaunch( const terminalSpawn = trackTerminalSpawnDispatch() let result: AgentLaunchResult try { - result = await runAgentLaunch(intent, context, { + result = await runAgentLaunch(intent, context, view, { attachOperationId: admission.attachOperationId, callerKey: admission.callerKey, terminalSpawn, recordSurface: (provisional) => void settleQuietly(admission.record(provisional)) }) } catch (error) { + if (view.early?.closedByUser()) { + await settleLaunchWhoseTabWasClosed(context, view.early, admission) + } const failedWithoutEffects = launchFailureWithoutEffectsCode( error, intent.target.kind, @@ -285,6 +243,10 @@ async function executeReplaySafeAgentLaunch( } throw new AgentLaunchExecutionError(error, failedWithoutEffects !== null) } + if (view.early?.closedByUser()) { + // The user closed its tab after the spawn left: the agent stops, as any closed tab's does. + await settleLaunchWhoseTabWasClosed(context, view.early, admission) + } // Bookkeeping: a failure leaves the first write, whose owed prompt replays as `unconfirmed` (or as // `unknown` to a caller that cannot read it), never as `not-delivered`. await settleQuietly(admission.settle(result)) @@ -335,6 +297,9 @@ export const AGENT_LAUNCH_METHODS = [ code: WORKTREE_CREATE_COLLISION_CODE }) } + if (error.cause instanceof AgentLaunchTabClosedError) { + throw agentLaunchTabClosedAnswer(context) + } if (error.failedWithoutEffects) { throw error.cause } @@ -362,7 +327,12 @@ export const AGENT_LAUNCH_METHODS = [ context ).catch((error: unknown) => { // Preserve the original error contract for callers of the optional-identity method. - throw error instanceof AgentLaunchExecutionError ? error.cause : error + if (error instanceof AgentLaunchExecutionError) { + throw error.cause instanceof AgentLaunchTabClosedError + ? agentLaunchTabClosedAnswer(context) + : error.cause + } + throw error }) } }) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index c5b279e41db2..8a6b3d2bc467 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -1,4 +1,9 @@ +import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { + AgentLaunchTabPublished, + AgentLaunchTabPublishRequest +} from '../../shared/agent-launch-tab-publication' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' import type { TuiAgent } from '../../shared/tui-agent' @@ -75,6 +80,12 @@ export type RuntimeNotifier = { | Promise<{ tabId: string; title?: string | null; identity?: TerminalRevealIdentity }> | { tabId: string; title?: string | null; identity?: TerminalRevealIdentity } | void + /** Shows an agent launch's tab before its process exists; the pane attaches when it does. */ + publishAgentLaunchTab?( + request: Omit + ): Promise + /** A launch pane's fate, for the window to keep on its tab or act on. */ + agentLaunchPaneVerdict?(event: AgentLaunchPaneVerdictEvent): void resolveLegacyWorkerTerminalRecovery?( paneKey: string, resolution: 'adopted' | 'exited' | 'rolled_back', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 9cfe31251b6e..156ff03210e1 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -1,3 +1,4 @@ +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' import type { RpcTransport } from '../rpc/transport' @@ -226,7 +227,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { ) }, onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), - onReady: () => { + onReady: (socket) => { // Why: first authenticated mobile/remote client (direct WS and // cloud relay both attach here) starts path-candidate tracking. // Activation is a local-host concern: candidate buffers live on the @@ -234,6 +235,9 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { // legitimately lack this method (its own server activates it). this.runtime.activateRecentPtyPathCandidateTracking?.() this.mobileRelayPairingProvider?.onDemandStateChanged?.() + if (socket.clientCapabilities.includes(AGENT_LAUNCH_RUNTIME_CAPABILITY)) { + this.runtime.noteAgentLaunchClientReady?.() + } }, onClose: (socket, hasOtherConnections) => { if (!socket) { diff --git a/src/main/startup/agent-launch-record-warmup.ts b/src/main/startup/agent-launch-record-warmup.ts new file mode 100644 index 000000000000..8469e15cd0bc --- /dev/null +++ b/src/main/startup/agent-launch-record-warmup.ts @@ -0,0 +1,25 @@ +import type { BrowserWindow } from 'electron' +import { mainProcessState as state } from './main-process-state' + +// Why a delay after load: the open is synchronous disk work on the main thread (4-16 ms measured on +// an idle Mac, 95-280 ms on a loaded one), and the renderer's own start runs right after first load. +export const AGENT_LAUNCH_RECORD_WARMUP_DELAY_MS = 1_500 + +/** Startup is settled: the runtime may open the launch record once a client that can launch is + * connected too, so that first launch does not pay for it. */ +function markStartupSettled(): void { + state.runtime?.noteAgentLaunchStartupSettled() +} + +function warmLater(): void { + setTimeout(markStartupSettled, AGENT_LAUNCH_RECORD_WARMUP_DELAY_MS).unref?.() +} + +/** After the window's first load, so startup and the first paint never wait on it. */ +export function scheduleAgentLaunchRecordWarmup(window: BrowserWindow | null): void { + if (!window || window.isDestroyed() || !window.webContents.isLoading()) { + warmLater() + return + } + window.webContents.once('did-finish-load', warmLater) +} diff --git a/src/main/startup/main-process-runtime-launch-activation.test.ts b/src/main/startup/main-process-runtime-launch-activation.test.ts index ac0c681c6be8..6fd2ec6ec571 100644 --- a/src/main/startup/main-process-runtime-launch-activation.test.ts +++ b/src/main/startup/main-process-runtime-launch-activation.test.ts @@ -103,10 +103,15 @@ const { initializeMainProcessReady } = await import('./main-process-ready') const { mainProcessState: state } = await import('./main-process-state') const { createServeDesktopActivationGate } = await import('./serve-desktop-activation') const { focusExistingMainWindow } = await import('../window/focus-existing-window') +const { AGENT_LAUNCH_RECORD_WARMUP_DELAY_MS } = await import('./agent-launch-record-warmup') type FakeWindow = { id: number - webContents: { id: number } + webContents: { + id: number + isLoading: () => boolean + once: (event: string, listener: () => void) => void + } isDestroyed: () => boolean isMinimized: () => boolean restore: () => void @@ -119,13 +124,23 @@ describe('desktop startup activation', () => { let windows: FakeWindow[] let ipcHandles: Set let trustedRendererId: number | null + let firstLoadListeners: (() => void)[] + const startupSettled = vi.fn() // Mirrors openMainWindow's non-idempotent side effects that broke in the field. function openMainWindow(): FakeWindow { const id = windows.length + 1 const window: FakeWindow = { id, - webContents: { id }, + webContents: { + id, + isLoading: () => true, + once: (event, listener) => { + if (event === 'did-finish-load') { + firstLoadListeners.push(listener) + } + } + }, isDestroyed: () => false, isMinimized: () => false, restore: vi.fn(), @@ -147,12 +162,16 @@ describe('desktop startup activation', () => { showWindowWithoutStealingFocus.mockClear() ipcHandles = new Set() trustedRendererId = null + firstLoadListeners = [] + startupSettled.mockClear() launchHooks.duringInstallDirRepair = () => {} launchHooks.failBeforeWindow = false state.mainWindow = null state.isServeMode = false - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only null-checks the runtime before the mocked RPC server takes it. - state.runtime = {} as NonNullable + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only null-checks the runtime and marks its launch-record warm-up before the mocked RPC server takes it. + state.runtime = { + noteAgentLaunchStartupSettled: startupSettled + } as unknown as NonNullable // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only calls whenReady(). state.windowsShellPathHydration = { whenReady: () => Promise.resolve() @@ -201,6 +220,27 @@ describe('desktop startup activation', () => { } ) + it('marks startup settled for the launch record only after the window has loaded and settled', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + try { + await initializeMainProcessReady({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only calls once() on the returned window. + openMainWindow: () => openMainWindow() as unknown as NonNullable, + handleMacAppActivation: vi.fn() + }) + expect(startupSettled).not.toHaveBeenCalled() + + for (const listener of firstLoadListeners) { + listener() + } + expect(startupSettled).not.toHaveBeenCalled() + vi.advanceTimersByTime(AGENT_LAUNCH_RECORD_WARMUP_DELAY_MS) + expect(startupSettled).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('does not replay an activation when launch fails before the startup window', async () => { launchHooks.duringInstallDirRepair = () => state.desktopActivationGate?.requestActivation() launchHooks.failBeforeWindow = true diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index b3df23e5861c..054adf9f71b8 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -39,6 +39,7 @@ import { triggerStartupNotificationRegistration } from '../ipc/startup-notificat import { startDesktopPushService } from './main-process-push-startup' import { mainProcessState as state } from './main-process-state' import { logStartupMilestone } from './startup-diagnostics' +import { scheduleAgentLaunchRecordWarmup } from './agent-launch-record-warmup' import { emitServeBrowserIdentityActionLine } from '../server/serve-stdout-boundary' import { getBrowserIdentityModeStatus } from '../browser/browser-identity-mode-store' @@ -162,6 +163,7 @@ async function launchServeMode( console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + scheduleAgentLaunchRecordWarmup(null) // Why: a phone paired to a headless host still registers and unregisters its token; // it simply never receives a push, because nothing dispatches notifications here. startDesktopPushService(runtimeRpc) @@ -237,6 +239,7 @@ async function launchDesktopMode( } ) ]) + scheduleAgentLaunchRecordWarmup(win) if (!runtimeRpcStartResult.ok) { // Why gated: this dialog is the only launch-phase text read through translateMain, and i18n // now settles alongside this phase — without the wait a non-English user could get the diff --git a/src/main/window/agent-launch-tab-publish-relay.ts b/src/main/window/agent-launch-tab-publish-relay.ts new file mode 100644 index 000000000000..fb635d4171ac --- /dev/null +++ b/src/main/window/agent-launch-tab-publish-relay.ts @@ -0,0 +1,51 @@ +import { randomUUID } from 'node:crypto' + +import { ipcMain } from 'electron' +import type { BrowserWindow } from 'electron' +import type { + AgentLaunchTabPublished, + AgentLaunchTabPublishReply, + AgentLaunchTabPublishRequest +} from '../../shared/agent-launch-tab-publication' + +const AGENT_LAUNCH_TAB_PUBLISH_TIMEOUT_MS = 10_000 + +/** Asks the window to show a launch's tab under the host's ids; resolves with where it landed. */ +export function requestAgentLaunchTabPublishFromRenderer( + mainWindow: BrowserWindow, + request: Omit +): Promise { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + return Promise.reject(new Error('renderer_unavailable')) + } + const webContents = mainWindow.webContents + const requestId = randomUUID() + return new Promise((resolve, reject) => { + const finish = (error: Error | null, published?: AgentLaunchTabPublished): void => { + clearTimeout(timeout) + ipcMain.removeListener('agentLaunch:tabPublishReply', onReply) + if (error) { + reject(error) + } else if (published) { + resolve(published) + } + } + const timeout = setTimeout( + () => finish(new Error('agent_launch_tab_publish_timeout')), + AGENT_LAUNCH_TAB_PUBLISH_TIMEOUT_MS + ) + const onReply = (event: Electron.IpcMainEvent, reply: AgentLaunchTabPublishReply): void => { + // Why: request ids are visible to renderer code; only the targeted window may answer. + if (event.sender !== webContents || reply.requestId !== requestId) { + return + } + if ('error' in reply) { + finish(new Error(reply.error)) + return + } + finish(null, { tabId: reply.tabId, created: reply.created, placement: reply.placement }) + } + ipcMain.on('agentLaunch:tabPublishReply', onReply) + webContents.send('ui:publishAgentLaunchTab', { ...request, requestId }) + }) +} diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 0322a816d709..857ed79d787e 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -11,6 +11,7 @@ import type { import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' +import { requestAgentLaunchTabPublishFromRenderer } from './agent-launch-tab-publish-relay' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import { registerRendererDocumentNavigation } from './renderer-document-navigation' @@ -143,6 +144,9 @@ export function registerRuntimeWindowLifecycle( reject(new Error('runtime_unavailable')) } }), + publishAgentLaunchTab: (request) => + requestAgentLaunchTabPublishFromRenderer(mainWindow, request), + agentLaunchPaneVerdict: (event) => send('ui:agentLaunchPaneVerdict', event), resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) => send('agentStatus:legacyWorkerTerminalRecovery', { paneKey, diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index f49e7e65e499..458a50c05841 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -1,6 +1,11 @@ import { ipcRenderer } from 'electron' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' +import type { + AgentLaunchTabPublishReply, + AgentLaunchTabPublishRequest +} from '../../shared/agent-launch-tab-publication' +import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -92,6 +97,25 @@ export const uiTerminalAndSessionTabsApi = { replyTerminalCreate: (reply: TerminalTabCreateReply): void => { ipcRenderer.send('terminal:tabCreateReply', reply) }, + onPublishAgentLaunchTab: ( + callback: (data: AgentLaunchTabPublishRequest) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentLaunchTabPublishRequest) => + callback(data) + ipcRenderer.on('ui:publishAgentLaunchTab', listener) + return () => ipcRenderer.removeListener('ui:publishAgentLaunchTab', listener) + }, + replyAgentLaunchTabPublish: (reply: AgentLaunchTabPublishReply): void => { + ipcRenderer.send('agentLaunch:tabPublishReply', reply) + }, + onAgentLaunchPaneVerdict: ( + callback: (data: AgentLaunchPaneVerdictEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentLaunchPaneVerdictEvent) => + callback(data) + ipcRenderer.on('ui:agentLaunchPaneVerdict', listener) + return () => ipcRenderer.removeListener('ui:agentLaunchPaneVerdict', listener) + }, onSplitTerminal: ( callback: (data: { tabId: string diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 74692c5c4728..9a9c8524ed3e 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -1,6 +1,11 @@ import type { MarkdownDocument } from '../../shared/filesystem-entry-types' import type { PersistedUIState } from '../../shared/persisted-ui-state-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { + AgentLaunchTabPublishReply, + AgentLaunchTabPublishRequest +} from '../../shared/agent-launch-tab-publication' +import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { @@ -180,6 +185,9 @@ export type UiCommandEventApi = { callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void ) => () => void replyTerminalCreate: (reply: TerminalTabCreateReply) => void + onPublishAgentLaunchTab: (callback: (data: AgentLaunchTabPublishRequest) => void) => () => void + replyAgentLaunchTabPublish: (reply: AgentLaunchTabPublishReply) => void + onAgentLaunchPaneVerdict: (callback: (data: AgentLaunchPaneVerdictEvent) => void) => () => void onSplitTerminal: ( callback: (data: { tabId: string diff --git a/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx new file mode 100644 index 000000000000..51feff3b4234 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/AgentLaunchPaneNotice.tsx @@ -0,0 +1,62 @@ +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { agentLaunchPanePrompt } from '@/lib/agent-launch-pane-prompt' +import { createPortal } from 'react-dom' +import type { AgentLaunchPaneOutcome } from '../../../../shared/agent-launch-pane-verdict' +import { agentLaunchPaneNoticeText } from './agent-launch-pane-notice-text' + +/** + * What a pane an agent launch laid out shows when its agent is not running in it. Not dismissable: + * it is the pane's state, read again from the launch record on every mount, not a passing error. + */ +export function AgentLaunchPaneNotice({ + refusal, + tabId +}: { + refusal: AgentLaunchPaneOutcome + tabId: string +}): React.JSX.Element { + const prompt = agentLaunchPanePrompt(tabId) + return ( +
+ {agentLaunchPaneNoticeText(refusal)} + {prompt ? ( + + ) : null} +
+ ) +} + +/** The notice in the active pane, when that pane's error is a launch's. */ +export function AgentLaunchPaneNoticePortal({ + refusal, + isActive, + pane, + tabId +}: { + refusal: AgentLaunchPaneOutcome | null + isActive: boolean + pane: { id: number; container: HTMLElement } | null | undefined + tabId: string +}): React.JSX.Element | null { + if (!refusal || !isActive || !pane) { + return null + } + return createPortal( + , + pane.container, + `agent-launch-pane-notice-${pane.id}` + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index 7eda851e7e9e..99f92b144e67 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -7,6 +7,7 @@ import CloseTerminalDialog from './CloseTerminalDialog' import TerminalContextMenu from './TerminalContextMenu' import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay' import { isPaneOwnerUnverifiedError, TerminalErrorToast } from './TerminalErrorToast' +import { AgentLaunchPaneNoticePortal } from './AgentLaunchPaneNotice' import { requestTerminalPaneRecovery } from './terminal-pane-recovery' import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog' import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' @@ -109,6 +110,7 @@ export function TerminalPaneSurface({ terminalLinkActionRequest, titleUsesLightSurface, visibleQuickCommandHosts, + visibleLaunchRefusal, visibleTerminalError, worktreeId } = controller @@ -161,6 +163,12 @@ export function TerminalPaneSurface({ }} /> + {/* Why: the reconnect banner already owns SSH recovery UX; the z-50 error toast was painting over it (same bottom strip) with the raw ssh:connect failure. */} {visibleTerminalError && isActive && !showSshReconnectOverlay && activePane diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts new file mode 100644 index 000000000000..e624a2328d50 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice-text.ts @@ -0,0 +1,50 @@ +import { translate } from '@/i18n/i18n' +import type { AgentLaunchPaneOutcome } from '../../../../shared/agent-launch-pane-verdict' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +/** What the pane shows, if its tab keeps a final launch outcome for this leaf. */ +export function agentLaunchPaneOutcomeForLeaf( + tab: Pick | null | undefined, + leafId: string | null | undefined +): AgentLaunchPaneOutcome | null { + const launchPane = tab?.agentLaunchPane + return launchPane?.outcome && launchPane.leafId === leafId ? launchPane.outcome : null +} + +/** The recorded reason in words a user can act on; null when it names nothing they can fix. */ +function notStartedReason(code: string): string | null { + if (code.includes('ENOENT')) { + return translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.commandNotFound', + "Its command wasn't found on this machine." + ) + } + if (code === 'agent_session_exited_during_start') { + return translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.exitedDuringStart', + 'It exited while starting.' + ) + } + if (code === 'worktree_not_found') { + return translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.workspaceGone', + 'Its workspace is no longer available.' + ) + } + return null +} + +export function agentLaunchPaneNoticeText(refusal: AgentLaunchPaneOutcome): string { + if (refusal.kind === 'unconfirmed') { + return translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed', + "Couldn't confirm the agent started." + ) + } + const lead = translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted', + "The agent couldn't start." + ) + const reason = notStartedReason(refusal.code) + return reason ? `${lead} ${reason}` : lead +} diff --git a/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts new file mode 100644 index 000000000000..55b9eef36d5e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-launch-pane-notice.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' + +const CATALOG: Record = { + 'auto.components.terminal.pane.AgentLaunchPaneNotice.notStarted': '智能体无法启动。', + 'auto.components.terminal.pane.AgentLaunchPaneNotice.unconfirmed': '无法确认智能体已启动。', + 'auto.components.terminal.pane.AgentLaunchPaneNotice.exitedDuringStart': '它在启动时退出了。' +} +vi.mock('@/i18n/i18n', () => ({ + translate: (key: string, fallback: string) => CATALOG[key] ?? fallback +})) + +const { agentLaunchPaneNoticeText, agentLaunchPaneOutcomeForLeaf } = + await import('./agent-launch-pane-notice-text') + +describe("the pane of a launch whose agent isn't running", () => { + it("says it couldn't start, in the viewer's language, with a reason they can act on", () => { + expect( + agentLaunchPaneNoticeText({ kind: 'not-started', code: 'agent_session_exited_during_start' }) + ).toBe('智能体无法启动。 它在启动时退出了。') + }) + + it('never shows a host error code', () => { + expect( + agentLaunchPaneNoticeText({ kind: 'not-started', code: 'agent_launch_pane_already_live' }) + ).toBe('智能体无法启动。') + }) + + it("says it can't confirm, never that it failed, when nobody knows", () => { + expect(agentLaunchPaneNoticeText({ kind: 'unconfirmed' })).toBe('无法确认智能体已启动。') + }) +}) + +describe('which pane shows it', () => { + const tab = { agentLaunchPane: { leafId: 'leaf-1', outcome: { kind: 'unconfirmed' as const } } } + + it('the leaf the launch laid out, once its fate is final', () => { + expect(agentLaunchPaneOutcomeForLeaf(tab, 'leaf-1')).toEqual({ kind: 'unconfirmed' }) + expect(agentLaunchPaneOutcomeForLeaf(tab, 'leaf-2')).toBeNull() + expect( + agentLaunchPaneOutcomeForLeaf({ agentLaunchPane: { leafId: 'leaf-1' } }, 'leaf-1') + ).toBeNull() + expect(agentLaunchPaneOutcomeForLeaf(undefined, 'leaf-1')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts index bcdbd9b41b30..0686086efa21 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts @@ -17,6 +17,7 @@ import type { TerminalPaneBindingController } from './use-terminal-pane-layout-b import { retireUnboundIpcTerminalPane } from './retire-unbound-ipc-terminal-pane' import { capturePendingTerminalPaneClose } from './terminal-pane-close-admission' import { commitTerminalSurfaceClose } from '@/store/terminals/terminal-surface-close-intent' +import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' export function useTerminalPaneCloseActions(controller: TerminalPaneBindingController) { const confirmedCloseRef = useRef<(() => void) | null>(null) @@ -51,6 +52,13 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr clearSessionRestoredBannerForPane(paneId) const leafId = manager.getLeafId(paneId) if (leafId) { + const launchPane = useAppStore + .getState() + .tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.agentLaunchPane + if (launchPane?.leafId === leafId && !launchPane.outcome) { + // The user closed a launch pane whose agent is still starting; main stops that launch. + noteAgentLaunchPaneClosedByUser(tabId, leafId) + } commitTerminalSurfaceClose(worktreeId, { kind: 'pane', tabId, leafId }) retireUnboundIpcTerminalPane({ getState: useAppStore.getState, 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 7e3530c72148..f2fe6efa62b9 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 @@ -8,6 +8,7 @@ import { resolveOpaqueTerminalBackground } from '@/lib/terminal-theme' import { stripSshReconnectOwnedErrorLines } from './TerminalErrorToast' +import { agentLaunchPaneOutcomeForLeaf } from './agent-launch-pane-notice-text' import { mapPaneTerminalErrors, terminalErrorForPane } from './terminal-error-accumulation' import { nativeChatLaunchAgentForLeaf, @@ -103,6 +104,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll terminalErrorsByPaneId, activePane?.id ?? null ) + const visibleLaunchRefusal = agentLaunchPaneOutcomeForLeaf(terminalTab, activePane?.leafId) const menuPaneHasCustomTitle = contextMenu.menuPaneId !== null && Boolean(paneTitles[contextMenu.menuPaneId]) const menuAgentSessionId = useAppStore((state) => @@ -196,6 +198,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll activePane, managedPanes, showSshReconnectOverlay, + visibleLaunchRefusal, visibleTerminalError, menuPaneHasCustomTitle, menuAgentSessionId, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts index 5c0522c97f15..71c1bb1bede9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-title-state.ts @@ -5,6 +5,7 @@ import { type RemotePaneLayoutPusher } from './remote-pane-layout-push' import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure' +import { AGENT_LAUNCH_PANE_REFUSED_CODE } from '../../../../shared/agent-launch-pane-verdict' import { appendPaneTerminalError, clearPaneTerminalError } from './terminal-error-accumulation' import { stripSshReconnectOwnedErrorLines } from './TerminalErrorToast' import { updateTerminalRemoteRuntimeRecoveryUiState } from './terminal-remote-runtime-recovery-ui-state' @@ -86,6 +87,10 @@ export function useTerminalPaneTitleState(controller: TerminalPaneFoundation) { [cancelPendingRenameFrames] ) const onPtyErrorRef = useRef((paneId: number, message: string) => { + if (message.includes(AGENT_LAUNCH_PANE_REFUSED_CODE)) { + // A launch pane's verdict reached the tab typed; its notice shows it, not an error. + return + } if (isTerminalSessionStateSaveFailure(message)) { setTerminalError(null) setTerminalErrorsByPaneId({}) diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index 1f58e87cb99f..4aa1539be6ce 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -51,6 +51,8 @@ export function buildWindowApi(args: { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts index 249e435b9a47..35e7b9216b8f 100644 --- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -178,6 +178,8 @@ export async function useIpcEventsForCloseRouting({ onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts index 0e050f41cfef..63e827954333 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-scenario-types.ts @@ -21,6 +21,7 @@ export type TerminalCreateListenerPayload = { activate?: boolean focus?: boolean presentation?: 'background' | 'focused' + surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string diff --git a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts index 20e954aa1024..4c0d61565539 100644 --- a/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-terminal-create-window-test-fixtures.ts @@ -98,6 +98,8 @@ export function buildTerminalCreateWindow(args: { }, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onSplitTerminal: () => () => {}, onRenameTerminal: () => () => {}, onFocusTerminal: ( diff --git a/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts new file mode 100644 index 000000000000..a1fe9f8776d5 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-launch-tab-ipc-bridge.ts @@ -0,0 +1,21 @@ +import { publishAgentLaunchTab } from '@/lib/agent-launch-tab-publication' +import { applyAgentLaunchPaneVerdict } from '@/lib/agent-launch-pane-verdict-application' + +export function registerAgentLaunchTabIpcBridge(unsubs: (() => void)[]): void { + unsubs.push( + window.api.ui.onPublishAgentLaunchTab((request) => { + try { + window.api.ui.replyAgentLaunchTabPublish({ + requestId: request.requestId, + ...publishAgentLaunchTab(request) + }) + } catch (error) { + window.api.ui.replyAgentLaunchTabPublish({ + requestId: request.requestId, + error: error instanceof Error ? error.message : 'agent_launch_tab_publish_failed' + }) + } + }), + window.api.ui.onAgentLaunchPaneVerdict(applyAgentLaunchPaneVerdict) + ) +} diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index bbe9899bc694..240991417728 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -27,6 +27,7 @@ import { registerTabLifecycleIpcBridge } from './tab-lifecycle-ipc-bridge' import { registerTerminalPresentationIpcBridge } from './terminal-presentation-ipc-bridge' import { registerPtySourceDisownedIpcBridge } from './pty-source-disowned-ipc-bridge' import { registerTerminalRequestIpcBridge } from './terminal-request-ipc-bridge' +import { registerAgentLaunchTabIpcBridge } from './agent-launch-tab-ipc-bridge' import { registerTerminalUiRoutingIpcBridge } from './terminal-ui-routing-ipc-bridge' import { registerUpdaterStatusIpcBridge } from './updater-status-ipc-bridge' import { createWorktreeEventRuntime } from './worktree-event-runtime' @@ -117,6 +118,7 @@ export function installAppLifetimeIpcEvents( registerTerminalPresentationIpcBridge(unsubs) registerTerminalRequestIpcBridge(unsubs) + registerAgentLaunchTabIpcBridge(unsubs) registerPtySourceDisownedIpcBridge(unsubs) registerTerminalUiRoutingIpcBridge(unsubs) registerSessionTabIpcBridge(unsubs) diff --git a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts index 83b06e155509..b587c249e848 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts @@ -35,9 +35,18 @@ export function focusTerminalInitiatedTab( } } -export function activateTerminalInitiatedWorktree(store: AppState, worktreeId: string): void { +export function activateTerminalInitiatedWorktree( + store: AppState, + worktreeId: string, + // Activate after creating into a requested group: activation prunes a workspace's empty groups. + createdTabIds?: readonly string[] +): void { store.setActiveView('terminal') - store.setActiveWorktree(worktreeId) + if (createdTabIds) { + store.setActiveWorktree(worktreeId, undefined, { createdTabIds }) + } else { + store.setActiveWorktree(worktreeId) + } store.markWorktreeVisited(worktreeId) if (!store.isNavigatingHistory) { store.recordWorktreeVisit(worktreeId) diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 5fd983d74d08..368bb66413e1 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -10,6 +10,7 @@ import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mod import { getConnectionIdFromState } from '@/lib/connection-context' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { tryMakePaneKey } from './agent-status-routing' +import { wasAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' import { useAppStore } from '../../store' import { activateExistingLeafInLayout, @@ -58,6 +59,13 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v activateTerminalInitiatedWorktree(store, worktreeId) } const worktreeTabs = store.tabsByWorktree[worktreeId] ?? [] + if (ptyId && tabId && leafId && wasAgentLaunchPaneClosedByUser(tabId, leafId)) { + // The user closed the launch's tab or pane while it waited. That close wins: it stays + // closed, and its agent stops, as closing any tab stops what runs in it. + // The host stops the same agent; a second kill of a retired PTY may reject, harmlessly. + window.api.pty.kill(ptyId).catch(() => {}) + throw new Error('agent_launch_tab_closed') + } // Why: a split pane revealed from mobile is only bound in the persisted // layout until its pane mounts; missing it minted a duplicate tab (#10486). const ownership = ptyId 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 new file mode 100644 index 000000000000..4649c299bd85 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-placement.test.ts @@ -0,0 +1,108 @@ +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' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) +})) +const testStore = vi.hoisted(() => { + const ref: { current: ReturnType | null } = { current: null } + return ref +}) +vi.mock('../../store', () => ({ + useAppStore: { + getState: () => { + if (!testStore.current) { + throw new Error('no test store') + } + return testStore.current.getState() + } + } +})) +vi.mock('@/lib/terminal-worktree-route', () => ({ + resolveTerminalWorktreeRoute: () => ({ runtimeEnvironmentId: null }) +})) +vi.mock('./terminal-command-state', async (importOriginal) => ({ + ...(await importOriginal()), + focusTerminalInitiatedTab: vi.fn() +})) + +createTabsSliceMockApi() + +const { registerTerminalRequestIpcBridge } = await import('./terminal-request-ipc-bridge') + +const WT = 'repo1::/tmp/feature' + +type CreateRequest = { + requestId: string + worktreeId: string + targetGroupId?: string + presentation?: 'focused' | 'background' +} + +let request!: (data: CreateRequest) => void +const replyTerminalCreate = vi.fn() +let store: ReturnType + +beforeEach(() => { + store = createTestStore() + testStore.current = store + replyTerminalCreate.mockClear() + vi.stubGlobal('window', { + ...globalThis.window, + api: { + ...globalThis.window?.api, + ui: { + ...globalThis.window?.api?.ui, + onRequestTerminalCreate: (callback: (data: CreateRequest) => void) => { + request = callback + return () => {} + }, + replyTerminalCreate + } + } + }) + registerTerminalRequestIpcBridge([]) + store.getState().setActiveWorktree(WT) +}) + +describe("a host's focused terminal create into a requested group", () => { + // Activating a workspace with a live tab prunes its empty groups; the requested one must survive. + it('lands in a just-created empty split beside a live terminal', () => { + store.getState().createTab(WT) + const sourceGroupId = store.getState().groupsByWorktree[WT]![0]!.id + const splitGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')! + store.getState().focusGroup(WT, splitGroupId) + + request({ + requestId: 'request-1', + worktreeId: WT, + targetGroupId: splitGroupId, + presentation: 'focused' + }) + + const tabId = replyTerminalCreate.mock.calls[0]?.[0]?.tabId + expect(tabId).toBeDefined() + const landed = store + .getState() + .unifiedTabsByWorktree[WT]?.find((tab) => tab.entityId === tabId)?.groupId + expect(landed).toBe(splitGroupId) + expect(store.getState().groupsByWorktree[WT]?.map((group) => group.id)).toContain(splitGroupId) + }) + + it('is new work in a workspace with no live terminal: its first bind moves it up in Recent', () => { + store.getState().setActiveWorktree('repo1::/tmp/other') + + request({ requestId: 'request-2', worktreeId: WT, presentation: 'focused' }) + + const tabId: string = replyTerminalCreate.mock.calls[0]?.[0]?.tabId + const tab = store.getState().tabsByWorktree[WT]?.find((candidate) => candidate.id === tabId) + expect(tab?.pendingActivationSpawn).toBeUndefined() + const epoch = store.getState().sortEpoch + store.getState().updateTabPtyId(tabId, 'pty-new-1') + expect(store.getState().sortEpoch).toBe(epoch + 1) + }) +}) 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 7a0190d7f96a..6aa784fa746a 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 @@ -51,9 +51,6 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { const shouldActivate = terminalPresentation === 'focused' const shouldSurfaceOwner = terminalPresentation !== 'background' && data.surfaceOwner !== false - if (shouldActivate) { - activateTerminalInitiatedWorktree(store, worktreeId) - } // Why: the paired launch client already resolved the mode, so its choice wins over the host renderer's local default. const tabOptions = data.launchAgent ? { @@ -92,6 +89,8 @@ export function registerTerminalRequestIpcBridge(unsubs: (() => void)[]): void { } } if (shouldActivate) { + // After the tab lands: activating prunes the workspace's empty groups, the requested one too. + activateTerminalInitiatedWorktree(store, worktreeId, [tab.id]) store.setActiveTabType('terminal', worktreeId) store.setActiveTab(tab.id) } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts new file mode 100644 index 000000000000..1730fb08497f --- /dev/null +++ b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupTerminalCreateSurfacing } from './ipc-events-terminal-create-test-harness' + +const LEAF = '88888888-8888-4888-8888-888888888888' + +describe('a launch tab while its agent is starting', () => { + it('stays closed when the user closed it, and its agent stops, as closing any tab stops it', async () => { + const scenario = await setupTerminalCreateSurfacing(() => false) + const { createTab, replyTerminalCreate, createTerminalListenerRef, storeState } = scenario + if (!createTerminalListenerRef.current) { + throw new Error('Expected the create-terminal listener to be registered') + } + const kill = vi.fn(async () => {}) + Object.assign(window.api, { pty: { kill } }) + storeState.tabsByWorktree['wt-1'] = [] + // The harness resets modules; the bridge reads the instance it imported. + const { noteAgentLaunchPaneClosedByUser } = await import('@/lib/agent-launch-pane-closes') + noteAgentLaunchPaneClosedByUser('tab-closed', LEAF) + + createTerminalListenerRef.current({ + requestId: 'reveal-closed-launch-tab', + worktreeId: 'wt-1', + ptyId: 'pty-agent', + tabId: 'tab-closed', + leafId: LEAF, + surfaceOwner: false + }) + + expect(createTab).not.toHaveBeenCalled() + expect(kill).toHaveBeenCalledWith('pty-agent') + expect(replyTerminalCreate).toHaveBeenCalledWith({ + requestId: 'reveal-closed-launch-tab', + error: 'agent_launch_tab_closed' + }) + }) + + it('is brought back, agent running, when the window merely lost it (a reload before it saved)', async () => { + const scenario = await setupTerminalCreateSurfacing(() => false) + const { createTab, createTerminalListenerRef, storeState } = scenario + if (!createTerminalListenerRef.current) { + throw new Error('Expected the create-terminal listener to be registered') + } + const kill = vi.fn(async () => {}) + Object.assign(window.api, { pty: { kill } }) + storeState.tabsByWorktree['wt-1'] = [] + + createTerminalListenerRef.current({ + requestId: 'reveal-reloaded-launch-tab', + worktreeId: 'wt-1', + ptyId: 'pty-agent', + tabId: 'tab-reloaded', + leafId: LEAF, + surfaceOwner: false + }) + + expect(kill).not.toHaveBeenCalled() + expect(createTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ id: 'tab-reloaded', initialPtyId: 'pty-agent' }) + ) + }) +}) diff --git a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts index 8e21584268cb..8036ef7cd400 100644 --- a/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts @@ -163,6 +163,8 @@ describe('useIpcEvents browser tab create routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts index 3e9e8d96ac45..739aab59aa91 100644 --- a/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-cli-worktree-activation.test.ts @@ -142,6 +142,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { }, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, @@ -408,6 +410,8 @@ describe('useIpcEvents CLI-created worktree activation', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts index aa2b87f5b96f..6cefe1365f78 100644 --- a/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-close-routing-browser-pages.test.ts @@ -139,6 +139,8 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, @@ -365,6 +367,8 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, @@ -586,6 +590,8 @@ describe('useIpcEvents browser tab close routing', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 3f20b3870ad8..9c204026b7aa 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -39,6 +39,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ssh.onPortForwardsChanged', 'ssh.onStateChanged', 'ui.onActivateWorktree', + 'ui.onAgentLaunchPaneVerdict', 'ui.onCloseActiveTab', 'ui.onCloseFloatingItem', 'ui.onCloseSessionTab', @@ -66,6 +67,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ui.onOpenSkillShare', 'ui.onOpenTasks', 'ui.onOpenWorkspaceBoard', + 'ui.onPublishAgentLaunchTab', 'ui.onRenameTerminal', 'ui.onRequestTabClose', 'ui.onRequestTabCreate', @@ -145,6 +147,8 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ui.onCreateTerminal', 'ui.onRequestTerminalTabMount', 'ui.onRequestTerminalCreate', + 'ui.onPublishAgentLaunchTab', + 'ui.onAgentLaunchPaneVerdict', 'pty.onExit', 'ui.onSplitTerminal', 'ui.onRenameTerminal', diff --git a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts index 622e3909b4b6..fbcabaeecc9f 100644 --- a/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-ssh-disconnect-cleanup.test.ts @@ -149,6 +149,8 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts index 002e071a19de..d631c59b7720 100644 --- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts @@ -114,6 +114,8 @@ describe('useIpcEvents updater integration', () => { onActivateWorktree: () => () => {}, onCreateTerminal: () => () => {}, onRequestTerminalCreate: () => () => {}, + onPublishAgentLaunchTab: () => () => {}, + onAgentLaunchPaneVerdict: () => () => {}, onRequestTerminalTabMount: () => () => {}, replyTerminalCreate: () => {}, onSplitTerminal: () => () => {}, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fe2aff1199c7..3046b3295e72 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3521,6 +3521,14 @@ }, "TerminalRestoringPlaceholder": { "restoring": "Restoring terminal…" + }, + "AgentLaunchPaneNotice": { + "commandNotFound": "Its command wasn't found on this machine.", + "copyPrompt": "Copy prompt", + "exitedDuringStart": "It exited while starting.", + "notStarted": "The agent couldn't start.", + "unconfirmed": "Couldn't confirm the agent started.", + "workspaceGone": "Its workspace is no longer available." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cd2f8913930f..a3275123fc40 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3310,6 +3310,14 @@ "TerminalQuickCommandsSubmenu": { "3ccc7981bb": "Anfitrión no disponible", "54f29b7c0d": "Cargando host…" + }, + "AgentLaunchPaneNotice": { + "commandNotFound": "Su comando no se encontró en este equipo.", + "copyPrompt": "Copiar prompt", + "exitedDuringStart": "Se cerró mientras se iniciaba.", + "notStarted": "El agente no pudo iniciarse.", + "unconfirmed": "No se pudo confirmar que el agente se iniciara.", + "workspaceGone": "Su espacio de trabajo ya no está disponible." } } }, diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0ca533b16d70..702b057b41f8 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3413,6 +3413,14 @@ }, "TerminalRestoringPlaceholder": { "restoring": "Restauration du terminal…" + }, + "AgentLaunchPaneNotice": { + "commandNotFound": "Sa commande est introuvable sur cette machine.", + "copyPrompt": "Copier le prompt", + "exitedDuringStart": "Il s'est arrêté pendant le démarrage.", + "notStarted": "L'agent n'a pas pu démarrer.", + "unconfirmed": "Impossible de confirmer que l'agent a démarré.", + "workspaceGone": "Son espace de travail n'est plus disponible." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7abc80e5c0b9..b12ee33b8d95 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3310,6 +3310,14 @@ }, "TerminalRestoringPlaceholder": { "restoring": "ターミナルを復元中…" + }, + "AgentLaunchPaneNotice": { + "commandNotFound": "このマシンでコマンドが見つかりませんでした。", + "copyPrompt": "プロンプトをコピー", + "exitedDuringStart": "起動中に終了しました。", + "notStarted": "Agent を起動できませんでした。", + "unconfirmed": "Agent が起動したか確認できませんでした。", + "workspaceGone": "ワークスペースは利用できなくなりました。" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 73330f8e45fb..41eebfdcd6eb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3310,6 +3310,14 @@ }, "TerminalRestoringPlaceholder": { "restoring": "터미널 복원 중…" + }, + "AgentLaunchPaneNotice": { + "commandNotFound": "이 컴퓨터에서 명령을 찾을 수 없습니다.", + "copyPrompt": "프롬프트 복사", + "exitedDuringStart": "시작하는 중에 종료되었습니다.", + "notStarted": "에이전트를 시작하지 못했습니다.", + "unconfirmed": "에이전트가 시작되었는지 확인하지 못했습니다.", + "workspaceGone": "작업 공간을 더 이상 사용할 수 없습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d141cf70610e..6ecc86d764e4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3310,6 +3310,14 @@ }, "TerminalRestoringPlaceholder": { "restoring": "正在恢复终端…" + }, + "AgentLaunchPaneNotice": { + "commandNotFound": "在此计算机上找不到其命令。", + "copyPrompt": "复制提示", + "exitedDuringStart": "它在启动时退出了。", + "notStarted": "智能体无法启动。", + "unconfirmed": "无法确认智能体已启动。", + "workspaceGone": "其工作区已不可用。" } } }, diff --git a/src/renderer/src/lib/agent-launch-pane-closes.ts b/src/renderer/src/lib/agent-launch-pane-closes.ts new file mode 100644 index 000000000000..84fefe8b1b48 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-closes.ts @@ -0,0 +1,31 @@ +/** + * Launch panes the user closed while their agent was still starting. The host learns of the close + * from its own commit of it; this session's record lets a spawn-time reveal that raced the close + * stop the agent instead of bringing the tab back. A tab merely missing from the window (a reload + * before the session saved it) is never read as a close. Store-free, so the store's close path can + * note it without an import cycle. + */ + +const MAX_REMEMBERED_CLOSES = 32 +const closedPaneKeys = new Set() + +// Not `makePaneKey`: that validates the leaf id, and a reveal must never fail over bookkeeping. +function closeKey(tabId: string, leafId: string): string { + return JSON.stringify([tabId, leafId]) +} + +export function noteAgentLaunchPaneClosedByUser(tabId: string, leafId: string): void { + const paneKey = closeKey(tabId, leafId) + closedPaneKeys.delete(paneKey) + closedPaneKeys.add(paneKey) + for (const oldest of closedPaneKeys) { + if (closedPaneKeys.size <= MAX_REMEMBERED_CLOSES) { + break + } + closedPaneKeys.delete(oldest) + } +} + +export function wasAgentLaunchPaneClosedByUser(tabId: string, leafId: string): boolean { + return closedPaneKeys.has(closeKey(tabId, leafId)) +} diff --git a/src/renderer/src/lib/agent-launch-pane-prompt.ts b/src/renderer/src/lib/agent-launch-pane-prompt.ts new file mode 100644 index 000000000000..875b17b6133d --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-prompt.ts @@ -0,0 +1,28 @@ +/** + * The prompt of a launch whose tab this window showed, so its pane can offer to copy it if the + * agent did not start. Held for this session only: the prompt is the caller's, and after a restart + * the pane still says what happened, without the copy. + */ + +const MAX_REMEMBERED_PROMPTS = 32 +const promptsByTabId = new Map() + +export function rememberAgentLaunchPanePrompt(tabId: string, prompt: string): void { + promptsByTabId.delete(tabId) + promptsByTabId.set(tabId, prompt) + for (const oldest of promptsByTabId.keys()) { + if (promptsByTabId.size <= MAX_REMEMBERED_PROMPTS) { + break + } + promptsByTabId.delete(oldest) + } +} + +export function agentLaunchPanePrompt(tabId: string): string | null { + return promptsByTabId.get(tabId) ?? null +} + +/** The pane's launch settled or went away: nothing is left to offer. */ +export function forgetAgentLaunchPanePrompt(tabId: string): void { + promptsByTabId.delete(tabId) +} diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts new file mode 100644 index 000000000000..5e65a5b0a00f --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { AgentLaunchPaneVerdict } from '../../../shared/agent-launch-pane-verdict' +import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) +})) +const testStore = vi.hoisted(() => { + const ref: { current: ReturnType | null } = { current: null } + return ref +}) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!testStore.current) { + throw new Error('no test store') + } + return testStore.current.getState() + } + } +})) +const applyClosedTerminalLeafNotice = vi.hoisted(() => vi.fn()) +vi.mock('@/components/terminal-pane/closed-terminal-leaf-notice', () => ({ + applyClosedTerminalLeafNotice +})) + +createTabsSliceMockApi() + +const { applyAgentLaunchPaneVerdict } = await import('./agent-launch-pane-verdict-application') +const { agentLaunchPanePrompt, rememberAgentLaunchPanePrompt } = + await import('./agent-launch-pane-prompt') +const { wasAgentLaunchPaneClosedByUser } = await import('./agent-launch-pane-closes') + +const WT = 'repo1::/tmp/feature' +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' +let store: ReturnType +let tabId: string + +function launchTab() { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) +} + +function apply(verdict: AgentLaunchPaneVerdict, leafId = LEAF): void { + applyAgentLaunchPaneVerdict({ worktreeId: WT, tabId, leafId, verdict }) +} + +beforeEach(() => { + store = createTestStore() + testStore.current = store + applyClosedTerminalLeafNotice.mockClear() + store.getState().setActiveWorktree(WT) + tabId = store.getState().createTab(WT, undefined, undefined, { + initialLeafId: LEAF, + agentLaunchPane: { leafId: LEAF, operationId: 'op-1' } + }).id + rememberAgentLaunchPanePrompt(tabId, 'fix the build') +}) + +describe("a launch pane's verdict in the window", () => { + it('settled: the tab forgets the launch and its prompt, so a restart reads nothing for it', () => { + apply({ kind: 'proceed' }) + expect(launchTab()?.agentLaunchPane).toBeUndefined() + expect(agentLaunchPanePrompt(tabId)).toBeNull() + }) + + it('final: the tab keeps it for its life', () => { + apply({ kind: 'unconfirmed' }) + expect(launchTab()?.agentLaunchPane).toEqual({ + leafId: LEAF, + operationId: 'op-1', + outcome: { kind: 'unconfirmed' } + }) + expect(agentLaunchPanePrompt(tabId)).toBe('fix the build') + }) + + it('final, for a launch pane the user already closed out of a split: nothing is kept', () => { + store.getState().setTabLayout(tabId, { + root: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' }, + activeLeafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d', + expandedLeafId: null + }) + apply({ kind: 'not-started', code: 'agent_launch_tab_closed' }) + expect(launchTab()?.agentLaunchPane).toBeUndefined() + expect(agentLaunchPanePrompt(tabId)).toBeNull() + }) + + it('withdrawn: a tab with only the launch pane goes', () => { + apply({ kind: 'withdrawn' }) + expect(launchTab()).toBeUndefined() + }) + + it('withdrawn: a split the user added stays; only the launch pane closes', () => { + store.getState().setTabLayout(tabId, { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' } + }, + activeLeafId: LEAF, + expandedLeafId: null + }) + apply({ kind: 'withdrawn' }) + expect(launchTab()).toBeDefined() + expect(applyClosedTerminalLeafNotice).toHaveBeenCalledWith(tabId, LEAF) + }) + + it('never touches a pane no launch laid out', () => { + apply({ kind: 'withdrawn' }, '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d') + expect(launchTab()).toBeDefined() + }) +}) + +describe('the user closing a launch tab', () => { + it('is remembered while its agent is starting, so a reveal that raced the close stops it', () => { + store.getState().closeTab(tabId) + expect(wasAgentLaunchPaneClosedByUser(tabId, LEAF)).toBe(true) + }) + + it('is not, for a tab whose launch already has its outcome', () => { + apply({ kind: 'unconfirmed' }) + store.getState().closeTab(tabId) + expect(wasAgentLaunchPaneClosedByUser(tabId, LEAF)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts new file mode 100644 index 000000000000..75b9ebafc7db --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -0,0 +1,54 @@ +/** + * The window's half of a launch pane's verdict: main derives it on the pane's spawn (or when it + * takes a pane back) and sends it typed; the tab keeps what is final and forgets what is settled. + */ + +import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict' +import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice' +import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import { useAppStore } from '@/store' +import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' + +export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { + const { worktreeId, tabId, leafId, verdict } = event + const state = useAppStore.getState() + const tab = state.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId) + // Only a pane its tab says a launch laid out: a verdict never touches anything else. + if (tab?.agentLaunchPane?.leafId !== leafId) { + return + } + switch (verdict.kind) { + case 'proceed': + // Settled: the agent attached, or the pane is an ordinary terminal again. + forgetAgentLaunchPanePrompt(tabId) + state.setTabAgentLaunchPane(tabId, undefined) + return + case 'not-started': + case 'unconfirmed': { + const root = state.terminalLayoutsByTabId[tabId]?.root + if (root && !collectLeafIdsInOrder(root).includes(leafId)) { + // The user closed that pane: nothing is left for the outcome to describe. + forgetAgentLaunchPanePrompt(tabId) + state.setTabAgentLaunchPane(tabId, undefined) + return + } + // Final for this pane, for the tab's life: no later spawn needs the launch record. + state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict }) + return + } + case 'withdrawn': { + forgetAgentLaunchPanePrompt(tabId) + // The pane alone when the user split the tab while it waited; their split stays. + if (state.terminalLayoutsByTabId[tabId]?.root?.type === 'leaf') { + state.closeTab(tabId, { + reason: 'cleanup', + recordInteraction: false, + captureRecentlyClosed: false + }) + } else { + state.setTabAgentLaunchPane(tabId, undefined) + applyClosedTerminalLeafNotice(tabId, leafId) + } + } + } +} diff --git a/src/renderer/src/lib/agent-launch-tab-publication.test.ts b/src/renderer/src/lib/agent-launch-tab-publication.test.ts new file mode 100644 index 000000000000..183c4d95eb06 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type { AgentLaunchTabPublishRequest } from '../../../shared/agent-launch-tab-publication' +import { createTabsSliceMockApi } from '../store/slices/tabs-slice-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) +})) + +const testStore = vi.hoisted(() => { + const ref: { current: ReturnType | null } = { current: null } + return ref +}) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => { + if (!testStore.current) { + throw new Error('no test store') + } + return testStore.current.getState() + } + } +})) +const focusTerminalInitiatedTab = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks/ipc-events/terminal-command-state', async (importOriginal) => ({ + ...(await importOriginal()), + focusTerminalInitiatedTab +})) + +createTabsSliceMockApi() + +const { publishAgentLaunchTab } = await import('./agent-launch-tab-publication') +const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') + +const WT = 'repo1::/tmp/feature' +const OTHER_WT = 'repo1::/tmp/other' +const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +let store: ReturnType + +function request( + overrides: Partial = {} +): AgentLaunchTabPublishRequest { + return { + requestId: 'request-1', + worktreeId: WT, + tabId: TAB_ID, + leafId: LEAF_ID, + launchAgent: 'claude', + viewMode: 'terminal', + viewer: 'none', + ...overrides + } +} + +function launchTab() { + return store.getState().unifiedTabsByWorktree[WT]?.find((tab) => tab.entityId === TAB_ID) +} + +beforeEach(() => { + store = createTestStore() + testStore.current = store + focusTerminalInitiatedTab.mockClear() + store.getState().setActiveWorktree(WT) +}) + +describe('publishing a launch tab before its agent exists', () => { + it('creates the tab under the host ids with its one pane laid out and no process', () => { + const published = publishAgentLaunchTab(request({ viewMode: 'chat' })) + + expect(published.created).toBe(true) + expect(published.tabId).toBe(TAB_ID) + const terminal = store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID) + // The tab remembers which pane a launch laid out, so after a restart that pane reads the record. + expect(terminal).toMatchObject({ + ptyId: null, + launchAgent: 'claude', + agentLaunchPane: { leafId: LEAF_ID } + }) + expect(store.getState().terminalLayoutsByTabId[TAB_ID]?.root).toEqual({ + type: 'leaf', + leafId: LEAF_ID + }) + expect(launchTab()?.viewMode).toBe('chat') + }) + + it('keeps the prompt for the pane to offer if its agent does not start', () => { + publishAgentLaunchTab(request({ prompt: 'fix the build' })) + expect(agentLaunchPanePrompt(TAB_ID)).toBe('fix the build') + }) + + it('never makes a second tab when a retry names the same one', () => { + publishAgentLaunchTab(request()) + const again = publishAgentLaunchTab(request({ requestId: 'request-2' })) + + expect(again).toMatchObject({ tabId: TAB_ID, created: false }) + expect(store.getState().tabsByWorktree[WT]?.filter((tab) => tab.id === TAB_ID)).toHaveLength(1) + }) + + it("keeps a launch's final outcome on a retry of that launch; a different launch resets it", () => { + const launchPane = () => + store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.agentLaunchPane + publishAgentLaunchTab(request({ operationId: 'op-1' })) + store.getState().setTabAgentLaunchPane(TAB_ID, { + leafId: LEAF_ID, + operationId: 'op-1', + outcome: { kind: 'unconfirmed' } + }) + + publishAgentLaunchTab(request({ requestId: 'request-2', operationId: 'op-1' })) + expect(launchPane()).toEqual({ + leafId: LEAF_ID, + operationId: 'op-1', + outcome: { kind: 'unconfirmed' } + }) + + publishAgentLaunchTab(request({ requestId: 'request-3', operationId: 'op-2' })) + expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-2' }) + }) + + it("remounts a pane that showed an earlier launch's outcome, so it spawns for the new launch", () => { + const generation = () => + store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.generation ?? 0 + publishAgentLaunchTab(request({ operationId: 'op-1' })) + // Its spawn was refused ("couldn't confirm"): the pane is idle until it remounts. + store.getState().setTabAgentLaunchPane(TAB_ID, { + leafId: LEAF_ID, + operationId: 'op-1', + outcome: { kind: 'unconfirmed' } + }) + const before = generation() + + publishAgentLaunchTab(request({ requestId: 'request-2', operationId: 'op-1' })) + expect(generation()).toBe(before) + + publishAgentLaunchTab(request({ requestId: 'request-3', operationId: 'op-2' })) + expect(generation()).toBe(before + 1) + // A pane still waiting on a launch is spawning already: another launch does not remount it. + publishAgentLaunchTab(request({ requestId: 'request-4', operationId: 'op-3' })) + expect(generation()).toBe(before + 1) + }) + + it("refuses a tab id another workspace already uses rather than minting one the agent won't find", () => { + store.getState().createTab(OTHER_WT, undefined, undefined, { id: TAB_ID }) + + expect(() => publishAgentLaunchTab(request())).toThrow('agent_launch_tab_id_taken') + expect(store.getState().tabsByWorktree[WT] ?? []).toEqual([]) + }) +}) + +describe('placement', () => { + function twoGroups() { + const anchor = store.getState().createUnifiedTab(WT, 'terminal') + const sourceGroupId = store.getState().groupsByWorktree[WT]![0]!.id + const splitGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')! + return { anchor, sourceGroupId, splitGroupId } + } + + it('lands in the requested group', () => { + const { sourceGroupId, splitGroupId } = twoGroups() + store.getState().focusGroup(WT, sourceGroupId) + + const published = publishAgentLaunchTab(request({ placement: { groupId: splitGroupId } })) + + expect(published.placement).toEqual({ groupId: splitGroupId }) + expect(launchTab()?.groupId).toBe(splitGroupId) + }) + + // Activating a workspace with a live tab prunes its empty groups; a CLI's focused launch activates + // it, and the split it asked for must still be there to receive the tab. + it.each( + (['none', 'focus-window', 'focus-in-workspace', 'reveal-owner'] as const).flatMap((viewer) => [ + { viewer, focused: 'split' as const }, + { viewer, focused: 'source' as const } + ]) + )( + 'lands in a just-created empty split beside a live terminal for a $viewer caller ($focused focused)', + ({ viewer, focused }) => { + store.getState().createTab(WT) + const sourceGroupId = store.getState().groupsByWorktree[WT]![0]!.id + const splitGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')! + store.getState().focusGroup(WT, focused === 'split' ? splitGroupId : sourceGroupId) + + const published = publishAgentLaunchTab( + request({ viewer, placement: { groupId: splitGroupId } }) + ) + + expect(published.placement).toEqual({ groupId: splitGroupId }) + expect(launchTab()?.groupId).toBe(splitGroupId) + expect(store.getState().groupsByWorktree[WT]?.map((group) => group.id)).toContain( + splitGroupId + ) + } + ) + + it("falls back to the anchor's group when the requested group is gone", () => { + const { anchor, sourceGroupId } = twoGroups() + + const published = publishAgentLaunchTab( + request({ placement: { groupId: 'group-closed', afterTabId: anchor.id } }) + ) + + expect(published.placement).toEqual({ groupId: sourceGroupId, fallback: 'anchor-group' }) + const order = store + .getState() + .groupsByWorktree[WT]!.find((g) => g.id === sourceGroupId)!.tabOrder + expect(order.indexOf(launchTab()!.id)).toBe(order.indexOf(anchor.id) + 1) + }) + + it('then to the active group, and the launch still gets its tab', () => { + const { splitGroupId } = twoGroups() + + const published = publishAgentLaunchTab(request({ placement: { groupId: 'group-closed' } })) + + expect(published.placement).toEqual({ groupId: splitGroupId, fallback: 'active-group' }) + expect(launchTab()?.groupId).toBe(splitGroupId) + }) +}) + +describe('a focused launch into a workspace with no live terminal', () => { + it('is the agent starting, not a wake: its first bind moves the workspace up in Recent', () => { + store.getState().setActiveWorktree(OTHER_WT) + publishAgentLaunchTab(request({ viewer: 'focus-window' })) + const tab = store.getState().tabsByWorktree[WT]?.find((candidate) => candidate.id === TAB_ID) + expect(tab?.pendingActivationSpawn).toBeUndefined() + expect(tab?.generation).toBeUndefined() + + const epoch = store.getState().sortEpoch + store.getState().updateTabPtyId(TAB_ID, 'pty-agent-1') + + expect(store.getState().sortEpoch).toBe(epoch + 1) + }) +}) + +describe('whose view moves', () => { + it('none: the window stays where it is', () => { + const before = store.getState().activeTabIdByWorktree[WT] + store.getState().setActiveWorktree(OTHER_WT) + + publishAgentLaunchTab(request({ viewer: 'none' })) + + expect(store.getState().activeWorktreeId).toBe(OTHER_WT) + expect(focusTerminalInitiatedTab).not.toHaveBeenCalled() + expect(store.getState().activeTabIdByWorktree[WT] ?? null).toBe(before ?? TAB_ID) + }) + + it('focus-window: the window goes to the workspace and the tab', () => { + store.getState().setActiveWorktree(OTHER_WT) + + publishAgentLaunchTab(request({ viewer: 'focus-window' })) + + expect(store.getState().activeWorktreeId).toBe(WT) + expect(store.getState().activeTabId).toBe(TAB_ID) + expect(focusTerminalInitiatedTab).toHaveBeenCalledWith(TAB_ID, LEAF_ID, WT) + }) + + it('focus-in-workspace: the desktop stays put when you have moved to another workspace', () => { + store.getState().setActiveWorktree(OTHER_WT) + + publishAgentLaunchTab(request({ viewer: 'focus-in-workspace' })) + + expect(store.getState().activeWorktreeId).toBe(OTHER_WT) + expect(store.getState().activeTabIdByWorktree[WT]).toBe(TAB_ID) + expect(focusTerminalInitiatedTab).not.toHaveBeenCalled() + }) + + it('focus-in-workspace: the tab is focused when you are still there', () => { + publishAgentLaunchTab(request({ viewer: 'focus-in-workspace' })) + + expect(store.getState().activeWorktreeId).toBe(WT) + expect(store.getState().activeTabId).toBe(TAB_ID) + expect(focusTerminalInitiatedTab).toHaveBeenCalledWith(TAB_ID, LEAF_ID, WT) + }) +}) diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts new file mode 100644 index 000000000000..ac678d981a85 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -0,0 +1,148 @@ +/** + * Showing an `agent.launch` tab the host asked for before its agent exists. + * + * The tab is created under the host's ids so the pane key the host bakes into the agent's PTY names + * this pane, and a retry that names the same tab finds it instead of making a second one. The pane + * is laid out with no process; its own spawn waits in main for the host's and attaches to it. + */ + +import type { AgentLaunchPlacementReceipt } from '../../../shared/agent-launch-intent' +import type { + AgentLaunchTabPublished, + AgentLaunchTabPublishRequest +} from '../../../shared/agent-launch-tab-publication' +import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' +import { + activateTerminalInitiatedWorktree, + focusTerminalInitiatedTab +} from '../hooks/ipc-events/terminal-command-state' +import { useAppStore } from '../store' +import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement' +import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' +import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' + +function landedGroupId(worktreeId: string, tabId: string): string | undefined { + return useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === tabId)?.groupId +} + +function placementFallback( + request: AgentLaunchTabPublishRequest, + landed: string, + anchored: boolean +): AgentLaunchPlacementReceipt['fallback'] { + const { groupId, afterTabId } = request.placement ?? {} + if (groupId !== undefined) { + return landed === groupId ? undefined : anchored ? 'anchor-group' : 'active-group' + } + return afterTabId !== undefined && !anchored ? 'active-group' : undefined +} + +function launchPaneFor(request: AgentLaunchTabPublishRequest): { + leafId: string + operationId?: string +} { + return { + leafId: request.leafId, + ...(request.operationId ? { operationId: request.operationId } : {}) + } +} + +export function publishAgentLaunchTab( + request: AgentLaunchTabPublishRequest +): AgentLaunchTabPublished { + const { worktreeId, tabId, leafId } = request + const store = useAppStore.getState() + const owner = Object.entries(store.tabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === tabId) + )?.[0] + if (owner !== undefined) { + // A retry naming the same tab: never a second one, and never another workspace's tab. + if (owner !== worktreeId) { + throw new Error('agent_launch_tab_id_taken') + } + const groupId = landedGroupId(worktreeId, tabId) + if (!groupId) { + throw new Error('agent_launch_tab_unplaced') + } + if (request.prompt) { + rememberAgentLaunchPanePrompt(tabId, request.prompt) + } + // A different launch into this pane: what an earlier one left on it no longer stands. A retry of + // the same launch is that launch, so its tab and any final verdict stay as they are. + const kept = store.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)?.agentLaunchPane + if (kept?.leafId !== leafId || kept.operationId !== request.operationId) { + // A pane that already showed an earlier launch's outcome refused its spawn and is idle: it + // must spawn again, so it waits for this launch and attaches to its agent. + store.setTabAgentLaunchPane(tabId, launchPaneFor(request), { + remount: kept?.leafId === leafId && kept.outcome !== undefined + }) + } + return { tabId, created: false, placement: { groupId } } + } + + const placement = resolveUnifiedTabCreatePlacement({ + groups: store.groupsByWorktree[worktreeId] ?? [], + tabs: store.unifiedTabsByWorktree[worktreeId] ?? [], + activeGroupId: store.activeGroupIdByWorktree[worktreeId], + targetGroupId: request.placement?.groupId, + afterTabId: request.placement?.afterTabId, + lookupWorktrees: () => + [ + ...(store.allWorktrees?.() ?? []), + ...(store.folderWorkspaces ?? []).map(folderWorkspaceToWorktree) + ].filter((worktree) => worktree.id === worktreeId) + }) + const focuses = request.viewer === 'focus-window' || request.viewer === 'focus-in-workspace' + const tab = store.createTab(worktreeId, placement.groupId, undefined, { + id: tabId, + initialLeafId: leafId, + agentLaunchPane: launchPaneFor(request), + launchAgent: request.launchAgent, + viewMode: request.viewMode, + ...(focuses ? {} : { activate: false, recordInteraction: false }) + }) + if (request.prompt) { + rememberAgentLaunchPanePrompt(tabId, request.prompt) + } + if (tab.id !== tabId) { + // createTab mints a fresh id on a collision it saw and we did not; that tab would never attach. + useAppStore.getState().closeTab(tab.id, { recordInteraction: false }) + throw new Error('agent_launch_tab_id_taken') + } + if (placement.anchorTabId) { + const unifiedTabId = useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((item) => item.entityId === tab.id)?.id + if (unifiedTabId) { + insertUnifiedTabAfterAnchor(worktreeId, unifiedTabId, placement.anchorTabId) + } + } + if (request.viewer === 'focus-window') { + // After the tab lands: activating prunes the workspace's empty groups, the requested split too. + activateTerminalInitiatedWorktree(useAppStore.getState(), worktreeId, [tab.id]) + } + const current = useAppStore.getState() + // The desktop's own launch stays in its workspace: the window follows only if it is still there. + const windowShowsTab = + request.viewer === 'focus-window' || + (request.viewer === 'focus-in-workspace' && current.activeWorktreeId === worktreeId) + if (windowShowsTab) { + current.setActiveTabType('terminal', worktreeId) + current.setActiveTab(tab.id) + } + if (windowShowsTab || request.viewer === 'reveal-owner') { + if (request.viewer !== 'focus-in-workspace') { + current.revealWorktreeInSidebar(worktreeId) + } + focusTerminalInitiatedTab(tab.id, leafId, worktreeId) + } + const landed = landedGroupId(worktreeId, tab.id) ?? placement.groupId ?? '' + const fallback = placementFallback(request, landed, placement.anchorTabId !== undefined) + return { + tabId: tab.id, + created: true, + placement: { groupId: landed, ...(fallback ? { fallback } : {}) } + } +} diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index 8f89ecee0381..abc631dc6fa7 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -1,73 +1,6 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' -import { agentTabsDefaultToNativeChat } from '../../../shared/structured-native-chat-launch-route' -import type { Tab } from '../../../shared/tab-types' -import type { TuiAgent } from '../../../shared/tui-agent' -import { canMirrorLaunchDraftToNativeChat } from '@/lib/native-chat-launch-draft-mirrorability' -import { - isNativeChatSupportedAgent, - nativeChatRequiresLocalTranscript -} from '@/lib/native-chat-supported-agent' - -export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' - -/** - * Decide the initial `viewMode` for a newly launched agent tab from the - * opt-in `openAgentTabsInChatByDefault` setting. - * - * Returns `'chat'` only when the setting is explicitly on and the launched - * agent has a native-chat renderer. A draft launch opens in chat only when its - * unsent context can be mirrored into the composer — gated on the same - * predicate as seeding so the view never opens empty beside a filled TUI input. - */ -export function decideInitialAgentTabViewMode(args: { - experimentalNativeChat?: boolean - openAgentTabsInChatByDefault?: boolean - agent?: TuiAgent | null - promptDelivery?: NativeChatLaunchPromptDelivery - /** The unsent launch context, when `promptDelivery` is `'draft'`. */ - launchDraftText?: string - nativeChatTranscriptIsLocalReadable?: boolean -}): Tab['viewMode'] { - if (!agentTabsDefaultToNativeChat(args)) { - return undefined - } - if (!isNativeChatSupportedAgent(args.agent)) { - return undefined - } - if ( - nativeChatRequiresLocalTranscript(args.agent) && - args.nativeChatTranscriptIsLocalReadable !== true - ) { - return undefined - } - if ( - args.promptDelivery === 'draft' && - !canMirrorLaunchDraftToNativeChat(args.launchDraftText ?? '') - ) { - return undefined - } - return 'chat' -} - -export function initialAgentTabViewModeProps( - settings: - | Pick - | null - | undefined, - options: { - agent?: TuiAgent | null - promptDelivery?: NativeChatLaunchPromptDelivery - launchDraftText?: string - nativeChatTranscriptIsLocalReadable?: boolean - } = {} -): { viewMode?: Tab['viewMode'] } { - const viewMode = decideInitialAgentTabViewMode({ - experimentalNativeChat: settings?.experimentalNativeChat, - openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, - agent: options.agent, - promptDelivery: options.promptDelivery, - launchDraftText: options.launchDraftText, - nativeChatTranscriptIsLocalReadable: options.nativeChatTranscriptIsLocalReadable - }) - return viewMode ? { viewMode } : {} -} +// The host derives an agent.launch tab's view by this same rule, so it lives in shared. +export { + decideInitialAgentTabViewMode, + initialAgentTabViewModeProps, + type NativeChatLaunchPromptDelivery +} from '../../../shared/native-chat-initial-view-mode' diff --git a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts index 23e7395c8538..006a873b1cad 100644 --- a/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts +++ b/src/renderer/src/lib/native-chat-launch-draft-mirrorability.ts @@ -1,24 +1 @@ -import { - AGENT_TUI_CLEAR_MAX_LINES, - countAgentTuiInputLines -} from '../../../shared/agent-tui-input-clear' - -/** - * Single source of truth for whether unsent launch context can be mirrored from - * the agent's TUI input into the native-chat composer. - * - * Both the seeding path (`seedNativeChatLaunchDraftForAgentTab`) and the - * initial-view-mode decision (`decideInitialAgentTabViewMode`) gate on this one - * predicate, so a draft launch can never open in chat with a composer that - * chat then refuses to fill. - * - * CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line - * separators and drafts beyond that budget remain terminal-only. - */ -export function canMirrorLaunchDraftToNativeChat(text: string): boolean { - return ( - text.trim().length > 0 && - !/[\u2028\u2029]/.test(text) && - countAgentTuiInputLines(text) <= AGENT_TUI_CLEAR_MAX_LINES - ) -} +export { canMirrorLaunchDraftToNativeChat } from '../../../shared/native-chat-launch-draft-mirrorability' diff --git a/src/renderer/src/lib/native-chat-transcript-readability.ts b/src/renderer/src/lib/native-chat-transcript-readability.ts index 165b2a36a1a4..046e0a2e086c 100644 --- a/src/renderer/src/lib/native-chat-transcript-readability.ts +++ b/src/renderer/src/lib/native-chat-transcript-readability.ts @@ -1,8 +1 @@ -import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' - -/** Model-A SSH stores Grok transcripts on a host this renderer cannot read. */ -export function isNativeChatTranscriptLocalReadable( - connectionId: string | null | undefined -): boolean { - return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) -} +export { isNativeChatTranscriptLocalReadable } from '../../../shared/native-chat-transcript-readability' diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1da1c4845092..5b2137120342 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -5,6 +5,7 @@ import type { TerminalSlice } from '../terminals/terminal-state' export type { TerminalSlice } from '../terminals/terminal-state' import { createTerminalEphemeralActions } from '../terminals/terminal-ephemeral-state' import { createTerminalTabCreationActions } from '../terminals/terminal-tab-creation' +import { createTerminalTabAgentLaunchPaneActions } from '../terminals/terminal-tab-agent-launch-pane' import { createActiveWorkspaceTerminalActions } from '../terminals/terminal-active-workspace-creation' import { createTerminalTabCloseActions } from '../terminals/terminal-tab-close' import { createTerminalTabNavigationActions } from '../terminals/terminal-tab-navigation' @@ -78,6 +79,7 @@ export const createTerminalSlice: StateCreator recentQuickCommandIdByGroup: {}, ...createTerminalEphemeralActions(set, get), ...createTerminalTabCreationActions(set, get), + ...createTerminalTabAgentLaunchPaneActions(set), ...createActiveWorkspaceTerminalActions(set, get), ...createTerminalTabCloseActions(set, get), ...createTerminalTabNavigationActions(set, get), diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 3d24fe69cd64..f3027919befd 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -312,7 +312,11 @@ export type WorktreeSlice = { setActiveWorktree: ( worktreeId: string | null, executionHostId?: ExecutionHostId, - options?: { stateTransition?: ActiveWorktreeStateTransition } + options?: { + stateTransition?: ActiveWorktreeStateTransition + /** Tabs the caller just created there: their first spawn is new work, not a wake. */ + createdTabIds?: readonly string[] + } ) => boolean /** * Health-driven remount of one terminal tab: bumps the tab's generation so diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 84c85c45a3c4..e4536b715079 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -36,6 +36,9 @@ export function createSetActiveWorktree( ): WorktreeSlice['setActiveWorktree'] { return (worktreeId, executionHostId, options) => { const stateTransition = options?.stateTransition?.(get()) + const createdTabIds = new Set(options?.createdTabIds) + // A tab its caller just created is not asleep: its first bind is new work, not a wake. + const isWakeable = (tab: { id: string }): boolean => !createdTabIds.has(tab.id) if (stateTransition && !stateTransition.activate) { if (Object.keys(stateTransition.patch).length > 0) { set(stateTransition.patch) @@ -102,15 +105,16 @@ export function createSetActiveWorktree( // Tag every tab on FIRST activation so reattach/fresh-spawn updateTabPtyId suppresses activity + sortEpoch bumps. // Generation is only bumped when no tab has a live PTY — a live remount would kill the user's shell. const tabs = s.tabsByWorktree[worktreeId ?? ''] ?? [] + const wakeable = tabs.filter(isWakeable) const allDead = worktreeId != null && - tabs.length > 0 && - tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) + wakeable.length > 0 && + wakeable.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) const isFirstActivation = worktreeId != null && !s.everActivatedWorktreeIds.has(worktreeId) - const shouldTagTabs = worktreeId != null && tabs.length > 0 && isFirstActivation + const shouldTagTabs = worktreeId != null && wakeable.length > 0 && isFirstActivation // Why: bump generation in the same set() as activation so a dead-transport pane can't go visible-but-dead before remount. shouldPrepareTerminalTabs = Boolean( - worktreeId && tabs.length > 0 && shouldTagTabs && !allDead + worktreeId && wakeable.length > 0 && shouldTagTabs && !allDead ) shouldTagTerminalTabs = shouldTagTabs const nextEverActivated = isFirstActivation @@ -141,13 +145,17 @@ export function createSetActiveWorktree( ? { tabsByWorktree: { ...s.tabsByWorktree, - [worktreeId]: tabs.map((tab) => ({ - ...tab, - generation: (tab.generation ?? 0) + 1, - pendingActivationSpawn: getTerminalActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - })) + [worktreeId]: tabs.map((tab) => + !isWakeable(tab) + ? tab + : { + ...tab, + generation: (tab.generation ?? 0) + 1, + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) + } + ) } } : {} @@ -220,24 +228,29 @@ export function createSetActiveWorktree( return s } const tabs = s.tabsByWorktree[worktreeId] ?? [] - if (tabs.length === 0) { + const wakeable = tabs.filter(isWakeable) + if (wakeable.length === 0) { return s } - const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) + const allDead = wakeable.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) if (!allDead && !shouldTagTerminalTabs) { return s } return { tabsByWorktree: { ...s.tabsByWorktree, - [worktreeId]: tabs.map((tab) => ({ - ...tab, - ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), - // Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent. - pendingActivationSpawn: getTerminalActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - })) + [worktreeId]: tabs.map((tab) => + !isWakeable(tab) + ? tab + : { + ...tab, + ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), + // Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent. + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) + } + ) } } }) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 601b3a009d3c..4a30b52a2d79 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -69,7 +69,7 @@ export type TerminalActions = { options?: { pendingActivationSpawn?: boolean initialPtyId?: string - /** Stable leaf identity for adopting an already-live pane without changing its pane key. */ + /** Stable leaf identity: an already-live pane's, or a host launch's pane before its process. */ initialLeafId?: string /** Published atomically with the tab so its first mount cannot spawn a bare shell. */ pendingStartup?: TerminalState['pendingStartupByTabId'][string] @@ -79,6 +79,8 @@ export type TerminalActions = { recordInteraction?: boolean id?: string launchAgent?: TuiAgent + /** The pane a host `agent.launch` laid out before its agent existed, while its fate is open. */ + agentLaunchPane?: TerminalTab['agentLaunchPane'] quickCommandLabel?: string | null viewMode?: Tab['viewMode'] startupCwd?: string @@ -131,6 +133,13 @@ export type TerminalActions = { } ) => void setTabColor: (tabId: string, color: string | null) => void + /** What the tab keeps about the launch that laid out one of its panes; undefined clears it. + * `remount` remounts the tab's panes, so one whose spawn was refused spawns again. */ + setTabAgentLaunchPane: ( + tabId: string, + launchPane: TerminalTab['agentLaunchPane'], + options?: { remount?: boolean } + ) => void /** Binds only live tabs and migrates replacement identity state before publishing ownership. */ updateTabPtyId: ( tabId: string, diff --git a/src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts b/src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts new file mode 100644 index 000000000000..03c721c8875f --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-tab-agent-launch-pane.ts @@ -0,0 +1,32 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalSlice, TerminalStoreSet } from './terminal-state' + +export function createTerminalTabAgentLaunchPaneActions( + set: TerminalStoreSet +): Pick { + return { + setTabAgentLaunchPane: (tabId, launchPane, options) => { + set((s) => { + const next = { ...s.tabsByWorktree } + for (const wId of Object.keys(next)) { + if (!next[wId].some((t) => t.id === tabId)) { + continue + } + next[wId] = next[wId].map((t): TerminalTab => { + if (t.id !== tabId) { + return t + } + const { agentLaunchPane: _previous, ...rest } = t + void _previous + // The proven remount seam: live PTYs detach and reattach; a refused pane spawns afresh. + const remounted = options?.remount + ? { ...rest, generation: (t.generation ?? 0) + 1 } + : rest + return launchPane ? { ...remounted, agentLaunchPane: launchPane } : remounted + }) + } + return { tabsByWorktree: next } + }) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 254a8c51c4e7..198a2e114730 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -18,6 +18,7 @@ import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { removePaneKeysByTabPrefix } from '../slices/agent-status-pane-keyed-records' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' +import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' export function createTerminalTabCloseActions( set: TerminalStoreSet, @@ -59,6 +60,14 @@ export function createTerminalTabCloseActions( const closing = before.find((t) => t.id === tabId) if (closing) { closingWorktreeId = wId + // The user closing a launch tab whose agent is still starting: that close wins. + if ( + closeReason === 'user' && + closing.agentLaunchPane && + !closing.agentLaunchPane.outcome + ) { + noteAgentLaunchPaneClosedByUser(tabId, closing.agentLaunchPane.leafId) + } // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). if (!closedTab) { closedTab = closing diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index cbe42c62ee0f..3aa54f3550fc 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -87,8 +87,9 @@ export function createTerminalTabCreationActions( ? options.initialLeafId : undefined // Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation. + // Why a bare leaf id too: a host launch lays out its pane before the process it attaches to exists. const initialLeafId = - options?.initialPtyId || options?.pendingStartup + options?.initialPtyId || options?.pendingStartup || requestedInitialLeafId ? (requestedInitialLeafId ?? createBrowserUuid()) : undefined const shouldActivate = options?.activate !== false @@ -131,6 +132,7 @@ export function createTerminalTabCreationActions( ...(startupCwd && startupCwd.length > 0 ? { startupCwd } : {}), ...(options?.forceHostRuntime ? { forceHostRuntime: true } : {}), ...(options?.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options?.agentLaunchPane ? { agentLaunchPane: options.agentLaunchPane } : {}), // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) } diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 6672649c9a64..b015e1647b4e 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -246,6 +246,9 @@ export function createWebUiApi(): NonNullable['ui']> { onRequestTerminalCreate: () => noopUnsubscribe, onRequestTerminalTabMount: () => noopUnsubscribe, replyTerminalCreate: () => {}, + onPublishAgentLaunchTab: () => noopUnsubscribe, + onAgentLaunchPaneVerdict: () => noopUnsubscribe, + replyAgentLaunchTabPublish: () => {}, onSplitTerminal: () => noopUnsubscribe, onRenameTerminal: () => noopUnsubscribe, onFocusTerminal: () => noopUnsubscribe, diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index c809b96b63a0..39ab0ce46e19 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -45,6 +45,9 @@ export type AgentLaunchTarget = /** The workspace root the host resolved for that selector. Host-set, never accepted from a * caller: it decides whether a requested `cwd` names the root or somewhere else. */ workspacePath?: string + /** The workspace's SSH connection, `null` when local. Host-set, like `workspacePath`: it + * decides whether the window can read the agent's transcript, so which view the tab opens in. */ + connectionId?: string | null } /** A worktree this launch creates. `create` is the `worktree.create` request minus its agent * fields — the launch owns those, so a caller cannot set a startup agent behind the router. */ @@ -98,6 +101,23 @@ export type AgentLaunchIntent = { sessionId?: string } +/** + * Where in the workspace's tab layout a new tab goes: a group, the tab it follows (a host tab id; a + * terminal's tab is the tab half of its pane key), or both. Never fails a launch: a group that is + * gone falls back to the anchor's group, then to the active one. + */ +export type AgentLaunchPlacement = { groupId?: string; afterTabId?: string } + +/** Whether the caller's own view moves to the new tab. Never names another viewer's screen. */ +export type AgentLaunchPresentation = 'focused' | 'background' + +/** Where the tab landed, as the window that owns the layout reported it. */ +export type AgentLaunchPlacementReceipt = { + groupId: string + /** Present when the requested group was not used: the anchor tab's group, or the active one. */ + fallback?: 'anchor-group' | 'active-group' +} + /** The surface the host actually created. */ export type AgentLaunchOutcome = | { @@ -185,6 +205,9 @@ export type AgentLaunchResult = { /** Why the outcome is what it is — always populated, so a downgrade is never silent. */ receipt: AgentLaunchModeReceipt prompt?: AgentLaunchPromptReceipt + /** Absent when no window placed the tab: an older host, no requested placement, or a host with no + * window owning the layout. */ + placement?: AgentLaunchPlacementReceipt } export type AgentLaunchMode = 'structured' | 'terminal' @@ -235,7 +258,21 @@ export function isAgentLaunchResult(value: unknown): value is AgentLaunchResult typeof result.worktreeId === 'string' && isAgentLaunchModeReceipt(result.receipt) && (result.warning === undefined || typeof result.warning === 'string') && - (result.prompt === undefined || isAgentLaunchPromptReceipt(result.prompt)) + (result.prompt === undefined || isAgentLaunchPromptReceipt(result.prompt)) && + (result.placement === undefined || isAgentLaunchPlacementReceipt(result.placement)) + ) +} + +function isAgentLaunchPlacementReceipt(value: unknown): value is AgentLaunchPlacementReceipt { + if (typeof value !== 'object' || value === null) { + return false + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: narrowing an unknown for field-by-field validation; every field read below is checked before use. + const placement = value as Partial + // A fallback word this build does not know still reads: placement is a report, not a gate. + return ( + typeof placement.groupId === 'string' && + (placement.fallback === undefined || typeof placement.fallback === 'string') ) } diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index d1a19d0d889e..fcd4b191172b 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -54,6 +54,9 @@ export type AgentLaunchFingerprintInput = { * its own original. That is the rule the mutable host settings above are excluded under — the * digest covers what the call DOES — and the cost of leaving it out is only that a replay reports * the first attempt's attribution, which is the truthful answer: one launch happened. + * + * `placement` and `presentation` are absent by the same rule: they say where the tab sits and whose + * view moves, not what runs, so a retry that moved them replays the first answer. */ } diff --git a/src/shared/agent-launch-pane-verdict.ts b/src/shared/agent-launch-pane-verdict.ts new file mode 100644 index 000000000000..5221e0cf8c1d --- /dev/null +++ b/src/shared/agent-launch-pane-verdict.ts @@ -0,0 +1,51 @@ +/** + * What a pane an `agent.launch` laid out before its agent existed does instead of starting a shell. + * Main derives it on the pane's spawn and sends it to the window as a typed value; the spawn itself + * only fails with `AGENT_LAUNCH_PANE_REFUSED_CODE`, so nothing is parsed out of error text. + */ + +import { z } from 'zod' + +/** The launch's fate for its pane once nothing more can change it; the tab keeps it for its life. */ +export type AgentLaunchPaneOutcome = + /** The launch failed before its agent ran; `code` is the recorded reason, for the window to word. */ + | { kind: 'not-started'; code: string } + /** The launch may have started its agent and nothing can tell. */ + | { kind: 'unconfirmed' } + +export type AgentLaunchPaneVerdict = + /** No launch owns the pane, or its agent runs or ran there: the pane's own spawn decides. */ + | { kind: 'proceed' } + | AgentLaunchPaneOutcome + /** The launch ran elsewhere or never ran; the host takes the pane back. */ + | { kind: 'withdrawn' } + +/** The one error a refused launch pane's spawn fails with; the window shows the verdict instead. */ +export const AGENT_LAUNCH_PANE_REFUSED_CODE = 'agent_launch_pane_refused' + +/** The pane a verdict is about, as the window addresses it. */ +export type AgentLaunchPaneAddress = { worktreeId: string; tabId: string; leafId: string } + +export type AgentLaunchPaneVerdictEvent = AgentLaunchPaneAddress & { + verdict: AgentLaunchPaneVerdict +} + +/** A tab's launch-pane state as persisted; a malformed one is dropped, never the session. */ +export const agentLaunchPaneOnTabSchema = z + .object({ + leafId: z.string(), + operationId: z.string().optional(), + outcome: z.custom(isAgentLaunchPaneOutcome).optional() + }) + .optional() + .catch(undefined) + +export function isAgentLaunchPaneOutcome(value: unknown): value is AgentLaunchPaneOutcome { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + return false + } + return ( + value.kind === 'unconfirmed' || + (value.kind === 'not-started' && 'code' in value && typeof value.code === 'string') + ) +} diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index e4853e84f872..060766828e27 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -34,10 +34,25 @@ export const AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY = export const AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY = 'agent.launch.prompt-unconfirmed.v1' as const +// Host-advertised: reads `placement` and `presentation`, reports the placement in the receipt, and +// may publish the launch's tab before it admits the launch. +export const AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY = 'agent.launch.placement.v1' as const + +// Client-advertised only: the client reads a listed launch tab with no terminal yet as "not +// started", never as proof its agent runs. The host publishes a paired caller's tab before the +// spawn only for a client that says so. +export const AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY = 'agent.launch.unstarted-tab.v1' as const + +// Client-advertised only: the client reads `agent_launch_tab_closed` (the user closed the launch's +// tab while it started, so it was stopped) as a definite answer. Others get +// `agent_session_operation_unknown` for it, as before the host knew. +export const AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY = 'agent.launch.tab-closed.v1' as const + export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ AGENT_LAUNCH_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_RUNTIME_CAPABILITY, AGENT_LAUNCH_REPLAY_REQUIRED_RUNTIME_CAPABILITY, AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY, - AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY + AGENT_LAUNCH_PROMPT_UNCONFIRMED_RUNTIME_CAPABILITY, + AGENT_LAUNCH_PLACEMENT_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/agent-launch-tab-closed.ts b/src/shared/agent-launch-tab-closed.ts new file mode 100644 index 000000000000..be4b1f6fd2e1 --- /dev/null +++ b/src/shared/agent-launch-tab-closed.ts @@ -0,0 +1,11 @@ +export const AGENT_LAUNCH_TAB_CLOSED_CODE = 'agent_launch_tab_closed' as const + +// The user closed the launch's tab while its agent was starting: the launch was stopped, and that +// is its answer. Only a client advertising `agent.launch.tab-closed.v1` is told so; any other reads +// the uncertain answer it always got. +export class AgentLaunchTabClosedError extends Error { + constructor() { + super(AGENT_LAUNCH_TAB_CLOSED_CODE) + this.name = 'AgentLaunchTabClosedError' + } +} diff --git a/src/shared/agent-launch-tab-publication.ts b/src/shared/agent-launch-tab-publication.ts new file mode 100644 index 000000000000..2eb8741988d0 --- /dev/null +++ b/src/shared/agent-launch-tab-publication.ts @@ -0,0 +1,57 @@ +/** + * The host asking the window that owns a workspace's tab layout to show an agent launch's tab before + * the agent exists. The window creates the tab under the host's ids (or finds the one it already + * made under them), lays out its one pane, and replies with where it landed. The pane then waits + * for the host's process; see `agent-launch-pane-attachment`. + */ + +import type { AgentLaunchPlacement, AgentLaunchPlacementReceipt } from './agent-launch-intent' +import type { TuiAgent } from './tui-agent' + +/** + * Whose screen moves, decided on the host from who called. Mirrors the browser-tab split between + * the host's view and a caller's own: a paired device never moves the host window. + * + * - `focus-window`: the window goes to the workspace and the tab (the CLI asked for focus). + * - `focus-in-workspace`: the tab becomes the workspace's active tab; the window switches to it only + * if it is already on that workspace (the desktop asked, then may have moved on). + * - `reveal-owner`: today's unfocused agent.launch reveal (workspace shown in the sidebar). + * - `none`: the tab is created in place; nothing moves. + */ +export type AgentLaunchTabViewerRule = + | 'focus-window' + | 'focus-in-workspace' + | 'reveal-owner' + | 'none' + +export type AgentLaunchTabPublishRequest = { + requestId: string + worktreeId: string + tabId: string + leafId: string + launchAgent: TuiAgent + viewMode: 'terminal' | 'chat' + placement?: AgentLaunchPlacement + viewer: AgentLaunchTabViewerRule + /** The launch's prompt, so a pane whose agent did not start can offer to copy it. */ + prompt?: string + /** Which launch this is: a retry of the same one never resets what the tab keeps about it. */ + operationId?: string +} + +export type AgentLaunchTabPublishReply = + | { + requestId: string + tabId: string + /** False when a tab with this id already existed and was reused: a retry. */ + created: boolean + placement: AgentLaunchPlacementReceipt + } + | { requestId: string; error: string } + +/** The reply as the host reads it. */ +export type AgentLaunchTabPublished = { + tabId: string + created: boolean + placement: AgentLaunchPlacementReceipt +} diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts index ef10fac8f2b4..109f196fd97e 100644 --- a/src/shared/agent-session-operation-ledger.test.ts +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -6,7 +6,10 @@ import { import { agentSessionOperationExpiry, agentSessionOperationKey, + claimAgentSessionOperation, evaluateAgentSessionOperation, + pendingAgentSessionOperationRow, + settleAgentSessionOperation, isAgentSessionOperationRow, pruneAgentSessionOperationRows, type AgentSessionOperationRow @@ -130,6 +133,48 @@ describe('operation admission', () => { }) }) +describe('the pane a launch laid out', () => { + const pane = { worktreeId: 'wt-1', paneKey: 'tab-1:leaf-1' } + + it('is recorded only by the claim that wins, and outlives the settle', () => { + const id = operationId(NOW) + const key = agentSessionOperationKey('caller', id) + const pending = new Map([ + [ + key, + pendingAgentSessionOperationRow({ + callerKey: 'caller', + operationId: id, + fingerprint: 'fp', + now: NOW + }) + ] + ]) + + const won = claimAgentSessionOperation(pending, { + callerKey: 'caller', + operationId: id, + ownedPane: pane + }) + expect(won.rows.get(key)?.ownedPane).toEqual(pane) + const lost = claimAgentSessionOperation(won.rows, { + callerKey: 'caller', + operationId: id, + ownedPane: { worktreeId: 'wt-1', paneKey: 'tab-2:leaf-2' } + }) + expect(lost.claim.claim).toBe('lost') + expect(lost.rows.get(key)?.ownedPane).toEqual(pane) + + const settled = settleAgentSessionOperation(won.rows, { + callerKey: 'caller', + operationId: id, + outcome: { status: 'failed', code: 'boom' } + }) + expect(settled.get(key)?.ownedPane).toEqual(pane) + expect(isAgentSessionOperationRow(settled.get(key))).toBe(true) + }) +}) + describe('retention', () => { it('keeps a tombstone strictly longer than its id can be admitted as new', () => { const expiry = agentSessionOperationExpiry(NOW, NOW) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 136b86af2b77..0467c361dc2b 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -68,6 +68,9 @@ export type AgentSessionOperationOutcome = /** The effect may or may not have happened; replay this answer instead of spawning again. */ | { status: 'unknown' } +/** A terminal pane an operation laid out before its process existed. */ +export type AgentSessionOperationOwnedPane = { worktreeId: string; paneKey: string } + export type AgentSessionOperationRow = { callerKey: string operationId: string @@ -76,6 +79,37 @@ export type AgentSessionOperationRow = { recordedAt: number expiresAt: number outcome: AgentSessionOperationOutcome + /** + * The pane an `agent.launch` showed before its agent existed. Written with the claim, so the pane + * can read its fate off this row — attach, couldn't start, couldn't confirm — across a restart, + * and stops being owned when the row expires. Not checked by `isAgentSessionOperationRow`: a + * malformed value costs that pane its verdict, never the row. + */ + ownedPane?: AgentSessionOperationOwnedPane +} + +/** Unexpired rows naming this pane as theirs. */ +export function listAgentSessionOperationRowsOwningPane( + rows: Iterable, + pane: AgentSessionOperationOwnedPane, + now: number +): AgentSessionOperationRow[] { + const owning: AgentSessionOperationRow[] = [] + for (const row of rows) { + const owned: unknown = row.ownedPane + if ( + row.expiresAt > now && + typeof owned === 'object' && + owned !== null && + 'paneKey' in owned && + 'worktreeId' in owned && + owned.paneKey === pane.paneKey && + owned.worktreeId === pane.worktreeId + ) { + owning.push(row) + } + } + return owning } export type AgentSessionOperationRefusalCode = @@ -164,7 +198,7 @@ export type AgentSessionOperationClaim = */ export function claimAgentSessionOperation( rows: ReadonlyMap, - args: { callerKey: string; operationId: string } + args: { callerKey: string; operationId: string; ownedPane?: AgentSessionOperationOwnedPane } ): { rows: Map; claim: AgentSessionOperationClaim } { const key = agentSessionOperationKey(args.callerKey, args.operationId) const existing = rows.get(key) @@ -174,7 +208,11 @@ export function claimAgentSessionOperation( if (existing.outcome.status !== 'pending') { return { rows: new Map(rows), claim: { claim: 'lost', row: existing } } } - const claimed: AgentSessionOperationRow = { ...existing, outcome: { status: 'unknown' } } + const claimed: AgentSessionOperationRow = { + ...existing, + outcome: { status: 'unknown' }, + ...(args.ownedPane ? { ownedPane: args.ownedPane } : {}) + } const next = new Map(rows) next.set(key, claimed) return { rows: next, claim: { claim: 'won', row: claimed } } diff --git a/src/shared/native-chat-initial-view-mode.ts b/src/shared/native-chat-initial-view-mode.ts new file mode 100644 index 000000000000..42cdb4e44b89 --- /dev/null +++ b/src/shared/native-chat-initial-view-mode.ts @@ -0,0 +1,73 @@ +import type { GlobalSettings } from './global-settings-types' +import { agentTabsDefaultToNativeChat } from './structured-native-chat-launch-route' +import type { Tab } from './tab-types' +import type { TuiAgent } from './tui-agent' +import { canMirrorLaunchDraftToNativeChat } from './native-chat-launch-draft-mirrorability' +import { + isNativeChatSupportedAgent, + nativeChatRequiresLocalTranscript +} from './native-chat-agent-support' + +export type NativeChatLaunchPromptDelivery = 'auto-submit' | 'draft' | 'submit-after-ready' + +/** + * Decide the initial `viewMode` for a newly launched agent tab from the + * opt-in `openAgentTabsInChatByDefault` setting. + * + * Returns `'chat'` only when the setting is explicitly on and the launched + * agent has a native-chat renderer. A draft launch opens in chat only when its + * unsent context can be mirrored into the composer — gated on the same + * predicate as seeding so the view never opens empty beside a filled TUI input. + */ +export function decideInitialAgentTabViewMode(args: { + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean + agent?: TuiAgent | null + promptDelivery?: NativeChatLaunchPromptDelivery + /** The unsent launch context, when `promptDelivery` is `'draft'`. */ + launchDraftText?: string + nativeChatTranscriptIsLocalReadable?: boolean +}): Tab['viewMode'] { + if (!agentTabsDefaultToNativeChat(args)) { + return undefined + } + if (!isNativeChatSupportedAgent(args.agent)) { + return undefined + } + if ( + nativeChatRequiresLocalTranscript(args.agent) && + args.nativeChatTranscriptIsLocalReadable !== true + ) { + return undefined + } + if ( + args.promptDelivery === 'draft' && + !canMirrorLaunchDraftToNativeChat(args.launchDraftText ?? '') + ) { + return undefined + } + return 'chat' +} + +export function initialAgentTabViewModeProps( + settings: + | Pick + | null + | undefined, + options: { + agent?: TuiAgent | null + promptDelivery?: NativeChatLaunchPromptDelivery + launchDraftText?: string + nativeChatTranscriptIsLocalReadable?: boolean + } = {} +): { viewMode?: Tab['viewMode'] } { + const viewMode = decideInitialAgentTabViewMode({ + experimentalNativeChat: settings?.experimentalNativeChat, + openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, + agent: options.agent, + promptDelivery: options.promptDelivery, + launchDraftText: options.launchDraftText, + nativeChatTranscriptIsLocalReadable: options.nativeChatTranscriptIsLocalReadable + }) + return viewMode ? { viewMode } : {} +} diff --git a/src/shared/native-chat-launch-draft-mirrorability.ts b/src/shared/native-chat-launch-draft-mirrorability.ts new file mode 100644 index 000000000000..a66584cd8464 --- /dev/null +++ b/src/shared/native-chat-launch-draft-mirrorability.ts @@ -0,0 +1,21 @@ +import { AGENT_TUI_CLEAR_MAX_LINES, countAgentTuiInputLines } from './agent-tui-input-clear' + +/** + * Single source of truth for whether unsent launch context can be mirrored from + * the agent's TUI input into the native-chat composer. + * + * Both the seeding path (`seedNativeChatLaunchDraftForAgentTab`) and the + * initial-view-mode decision (`decideInitialAgentTabViewMode`) gate on this one + * predicate, so a draft launch can never open in chat with a composer that + * chat then refuses to fill. + * + * CR/LF drafts are safe within the bounded TUI-clear budget. Unicode line + * separators and drafts beyond that budget remain terminal-only. + */ +export function canMirrorLaunchDraftToNativeChat(text: string): boolean { + return ( + text.trim().length > 0 && + !/[\u2028\u2029]/.test(text) && + countAgentTuiInputLines(text) <= AGENT_TUI_CLEAR_MAX_LINES + ) +} diff --git a/src/shared/native-chat-transcript-readability.ts b/src/shared/native-chat-transcript-readability.ts new file mode 100644 index 000000000000..60ef9f06ed44 --- /dev/null +++ b/src/shared/native-chat-transcript-readability.ts @@ -0,0 +1,8 @@ +import { isRuntimeOwnedSshTargetId } from './execution-host' + +/** Model-A SSH stores Grok transcripts on a host this renderer cannot read. */ +export function isNativeChatTranscriptLocalReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} diff --git a/src/shared/rpc-contract/agent-launch-params.test.ts b/src/shared/rpc-contract/agent-launch-params.test.ts index 2d5286ded17a..f425920d498c 100644 --- a/src/shared/rpc-contract/agent-launch-params.test.ts +++ b/src/shared/rpc-contract/agent-launch-params.test.ts @@ -50,4 +50,41 @@ describe('agent.launch params', () => { expect(parsed.data).not.toHaveProperty('paneKey') expect(parsed.data).not.toHaveProperty('sessionId') }) + + it('reads placement and presentation, and never refuses a launch over either', () => { + const parsed = AgentLaunch.parse({ + ...BASE, + placement: { groupId: 'group-1', afterTabId: 'tab-1', splitDirection: 'right' }, + presentation: 'background' + }) + expect(parsed.placement).toEqual({ groupId: 'group-1', afterTabId: 'tab-1' }) + expect(parsed.presentation).toBe('background') + // A word added later reads as absent: it is about the view, not whether the agent runs. + expect(AgentLaunch.parse({ ...BASE, presentation: 'peek' }).presentation).toBeUndefined() + }) + + it('drops a placement id or a presentation that is not a string instead of refusing the launch', () => { + expect( + AgentLaunch.parse({ ...BASE, placement: { groupId: 7, afterTabId: 'tab-1' } }).placement + ).toEqual({ + afterTabId: 'tab-1' + }) + expect(AgentLaunch.parse({ ...BASE, placement: 'group-1' }).placement).toBeUndefined() + expect(AgentLaunch.safeParse({ ...BASE, presentation: 5 })).toMatchObject({ + success: true, + data: { presentation: undefined } + }) + }) + + it('lets a host from before placement drop it rather than refuse the launch', () => { + const olderHost = AgentLaunchFields.omit({ placement: true, presentation: true }) + const parsed = olderHost.safeParse({ + ...BASE, + placement: { groupId: 'group-1' }, + presentation: 'focused' + }) + expect(parsed.success).toBe(true) + expect(parsed.data).not.toHaveProperty('placement') + expect(parsed.data).not.toHaveProperty('presentation') + }) }) diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index 3926c610708a..d95f113f4c98 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -35,6 +35,12 @@ const LaunchAgent = z /** The launch's fields without the cross-field check, for building an older host's shape in tests; * every receiver parses `AgentLaunch` or `AgentLaunchReplay`. */ +/** A layout id the caller may get wrong without failing the launch: anything but a string is dropped. */ +const tolerantTabLayoutId = z + .unknown() + .transform((value): string | undefined => (typeof value === 'string' ? value : undefined)) + .optional() + export const AgentLaunchFields = z.object({ agent: LaunchAgent, /** @@ -108,7 +114,28 @@ export const AgentLaunchFields = z.object({ * exists. Ignored when the launch settles as a terminal; the outcome's `sessionId` says which * session really exists. */ - sessionId: SessionId.optional() + sessionId: SessionId.optional(), + /** + * Where the new tab goes in the workspace's layout: a group, the host tab it should follow, or + * both. About the view, not the effect, so it is outside the replay fingerprint and never fails a + * launch: a group that is gone falls back to the anchor's group, then the active one, and keys + * this host does not know are dropped. The receipt's `placement` says where it landed. Read by + * hosts advertising `agent.launch.placement.v1`; older hosts drop it. + */ + placement: z + .object({ groupId: tolerantTabLayoutId, afterTabId: tolerantTabLayoutId }) + .optional() + .catch(undefined), + /** `terminal.create`'s field, with its meaning: `focused` selects the new tab, `background` moves + * nothing; absent reveals it, as before. Whose view that is follows main's navigation default for + * the caller, so a paired device moves only its own selection. Outside the fingerprint for the + * same reason as `placement`, and open like it: a word this host does not know reads as absent. */ + presentation: z + .unknown() + .transform((value): 'focused' | 'background' | undefined => + value === 'focused' || value === 'background' ? value : undefined + ) + .optional() }) /** A caller-minted session id must be shaped like every id the host mints, so an id still names diff --git a/src/shared/terminal-tab-types.ts b/src/shared/terminal-tab-types.ts index 9f7d9428b0a3..689aee495462 100644 --- a/src/shared/terminal-tab-types.ts +++ b/src/shared/terminal-tab-types.ts @@ -1,5 +1,6 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' import type { TuiAgent } from './tui-agent' +import type { AgentLaunchPaneOutcome } from './agent-launch-pane-verdict' /** Why recovery reasons live in the shared row type: the tab row carries the * recovery ledger, and the ledger records which reason it last acted on. */ @@ -95,6 +96,14 @@ export type TerminalTab = { * hook status overrides this once the agent does anything. Plain terminals * and manually-started agents omit it. */ launchAgent?: TuiAgent + /** + * The pane an `agent.launch` laid out before its agent existed, while its fate is open or once it + * is final. Pending (no `outcome`): the pane's spawn reads the launch record. Cleared once the + * agent attaches or the pane is an ordinary terminal again. `outcome`: the launch never ran there, + * and the pane says so for the tab's life, until a different launch (`operationId`) reuses it; a + * retry of the same launch changes nothing. + */ + agentLaunchPane?: { leafId: string; operationId?: string; outcome?: AgentLaunchPaneOutcome } /** Why: when `setActiveWorktree` bumps generation on all-dead tabs to drive a * TerminalPane remount, the fresh PTY that results is caused by navigation, * not by the user doing work. Without this flag the resulting diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 61c25e39ad43..b9748b32e16b 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -169,6 +169,50 @@ describe('parseWorkspaceSession', () => { } }) + it("keeps a launch pane's state for the tab's life, and drops a malformed one", () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + wt: [ + { + id: 'tab1', + ptyId: null, + worktreeId: 'wt', + title: 'claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'claude', + agentLaunchPane: { leafId: 'leaf-1', outcome: { kind: 'not-started', code: 'boom' } } + }, + { + id: 'tab2', + ptyId: null, + worktreeId: 'wt', + title: 'claude', + customTitle: null, + color: null, + sortOrder: 1, + createdAt: 1, + agentLaunchPane: { leafId: 'leaf-2', outcome: { kind: 'exploded' } } + } + ] + }, + terminalLayoutsByTabId: {} + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.tabsByWorktree.wt[0].agentLaunchPane).toEqual({ + leafId: 'leaf-1', + outcome: { kind: 'not-started', code: 'boom' } + }) + expect(result.value.tabsByWorktree.wt[1].agentLaunchPane).toBeUndefined() + } + }) + it('drops an unknown launchAgent without failing the whole session', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 9f983c832820..5274cb8be3e9 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -11,6 +11,7 @@ * survives, because one bad tab record must not cost every worktree its state. * Only a payload that is not a session at all falls back to defaults. */ +import { agentLaunchPaneOnTabSchema } from './agent-launch-pane-verdict' import { z } from 'zod' import { closedTerminalTabTombstoneSchema } from './closed-terminal-tab-tombstones' import type { WorkspaceKey } from './folder-workspace-types' @@ -118,7 +119,9 @@ const terminalTabSchema = z.object({ launchAgent: z .custom((v) => isTuiAgent(v)) .optional() - .catch(undefined) + .catch(undefined), + // Why: survives a restart so a restored launch pane reads its fate before it spawns. + agentLaunchPane: agentLaunchPaneOnTabSchema }) // ─── Unified tab model ────────────────────────────────────────────── From 9326aae0a1808227b1fe5bd47b200fa92bbf6dd3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:19:36 -0700 Subject: [PATCH 3/3] style(runtime): one-line the tab-order map so the headless browser-tabs runtime stays under its line limit The merge of main (#25724's emitMobileSessionTabsSnapshot metadata) plus this branch's close mark put the file one line over max-lines. Formatting only. --- ...rca-runtime-build-headless-mobile-session-browser-tabs.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 5ed889c2ea01..119cf3deecd0 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -269,10 +269,7 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt }) - .map((tab, index) => ({ - ...tab, - sortOrder: index - })) + .map((tab, index) => ({ ...tab, sortOrder: index })) this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabsByWorktree: {