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/5] 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/5] 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 c63d9ec16e44fb6b94d04a518c7b9e34926e4f14 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:42:13 -0700 Subject: [PATCH 3/5] feat(agent-launch): the desktop AI buttons start their agent through agent.launch Part 3 of 3 of the agent-launch unification, on main. The AI buttons (Fix checks, the source-control actions, commit/push recovery, Explain commit, notes and annotation sends, session continuation) started their agent from the window. They now ask the host's agent.launch to start it, with no prompt, into the tab the window makes at the click, under one operation id per click; the window's pane waits for the host's agent instead of spawning a shell. The window then pastes the prompt with main's own paste, moved out verbatim and started only once the host's agent holds the tab, so delivery and follow-ups are exactly main's. A typed new-tab prompt and launches while chat is the default keep main's path. The source-control dialog drops its launch-command preview, which the host builds now. --- .../SourceControlAgentActionDialog.test.tsx | 13 +- .../SourceControlAgentActionDialogForm.tsx | 48 +--- .../buildSourceControlAgentDeliveryPlan.ts | 30 +-- .../useSourceControlAgentActionStart.ts | 4 +- .../terminal-pane/ipc-pty-connect.ts | 9 + .../pty-transport-connect-spawn.test.ts | 28 ++ ...c-events-terminal-create-scenario-types.ts | 5 +- .../terminal-presentation-ipc-bridge.ts | 6 + ...eIpcEvents-agent-launch-closed-tab.test.ts | 34 +++ src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - .../agent-launch-caller-permissions.test.ts | 43 +++- .../lib/agent-launch-caller-placement.test.ts | 27 ++ ...ent-launch-caller-prompt-transport.test.ts | 35 ++- .../lib/agent-launch-funnel-test-harness.ts | 7 + .../lib/agent-launch-pane-spawn-hold.test.ts | 46 ++++ .../src/lib/agent-launch-pane-spawn-hold.ts | 58 +++++ .../lib/agent-launch-tab-publication.test.ts | 21 ++ .../src/lib/agent-launch-tab-publication.ts | 5 +- .../src/lib/agent-launch-through-host.test.ts | 243 ++++++++++++++++++ .../src/lib/agent-launch-through-host.ts | 206 +++++++++++++++ .../src/lib/launch-agent-in-new-tab.test.ts | 7 + .../src/lib/launch-agent-in-new-tab.ts | 62 +++-- .../launch-agent-new-tab-host-route.test.ts | 104 ++++++++ .../lib/launch-agent-new-tab-host-route.ts | 87 +++++++ .../src/lib/launch-agent-tab-prompt-paste.ts | 49 ++++ ...-control-agent-action-launch-check.test.ts | 56 ++++ ...ource-control-agent-action-launch-check.ts | 100 +++++++ .../source-control-agent-action-plan.test.ts | 112 -------- .../lib/source-control-agent-action-plan.ts | 213 --------------- src/shared/agent-launch-tab-publication.ts | 3 +- 35 files changed, 1223 insertions(+), 444 deletions(-) create mode 100644 src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-spawn-hold.ts create mode 100644 src/renderer/src/lib/agent-launch-through-host.test.ts create mode 100644 src/renderer/src/lib/agent-launch-through-host.ts create mode 100644 src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts create mode 100644 src/renderer/src/lib/launch-agent-new-tab-host-route.ts create mode 100644 src/renderer/src/lib/launch-agent-tab-prompt-paste.ts create mode 100644 src/renderer/src/lib/source-control-agent-action-launch-check.test.ts create mode 100644 src/renderer/src/lib/source-control-agent-action-launch-check.ts delete mode 100644 src/renderer/src/lib/source-control-agent-action-plan.test.ts delete mode 100644 src/renderer/src/lib/source-control-agent-action-plan.ts diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index ec087422f70e..a5592bb9fc0a 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ onSaveAgentDefault: vi.fn(), onLaunched: vi.fn(), onStart: vi.fn(), - planSourceControlAgentActionLaunch: vi.fn(), + checkSourceControlAgentActionLaunch: vi.fn(), toastError: vi.fn() })) vi.mock('@/components/agent/AgentCombobox', () => ({ @@ -51,8 +51,8 @@ vi.mock('@/components/ui/select', () => ({ vi.mock('../source-control/SourceControlActionVariableChips', () => ({ SourceControlActionVariableChips: () => React.createElement('div') })) -vi.mock('@/lib/source-control-agent-action-plan', () => ({ - planSourceControlAgentActionLaunch: mocks.planSourceControlAgentActionLaunch +vi.mock('@/lib/source-control-agent-action-launch-check', () => ({ + checkSourceControlAgentActionLaunch: mocks.checkSourceControlAgentActionLaunch })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } @@ -181,12 +181,7 @@ describe('SourceControlAgentActionDialog', () => { mocks.ensureDetectedAgents.mockResolvedValue(['codex']) mocks.ensureRemoteDetectedAgents.mockResolvedValue(['codex']) mocks.onStart.mockResolvedValue(true) - mocks.planSourceControlAgentActionLaunch.mockReturnValue({ - ok: true, - summary: 'Ready to launch.', - commandLabel: 'codex', - caveat: 'The prompt will be submitted after the agent is ready.' - }) + mocks.checkSourceControlAgentActionLaunch.mockReturnValue({ ok: true }) container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index dcaf2caf5841..cc0a2707de9f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -1,13 +1,5 @@ import React from 'react' -import { - CheckCircle2, - Info, - RefreshCw, - RotateCcw, - Settings, - Sparkles, - TriangleAlert -} from 'lucide-react' +import { Info, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react' import AgentCombobox from '@/components/agent/AgentCombobox' import { Button } from '@/components/ui/button' import { DialogFooter } from '@/components/ui/dialog' @@ -32,9 +24,9 @@ import { sourceControlActionRecipeMatchesTarget } from './source-control-action- import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result' import { translate } from '@/i18n/i18n' +/** What the dialog shows before it starts an agent: only an error the user can fix. */ export type SourceControlAgentActionDeliveryPlanState = | { status: 'idle' } - | { status: 'success'; summary: string; commandLabel: string; caveat: string } | { status: 'error'; error: string } type SourceControlAgentActionDialogFormProps = { @@ -345,36 +337,12 @@ export function SourceControlAgentActionDialogForm({ ) : null} - {deliveryPlan.status !== 'idle' ? ( -
- {deliveryPlan.status === 'error' ? ( - - - {deliveryPlan.error} - - ) : ( -
-
- - {deliveryPlan.summary} -
-
- {translate( - 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', - 'Launch:' - )}{' '} - {deliveryPlan.commandLabel} -
-
{deliveryPlan.caveat}
-
- )} + {deliveryPlan.status === 'error' ? ( +
+ + + {deliveryPlan.error} +
) : null}
diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index d8320dd10750..cca65a8bb5c0 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -1,28 +1,25 @@ -import { planSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-plan' +import { checkSourceControlAgentActionLaunch } from '@/lib/source-control-agent-action-launch-check' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/tui-agent' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' -import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null commandInput: string agentArgs?: string | undefined - promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' detectedAgents: TuiAgent[] connectionUnavailable: boolean launchPlatform?: NodeJS.Platform - /** Why: keep the previewed command label in sync with the real remote launch, - * which omits the Linux-only `orca-ide` rename for SSH hosts. */ + /** Why: SSH remotes run the plain `orca` shim, so the check builds the command they would. */ isRemote?: boolean } +/** The dialog's check before it starts an agent: an error the user can fix, else nothing to show. */ export function buildSourceControlAgentDeliveryPlan({ selectedAgent, commandInput, agentArgs, - promptDelivery, detectedAgents, connectionUnavailable, launchPlatform, @@ -32,19 +29,10 @@ export function buildSourceControlAgentDeliveryPlan({ return buildSourceControlAgentConnectionErrorPlan() } const settings = useAppStore.getState().settings - const result = planSourceControlAgentActionLaunch({ + const result = checkSourceControlAgentActionLaunch({ agent: selectedAgent, commandInput, agentArgs, - sessionOptions: selectedAgent - ? resolveInitialNativeChatSessionOptions(settings, { - agent: selectedAgent, - promptDelivery, - launchDraftText: commandInput.trim(), - nativeChatTranscriptIsLocalReadable: !isRemote - }) - : undefined, - promptDelivery, detectedAgents, disabledAgents: settings?.disabledTuiAgents, cmdOverrides: settings?.agentCmdOverrides, @@ -52,13 +40,5 @@ export function buildSourceControlAgentDeliveryPlan({ platform: launchPlatform, isRemote }) - if (!result.ok) { - return { status: 'error', error: result.error } - } - return { - status: 'success', - summary: result.summary, - commandLabel: result.commandLabel, - caveat: result.caveat - } + return result.ok ? { status: 'idle' } : { status: 'error', error: result.error } } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fef..4ca1519080cf 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -110,9 +110,8 @@ export function useSourceControlAgentActionStart({ return buildSourceControlAgentDeliveryPlan({ selectedAgent, commandInput, - // Why: the previewed command must show what the launch will really apply. + // Why: the check must judge the arguments the launch will really apply. agentArgs: agentArgsApply ? agentArgs : undefined, - promptDelivery, detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, @@ -124,7 +123,6 @@ export function useSourceControlAgentActionStart({ agentArgsApply, commandInput, connectionUnavailable, - promptDelivery, refreshDetectedAgents, selectedAgent, launchPlatform, diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index e8c0c2caf4f2..dfc7f71755a8 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -14,6 +14,7 @@ import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2 import type { IpcPtySessionHandlers } from './ipc-pty-session-handlers' import { isSshSessionGoneError } from './pty-connection/pty-connect-limits' import { spawnIpcPty } from './ipc-pty-spawn-request' +import { agentLaunchPaneSpawnHold } from '@/lib/agent-launch-pane-spawn-hold' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -81,6 +82,14 @@ export async function connectIpcPty( return } } + // A launch pane this window made spawns only once the host has taken it. + const launchPaneHold = agentLaunchPaneSpawnHold(transportOptions.tabId, transportOptions.leafId) + if (launchPaneHold) { + await launchPaneHold + if (context.isDestroyed()) { + return + } + } if (options.shouldContinue && !options.shouldContinue()) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 59502adcca68..e150ad1d0508 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -78,6 +78,34 @@ describe('createIpcPtyTransport', () => { expect(spawn).not.toHaveBeenCalled() }) + it("spawns a launch pane this window made only once the host has taken it, and never once it's gone", async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const { holdAgentLaunchPaneSpawn, releaseAgentLaunchPaneSpawn } = + await import('@/lib/agent-launch-pane-spawn-hold') + const spawn = window.api.pty.spawn as unknown as ReturnType + const pane = { worktreeId: 'wt', tabId: 'tab-held', leafId: 'leaf-held' } + + holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + const transport = createIpcPtyTransport(pane) + const connecting = transport.connect({ url: '', callbacks: {} }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(spawn).not.toHaveBeenCalled() + + releaseAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + await connecting + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ tabId: 'tab-held' })) + transport.disconnect() + + spawn.mockClear() + const release = holdAgentLaunchPaneSpawn(pane.tabId, pane.leafId) + const closed = createIpcPtyTransport(pane) + const abandoned = closed.connect({ url: '', callbacks: {} }) + closed.destroy?.() + release() + await expect(abandoned).resolves.toBeUndefined() + expect(spawn).not.toHaveBeenCalled() + }) + it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { const { createIpcPtyTransport } = await import('./pty-transport') vi.mocked(window.api.pty.spawn).mockRejectedValueOnce( 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 63e827954333..af3ef297368d 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 @@ -79,7 +79,10 @@ export type TerminalCreateSurfacingStore = { clearAgentLaunchConfig: SpyMock updateTabPtyId: Mock<(tabId: string, ptyId: string) => void> setTabLayout: Mock<(tabId: string, layout: unknown) => void> - tabsByWorktree: Record + tabsByWorktree: Record< + string, + { id: string; ptyId?: string | null; title?: string; agentLaunchPane?: { leafId: string } }[] + > folderWorkspaces: unknown[] projectGroups: unknown[] repos: { id: string; connectionId: string | null; executionHostId: string }[] 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 368bb66413e1..e978865a34ee 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 @@ -1,3 +1,4 @@ +import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' @@ -88,6 +89,11 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v throw new Error(`Terminal tab ${tabId} not found`) } const reusedTab = existingTab ?? splitTargetTab + if (ptyId && tabId && leafId && reusedTab?.agentLaunchPane?.leafId === leafId) { + // A launch pane this window made and the host never showed early: the host now holds it, + // so its spawn may attach instead of waiting out the whole launch. + releaseAgentLaunchPaneSpawn(tabId, leafId) + } const tab = reusedTab ?? (ptyId 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 index 1730fb08497f..a108deb9b377 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-launch-closed-tab.test.ts @@ -62,3 +62,37 @@ describe('a launch tab while its agent is starting', () => { ) }) }) + +describe('a launch tab this window made, which the host never showed early', () => { + it('takes the agent the host reveals under its own id, and lets its pane attach at once', async () => { + const scenario = await setupTerminalCreateSurfacing(() => false) + const { createTab, updateTabPtyId, createTerminalListenerRef, storeState } = scenario + if (!createTerminalListenerRef.current) { + throw new Error('Expected the create-terminal listener to be registered') + } + Object.assign(window.api, { pty: { kill: vi.fn(async () => {}) } }) + // The harness resets modules; the bridge reads the instance it imported. + const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = + await import('@/lib/agent-launch-pane-spawn-hold') + holdAgentLaunchPaneSpawn('tab-made', LEAF) + storeState.tabsByWorktree['wt-1'] = [ + { id: 'tab-made', ptyId: null, agentLaunchPane: { leafId: LEAF } } + ] + storeState.terminalLayoutsByTabId['tab-made'] = { root: { type: 'leaf', leafId: LEAF } } + const held = agentLaunchPaneSpawnHold('tab-made', LEAF) + + createTerminalListenerRef.current({ + requestId: 'reveal-window-made-launch-tab', + worktreeId: 'wt-1', + ptyId: 'pty-agent', + tabId: 'tab-made', + leafId: LEAF + }) + + // Never a second tab under the same id, and the waiting pane is let go to attach. + expect(createTab).not.toHaveBeenCalled() + expect(updateTabPtyId).toHaveBeenCalledWith('tab-made', 'pty-agent') + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold('tab-made', LEAF)).toBeNull() + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3046b3295e72..efb93b3358a1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13041,7 +13041,6 @@ "ea4788705e": "Cancel", "7ec6abbf2a": "Reset", "f4f3c9ca4a": "Prompt template", - "1bc0bdbb5e": "Launch:", "fe119187bb": "--model sonnet", "bc8dc39f4b": "CLI arguments", "b99c33cec5": "Settings", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a3275123fc40..99e7e21191b5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12819,7 +12819,6 @@ "ea4788705e": "Cancelar", "7ec6abbf2a": "Reiniciar", "f4f3c9ca4a": "Plantilla de prompt", - "1bc0bdbb5e": "Lanzamiento:", "fe119187bb": "--model sonnet", "bc8dc39f4b": "Argumentos CLI", "b99c33cec5": "Ajustes", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 702b057b41f8..925a088b3d40 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12922,7 +12922,6 @@ "ea4788705e": "Annuler", "7ec6abbf2a": "Réinitialiser", "f4f3c9ca4a": "Modèle de prompt", - "1bc0bdbb5e": "Lancement :", "fe119187bb": "--model sonnet", "bc8dc39f4b": "Arguments CLI", "b99c33cec5": "Paramètres", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b12ee33b8d95..285d368f6de8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12809,7 +12809,6 @@ "38b899cc02": "すべてのリポジトリ" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "起動:", "7ec6abbf2a": "リセット", "f4f3c9ca4a": "コマンドテンプレート", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 41eebfdcd6eb..31ad41be83cd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12809,7 +12809,6 @@ "38b899cc02": "모든 리포지토리" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "실행:", "7ec6abbf2a": "재설정", "f4f3c9ca4a": "프롬프트 템플릿", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6ecc86d764e4..517f2ce1dc6c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12809,7 +12809,6 @@ "38b899cc02": "所有仓库" }, "SourceControlAgentActionDialogForm": { - "1bc0bdbb5e": "启动:", "7ec6abbf2a": "重置", "f4f3c9ca4a": "提示词模板", "fe119187bb": "--model sonnet", diff --git a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 0c1b3066f171..dfc416260f84 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -10,10 +10,12 @@ import { } from './agent-launch-caller-profiles-test-harness' import { createLaunchFunnelStore, + hostLaunchRequest, queuedStartupCommand, queuedStartupPayload, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' +import { newTabPromptLaunchesThroughHost } from './launch-agent-new-tab-host-route' const store = createLaunchFunnelStore() @@ -45,6 +47,31 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// A launch the host delivers waits on its reply; these tests read only what was sent. +const callRuntimeRpc = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { + return newTabPromptLaunchesThroughHost({ + promptDelivery: profile.args.promptDelivery ?? 'auto-submit', + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + }) +} + +/** + * A call site whose prompt the host delivers states its arguments in the request, and the host + * builds the command from them by the same rule (its startup plan inputs); absent means the + * setting, shipped bypass default included. + */ +function expectHostRequestArguments(profile: AgentLaunchCallerProfile): void { + const request = hostLaunchRequest(callRuntimeRpc) + expect(queuedStartupCommand(store)).toBeUndefined() + if (profile.args.agentArgs === undefined) { + expect(request).not.toHaveProperty('agentArgs') + } else { + expect(request?.agentArgs).toBe(profile.args.agentArgs) + } +} const CODEX_BYPASS = '--dangerously-bypass-approvals-and-sandbox' @@ -73,6 +100,10 @@ describe('agent launch caller arguments and permission bypass', () => { it.each(cases)('puts %s on the command line its own arguments describe', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const command = queuedStartupCommand(store) expect(command).toBeDefined() @@ -87,6 +118,10 @@ describe('agent launch caller arguments and permission bypass', () => { it.each(cases)('keeps %s on the bypass posture its arguments encode', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const command = queuedStartupCommand(store) ?? '' // Why: the three recipe-driven call sites hand in saved arguments, which REPLACE the shipped @@ -99,6 +134,10 @@ describe('agent launch caller arguments and permission bypass', () => { 'forwards an explicit argument override from %s to the tab', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + expectHostRequestArguments(profile) + return + } const payload = queuedStartupPayload(store) if (profile.args.agentArgs === undefined) { @@ -221,7 +260,7 @@ describe('agent launch caller arguments and permission bypass', () => { worktreeId: 'wt-1' }) - expect(result?.startupPlan.sessionOptions).toEqual({ + expect(result?.startupPlan?.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) @@ -246,7 +285,7 @@ describe('agent launch caller arguments and permission bypass', () => { worktreeId: 'wt-1' }) - expect(result?.startupPlan.sessionOptions).toBeUndefined() + expect(result?.startupPlan?.sessionOptions).toBeUndefined() expect(queuedStartupCommand(store)).not.toContain("'-m'") }) }) diff --git a/src/renderer/src/lib/agent-launch-caller-placement.test.ts b/src/renderer/src/lib/agent-launch-caller-placement.test.ts index 9cc4fedab4c1..72b794f60cf4 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -11,9 +11,11 @@ import { createdTabGroupId, createdTabOptions, createLaunchFunnelStore, + hostLaunchRequest, queuedStartupPayload, resetLaunchFunnelStore } from './agent-launch-funnel-test-harness' +import { newTabPromptLaunchesThroughHost } from './launch-agent-new-tab-host-route' const store = createLaunchFunnelStore() @@ -45,6 +47,16 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// A launch the host delivers waits on its reply; these tests read only what was sent. +const callRuntimeRpc = vi.hoisted(() => vi.fn(() => new Promise(() => {}))) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { + return newTabPromptLaunchesThroughHost({ + promptDelivery: profile.args.promptDelivery ?? 'auto-submit', + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + }) +} const cases = callerProfileCases() @@ -109,6 +121,12 @@ describe('agent launch caller placement and telemetry', () => { async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + // The host starts the agent where the request names, so nothing waits on the tab. + expect(store.queueTabInitialCwd).not.toHaveBeenCalled() + expect(hostLaunchRequest(callRuntimeRpc)?.cwd).toBe(profile.args.initialCwd) + return + } if (profile.args.initialCwd) { expect(store.queueTabInitialCwd).toHaveBeenCalledExactlyOnceWith( 'tab-1', @@ -127,6 +145,15 @@ describe('agent launch caller placement and telemetry', () => { it.each(cases)('stamps the launch %s started with its telemetry source', async (_id, profile) => { await launch(profile) + if (launchesThroughHost(profile)) { + // The host stamps `agent_started` from the request; the window queues no command of its own. + expect(queuedStartupPayload(store)).toBeUndefined() + expect(hostLaunchRequest(callRuntimeRpc)).toMatchObject({ + agent: profile.args.agent, + launchSource: profile.args.launchSource ?? 'tab_bar_quick_launch' + }) + return + } expect(queuedStartupPayload(store)?.telemetry).toEqual({ agent_kind: `kind:${profile.args.agent}`, // git-history-explain-commit names no source, so it reports as a tab-bar quick launch. diff --git a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts index ed8d689c90df..3f3acb5e6d41 100644 --- a/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-prompt-transport.test.ts @@ -51,6 +51,20 @@ vi.mock('@/lib/agent-ready-wait', () => ({ vi.mock('@/runtime/local-runtime-capabilities', () => ({ readLocalRuntimeCapabilitiesOrUnknown: () => [] })) +// The host starts an AI button's agent in the tab's pane and answers; the window then pastes. +const callRuntimeRpc = vi.hoisted(() => + vi.fn(async (_target: unknown, _method: string, params: Record) => ({ + outcome: { kind: 'terminal', handle: 'term_1', paneKey: params.paneKey }, + worktreeId: 'wt-1', + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' } + })) +) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc, RuntimeRpcCallError: Error })) + +/** What the window's own launch command carries; a launch started by the host queues none. */ +function commandCarries(text: string): boolean { + return queuedStartupCommand(store)?.includes(text) ?? false +} const PROMPT = 'Explain the failing check and propose a fix.' @@ -147,14 +161,18 @@ describe('agent launch caller prompt transport', () => { if (profile.args.prompt === undefined) { expect(result?.pasteDraftAfterLaunch).toBe(false) expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() - expect(queuedStartupCommand(store)).not.toContain(PROMPT) + expect(commandCarries(PROMPT)).toBe(false) return } // quick-command is the only prompt-carrying call site that names no delivery mode, so its text // rides argv; every other one asks for draft or submit-after-ready and pastes. const ridesArgv = id === 'quick-command' expect(result?.pasteDraftAfterLaunch).toBe(!ridesArgv) - expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(ridesArgv) + expect(commandCarries(PROMPT)).toBe(ridesArgv) + // The host is never handed the text: the window pastes it, as main does. + for (const [, , params] of callRuntimeRpc.mock.calls) { + expect(params).not.toHaveProperty('prompt') + } } ) @@ -211,12 +229,15 @@ describe('agent launch caller prompt transport', () => { }) expect(result?.pasteDraftAfterLaunch).toBe(row.transport === 'paste') - expect(queuedStartupCommand(store)?.includes(PROMPT)).toBe(row.transport === 'argv') + expect(commandCarries(PROMPT)).toBe(row.transport === 'argv') if (row.transport === 'paste') { - expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ - content: PROMPT, - submit: row.submits - }) + // Through the host, the paste waits for the agent to be started in the tab. + await vi.waitFor(() => + expect(mockPasteDraftWhenAgentReady.mock.calls[0]?.[0]).toMatchObject({ + content: PROMPT, + submit: row.submits + }) + ) } else { expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled() } diff --git a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts index 8292a054cb63..c1fc80cafa25 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -108,3 +108,10 @@ export function createdTabOptions(store: LaunchFunnelStore): Record +): Record | undefined { + return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launchReplay')?.[2] +} diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts new file mode 100644 index 000000000000..bcbc87d7e9a0 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchPaneSpawnHold, + holdAgentLaunchPaneSpawn, + isAgentLaunchPaneSpawnHeld, + releaseAgentLaunchPaneSpawn +} from './agent-launch-pane-spawn-hold' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +describe('a launch pane this window made', () => { + it('holds its spawn until the host takes the pane', async () => { + holdAgentLaunchPaneSpawn(TAB, LEAF) + const held = agentLaunchPaneSpawnHold(TAB, LEAF) + let spawned = false + void held?.then(() => { + spawned = true + }) + await Promise.resolve() + expect(spawned).toBe(false) + expect(isAgentLaunchPaneSpawnHeld(TAB, LEAF)).toBe(true) + + expect(releaseAgentLaunchPaneSpawn(TAB, LEAF)).toBe(true) + await held + expect(spawned).toBe(true) + expect(isAgentLaunchPaneSpawnHeld(TAB, LEAF)).toBe(false) + expect(releaseAgentLaunchPaneSpawn(TAB, LEAF)).toBe(false) + }) + + it('ends the hold when the launch is over, and a second end is harmless', async () => { + const release = holdAgentLaunchPaneSpawn(TAB, LEAF) + const held = agentLaunchPaneSpawnHold(TAB, LEAF) + release() + release() + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold(TAB, LEAF)).toBeNull() + }) + + it('holds nothing for any other pane', () => { + const release = holdAgentLaunchPaneSpawn(TAB, LEAF) + expect(agentLaunchPaneSpawnHold(TAB, 'another-leaf')).toBeNull() + expect(agentLaunchPaneSpawnHold(undefined, LEAF)).toBeNull() + release() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts new file mode 100644 index 000000000000..4278d413b7e0 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts @@ -0,0 +1,58 @@ +/** + * Launch panes this window made before asking the host to launch into them. + * + * Such a pane must not spawn until the host has taken it: before that, main knows of no launch for + * it and would give it a shell, which the host's own spawn would then be refused over. The host + * takes the pane when it asks this window to show the launch's tab, so the hold ends there, or when + * the launch is over. Session-only: after a reload the pane's spawn reads the launch record like any + * launch pane. + */ + +type Hold = { released: Promise; release: () => void } + +const holds = new Map() + +// Not `makePaneKey`: that validates the leaf id, and a launch must never fail over bookkeeping. +function holdKey(tabId: string, leafId: string): string { + return JSON.stringify([tabId, leafId]) +} + +/** Holds the pane's spawn; the returned function ends the hold and is safe to call twice. */ +export function holdAgentLaunchPaneSpawn(tabId: string, leafId: string): () => void { + const key = holdKey(tabId, leafId) + let release!: () => void + const hold: Hold = { + released: new Promise((resolve) => { + release = resolve + }), + release: () => { + if (holds.get(key) === hold) { + holds.delete(key) + } + release() + } + } + holds.get(key)?.release() + holds.set(key, hold) + return hold.release +} + +/** The host has taken the pane: its spawn now waits in main for the host's agent. */ +export function releaseAgentLaunchPaneSpawn(tabId: string, leafId: string): boolean { + const hold = holds.get(holdKey(tabId, leafId)) + hold?.release() + return hold !== undefined +} + +/** What the pane's spawn awaits first, or null when nothing holds it. */ +export function agentLaunchPaneSpawnHold( + tabId: string | undefined, + leafId: string | undefined +): Promise | null { + return tabId && leafId ? (holds.get(holdKey(tabId, leafId))?.released ?? null) : null +} + +/** Still held: the host has not taken the pane. */ +export function isAgentLaunchPaneSpawnHeld(tabId: string, leafId: string): boolean { + return holds.has(holdKey(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 index 183c4d95eb06..8d9ec4c3f0b8 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -122,6 +122,27 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-2' }) }) + it('hands a tab this window made for the launch to the host, as the launch to take back', async () => { + const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = + await import('./agent-launch-pane-spawn-hold') + holdAgentLaunchPaneSpawn(TAB_ID, LEAF_ID) + store.getState().createTab(WT, undefined, undefined, { + id: TAB_ID, + initialLeafId: LEAF_ID, + agentLaunchPane: { leafId: LEAF_ID } + }) + const held = agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID) + + const published = publishAgentLaunchTab(request()) + + // The host now owns the pane, so a refused launch takes the tab back rather than leaving a shell. + expect(published).toMatchObject({ tabId: TAB_ID, created: true }) + await expect(held).resolves.toBeUndefined() + expect(agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID)).toBeNull() + // A retry finds no hold: the tab is no longer only this launch's. + expect(publishAgentLaunchTab(request({ requestId: 'request-2' })).created).toBe(false) + }) + 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 diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts index ac678d981a85..8ec7279e7477 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -20,6 +20,7 @@ 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' +import { releaseAgentLaunchPaneSpawn } from './agent-launch-pane-spawn-hold' function landedGroupId(worktreeId: string, tabId: string): string | undefined { return useAppStore @@ -79,7 +80,9 @@ export function publishAgentLaunchTab( remount: kept?.leafId === leafId && kept.outcome !== undefined }) } - return { tabId, created: false, placement: { groupId } } + // A tab this window made for this launch is the launch's to take back if it never runs. + const madeForThisLaunch = releaseAgentLaunchPaneSpawn(tabId, leafId) + return { tabId, created: madeForThisLaunch, placement: { groupId } } } const placement = resolveUnifiedTabCreatePlacement({ diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts new file mode 100644 index 000000000000..1967e6d8097c --- /dev/null +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -0,0 +1,243 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +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 callRuntimeRpc = vi.hoisted(() => + vi.fn<(target: unknown, method: string, params: Record) => Promise>() +) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + callRuntimeRpc +})) + +createTabsSliceMockApi() + +const { launchAgentThroughHost } = await import('./agent-launch-through-host') +const { agentLaunchPaneSpawnHold, releaseAgentLaunchPaneSpawn } = + await import('./agent-launch-pane-spawn-hold') +const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') + +const WT = 'repo1::/tmp/feature' +let store: ReturnType + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function rpcError(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'desktop-ipc', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-1' } + }) +} + +function terminalResult(paneKey: string) { + return { + outcome: { kind: 'terminal', handle: 'term_1', paneKey }, + worktreeId: WT, + receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'x' } + } +} + +function launchTab(tabId: string) { + return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === tabId) +} + +function lastParams(): Record { + return callRuntimeRpc.mock.calls.at(-1)?.[2] ?? {} +} + +function lastPaneKey(): string { + return String(lastParams().paneKey) +} + +function launch() { + return launchAgentThroughHost({ + agent: 'claude', + worktreeId: WT, + groupId: store.getState().activeGroupIdByWorktree[WT], + prompt: 'fix the failing checks', + agentArgs: null, + launchSource: 'source_control_recovery' + }) +} + +/** What the host does first: it takes the pane, by asking this window to show the tab. */ +function hostTakesPane(tabId: string): void { + const tab = launchTab(tabId)! + releaseAgentLaunchPaneSpawn(tab.id, tab.agentLaunchPane!.leafId) +} + +beforeEach(() => { + store = createTestStore() + testStore.current = store + callRuntimeRpc.mockReset() + store.getState().setActiveWorktree(WT) + store.getState().createUnifiedTab(WT, 'terminal') +}) + +describe('a desktop launch through the host', () => { + it('shows its tab at the click, in its split, waiting for the host before it spawns', () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + + const { tabId } = launch() + + const tab = launchTab(tabId)! + expect(tab).toMatchObject({ ptyId: null, launchAgent: 'claude' }) + const leafId = tab.agentLaunchPane!.leafId + expect(agentLaunchPaneSpawnHold(tab.id, leafId)).not.toBeNull() + expect(agentLaunchPanePrompt(tab.id)).toBe('fix the failing checks') + expect(store.getState().activeTabId).toBe(tabId) + // No prompt: the window pastes it, as main does, once the agent runs. + expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'agent.launchReplay', { + agent: 'claude', + target: { kind: 'existing', worktree: `id:${WT}` }, + agentArgs: null, + launchSource: 'source_control_recovery', + placement: { groupId: store.getState().activeGroupIdByWorktree[WT] }, + presentation: 'focused', + operationId: expect.stringMatching(/^\d+-[0-9a-f]{32}$/), + paneKey: `${tabId}:${leafId}` + }) + }) + + it('names every click as its own operation', () => { + callRuntimeRpc.mockReturnValue(new Promise(() => {})) + launch() + const first = lastParams() + launch() + const second = lastParams() + expect(second.operationId).not.toBe(first.operationId) + expect(second.paneKey).not.toBe(first.paneKey) + }) + + it('says the agent started only once the host answers, with its pane attached', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + const settled = vi.fn() + void outcome.then(settled) + hostTakesPane(tabId) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + + reply.resolve(terminalResult(lastPaneKey())) + + await expect(outcome).resolves.toEqual({ kind: 'started' }) + expect(launchTab(tabId)).toBeDefined() + }) + + it('takes its tab back on a refusal, before the pane ever spawns', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + + reply.reject(rpcError('agent_session_operation_conflict')) + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'agent_session_operation_conflict' + }) + expect(launchTab(tabId)).toBeUndefined() + }) + + it('leaves a launch the host took to its pane, which says how it ended', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + hostTakesPane(tabId) + + reply.reject(rpcError('agent_session_operation_unknown')) + + await expect(outcome).resolves.toEqual({ kind: 'pane-says' }) + expect(launchTab(tabId)).toBeDefined() + }) + + it('never leaves a pane the host did not take, which would open as a shell', async () => { + const reply = deferred() + callRuntimeRpc.mockReturnValue(reply.promise) + const { tabId, outcome } = launch() + + reply.reject(rpcError('worktree_not_found')) + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'worktree_not_found' + }) + expect(launchTab(tabId)).toBeUndefined() + }) + + // Why: the close was the user's own, as on the phone's "+". + it('says nothing more when the user closed the tab while it started', async () => { + callRuntimeRpc.mockRejectedValueOnce(rpcError('agent_launch_tab_closed')) + await expect(launch().outcome).resolves.toEqual({ kind: 'closed-by-user' }) + }) + + it('still starts the agent in the same tab when the launch record is full', async () => { + const unrecorded = deferred() + callRuntimeRpc + .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) + .mockReturnValueOnce(unrecorded.promise) + const { tabId, outcome } = launch() + const paneKey = lastPaneKey() + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledTimes(2)) + + const [, method, params] = callRuntimeRpc.mock.calls[1]! + expect(method).toBe('agent.launch') + expect(params).not.toHaveProperty('operationId') + // The same pane, held until the answer. + expect(params.paneKey).toBe(paneKey) + const leafId = launchTab(tabId)!.agentLaunchPane!.leafId + expect(agentLaunchPaneSpawnHold(tabId, leafId)).not.toBeNull() + + unrecorded.resolve(terminalResult(paneKey)) + await expect(outcome).resolves.toEqual({ kind: 'started' }) + }) + + it('takes its tab back when the unrecorded launch fails before the host revealed it', async () => { + callRuntimeRpc + .mockRejectedValueOnce(rpcError('agent_session_operation_capacity')) + .mockRejectedValueOnce(rpcError('worktree_not_found')) + const { tabId, outcome } = launch() + + await expect(outcome).resolves.toEqual({ + kind: 'not-started', + unconfirmed: false, + code: 'worktree_not_found' + }) + expect(launchTab(tabId)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts new file mode 100644 index 000000000000..89a1c36fb268 --- /dev/null +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -0,0 +1,206 @@ +/** + * A desktop launch started through the host's `agent.launch`, with no prompt. + * + * This window owns the workspace's tab layout, so it makes the agent's tab at the click, in the + * split it was asked for, as any new agent tab is made. The host records the launch under this + * click's operation id and starts the agent into that tab's pane. The pane's spawn waits until the + * host has taken it (`agent-launch-pane-spawn-hold`), then attaches to the agent or says why it could + * not start. The prompt stays this window's to paste, as main pastes it, once the agent has started. + */ + +import { useAppStore } from '@/store' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' +import { rememberAgentLaunchPanePrompt } from '@/lib/agent-launch-pane-prompt' +import { + holdAgentLaunchPaneSpawn, + isAgentLaunchPaneSpawnHeld +} from '@/lib/agent-launch-pane-spawn-hold' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import { createAgentSessionOperationId } from '@/runtime/agent-session-operation-id' +import { isAgentLaunchResult } from '../../../shared/agent-launch-intent' +import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { prefersStructuredNativeChatByDefault } from '../../../shared/structured-native-chat-launch-route' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { LaunchSource } from '../../../shared/telemetry-events' +import type { Tab } from '../../../shared/tab-types' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' + +export type HostAgentLaunchArgs = { + agent: TuiAgent + worktreeId: string + /** The split the launch was made from; the tab joins it. */ + groupId?: string + /** Kept by the window: a pane whose agent could not start offers to copy it. */ + prompt: string + /** Absent uses the settings default; `null` means no arguments. */ + agentArgs?: string | null + cwd?: string + /** The launch's session options; only string values reach the host, which reads no others. */ + sessionOptions?: Record + launchSource?: LaunchSource + quickCommandLabel?: string | null + /** The launch seeds a workspace being opened, so its spawn must not reshuffle Recent. */ + pendingActivationSpawn?: boolean + /** The view the tab opens in, decided as for any new agent tab. */ + viewMode?: Tab['viewMode'] +} + +/** What became of the launch, as this window must tell it. */ +export type HostAgentLaunchOutcome = + /** The agent was started in this tab's pane, which is attached to it. */ + | { kind: 'started' } + /** The pane shows how the launch ended: couldn't start, or couldn't confirm it started. */ + | { kind: 'pane-says' } + /** The user closed the tab while it started, and with it the launch: nothing more to say. */ + | { kind: 'closed-by-user' } + /** The tab is gone, so the window says it: nothing started, or whether it did is unknown. */ + | { kind: 'not-started'; unconfirmed: boolean; code?: string } + +/** Refused at admission: nothing ran under this click, and the host takes back the tab it was shown. */ +const ADMISSION_REFUSAL_CODES = new Set([ + 'agent_session_operation_invalid', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity' +]) + +function tabExists(worktreeId: string, tabId: string): boolean { + return (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) +} + +function closeLaunchTab(worktreeId: string, tabId: string): void { + if (tabExists(worktreeId, tabId)) { + useAppStore.getState().closeTab(tabId, { recordInteraction: false }) + } +} + +// Only a terminal in this pane is one the window can paste into; anything else, its pane explains. +function outcomeFromResult(result: unknown): HostAgentLaunchOutcome { + return isAgentLaunchResult(result) && result.outcome.kind === 'terminal' + ? { kind: 'started' } + : { kind: 'pane-says' } +} + +function stringSessionOptions(options: Record | undefined): { + sessionOptions?: Record +} { + const strings = Object.entries(options ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + return strings.length > 0 ? { sessionOptions: Object.fromEntries(strings) } : {} +} + +function launchParams(args: HostAgentLaunchArgs) { + return { + agent: args.agent, + target: { kind: 'existing', worktree: `id:${args.worktreeId}` }, + ...(args.agentArgs !== undefined ? { agentArgs: args.agentArgs } : {}), + ...(args.cwd ? { cwd: args.cwd } : {}), + ...stringSessionOptions(args.sessionOptions), + ...(args.launchSource ? { launchSource: args.launchSource } : {}), + ...(args.groupId ? { placement: { groupId: args.groupId } } : {}), + presentation: 'focused' + } +} + +/** + * The ledger is bookkeeping: a desktop past its per-caller row cap still gets its agent, through the + * host's unrecorded launch into the same pane. The refused launch already took back the tab it was + * shown, so the tab closes and returns at spawn (rare: 512 launches a day); the host's reveal at + * spawn hands the pane over (`terminal-presentation-ipc-bridge`). + */ +async function launchWithoutRecord( + args: HostAgentLaunchArgs, + pane: { tabId: string; leafId: string } +): Promise { + try { + return outcomeFromResult( + await callRuntimeRpc({ kind: 'local' }, 'agent.launch', { + ...launchParams(args), + paneKey: makePaneKey(pane.tabId, pane.leafId) + }) + ) + } catch (error) { + const code = error instanceof RuntimeRpcCallError ? error.code : undefined + // A pane the host never revealed would open as a shell; one it did holds the agent's terminal. + if (isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId)) { + closeLaunchTab(args.worktreeId, pane.tabId) + } + return { kind: 'not-started', unconfirmed: false, ...(code ? { code } : {}) } + } +} + +async function settleLaunch( + args: HostAgentLaunchArgs, + pane: { tabId: string; leafId: string }, + send: Promise, + releaseHold: () => void +): Promise { + try { + return outcomeFromResult(await send) + } catch (error) { + const code = error instanceof RuntimeRpcCallError ? error.code : undefined + if (code === AGENT_LAUNCH_TAB_CLOSED_CODE) { + return { kind: 'closed-by-user' } + } + if (code === 'agent_session_operation_capacity') { + // Awaited: the pane stays held until the unrecorded launch has its answer. + return await launchWithoutRecord(args, pane) + } + // The host took the pane once it showed the tab; a pane it never took would open as a shell, + // and a refused one is the host's to take back. + const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) + if (!hostTookPane || (code !== undefined && ADMISSION_REFUSAL_CODES.has(code))) { + closeLaunchTab(args.worktreeId, pane.tabId) + return { + kind: 'not-started', + unconfirmed: code === 'agent_session_operation_unknown', + ...(code ? { code } : {}) + } + } + return { kind: 'pane-says' } + } finally { + releaseHold() + } +} + +/** Where the host could turn a launch into a chat (chat is the default), this window's paste has no + * terminal to go to, so such a launch keeps main's own path. */ +export function windowMakesHostLaunchTab(): boolean { + return !prefersStructuredNativeChatByDefault(useAppStore.getState().settings) +} + +export function launchAgentThroughHost(args: HostAgentLaunchArgs): { + tabId: string + outcome: Promise +} { + const store = useAppStore.getState() + const tabId = createBrowserUuid() + const leafId = createBrowserUuid() + // Before the tab exists, so its first mount already waits. + const releaseHold = holdAgentLaunchPaneSpawn(tabId, leafId) + const send = callRuntimeRpc({ kind: 'local' }, 'agent.launchReplay', { + ...launchParams(args), + // A new click is a new operation; the pane is this click's too. + operationId: createAgentSessionOperationId(), + paneKey: makePaneKey(tabId, leafId) + }) + store.createTab(args.worktreeId, args.groupId, undefined, { + id: tabId, + initialLeafId: leafId, + agentLaunchPane: { leafId }, + launchAgent: args.agent, + quickCommandLabel: args.quickCommandLabel, + ...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), + ...(args.viewMode ? { viewMode: args.viewMode } : {}) + }) + rememberAgentLaunchPanePrompt(tabId, args.prompt) + seedNativeChatAppliedSessionOptions(tabId, args.agent, args.sessionOptions) + // Why: without it an activated launch can stay hidden behind an editor. + store.setActiveTabType('terminal', args.worktreeId) + persistAgentLaunchTabOrder(args.worktreeId, tabId) + return { tabId, outcome: settleLaunch(args, { tabId, leafId }, send, releaseHold) } +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index adf90ead68bf..f9c41da3ef01 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -116,6 +116,13 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +// Why: this file pins main's window launch and its paste, which chat-default and paired launches +// still take; an AI button's host launch reuses that paste and is pinned in its own tests. +vi.mock('@/lib/launch-agent-new-tab-host-route', () => ({ + newTabPromptLaunchesThroughHost: () => false, + launchNewTabPromptThroughHost: vi.fn() +})) + vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 87ddca84bd2b..3fbd07d2095c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -3,11 +3,12 @@ import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste' import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab -} from '@/lib/agent-launch-prompt-delivery' + launchNewTabPromptThroughHost, + newTabPromptLaunchesThroughHost +} from '@/lib/launch-agent-new-tab-host-route' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' @@ -18,7 +19,6 @@ import { resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' import type { TuiAgent } from '../../../shared/tui-agent' import type { LaunchSource } from '../../../shared/telemetry-events' import { resolveAgentLaunchExecutionContext } from '@/lib/launch-agent-execution-context' @@ -244,6 +244,35 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent if (beforeSurfaceOpen?.({ kind: 'local-terminal' }) === false) { return null } + if ( + pasteDraftAfterLaunch !== null && + newTabPromptLaunchesThroughHost({ promptDelivery, pastesPrompt: true }) + ) { + const launched = launchNewTabPromptThroughHost({ + agent, + worktreeId, + ...(groupId ? { groupId } : {}), + prompt: trimmedPrompt, + ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(initialCwd?.trim() ? { cwd: initialCwd } : {}), + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), + // The same source main's window stamps on its own launches. + launchSource: launchSource ?? 'tab_bar_quick_launch', + quickCommandLabel, + ...(pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), + ...(initialViewModeProps.viewMode ? { viewMode: initialViewModeProps.viewMode } : {}), + pasteContent: pasteDraftAfterLaunch, + submit: submitPastedPrompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) + return { + surface: { kind: 'local-terminal', tabId: launched.tabId }, + startupPlan, + pasteDraftAfterLaunch: true, + promptDeliveryResult: launched.promptDeliveryResult + } + } // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { @@ -284,30 +313,15 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent seedNativeChatLaunchDraftForAgentTab({ tabId: tab.id, agent, text: trimmedPrompt }) } if (pasteDraftAfterLaunch !== null) { - const timeoutNotice = createPasteReadinessTimeoutNotice({ + const deliveryPromise = pasteAgentLaunchPromptOnceReady({ worktreeId, tabId: tab.id, agent, - submitted: submitPastedPrompt - }) - const deliveryPromise = deliverLaunchPromptToAgentTab({ - tabId: tab.id, content: pasteDraftAfterLaunch, - agent, submit: submitPastedPrompt, - forcePaste: true, - onTimeout: timeoutNotice.onTimeout, - ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) - }).then((delivered) => { - if (delivered) { - if (agent === 'command-code' && submitPastedPrompt) { - // Why: Command Code has no prompt-submit hook; when Orca submits a - // generated prompt after readiness, seed working at delivery time. - seedCommandCodeSubmittedPromptStatus(worktreeId, tab.id, trimmedPrompt) - } - onPromptDelivered?.() - } - return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } + prompt: trimmedPrompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) }) if (promptDelivery === 'submit-after-ready') { promptDeliveryResult = deliveryPromise diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts new file mode 100644 index 000000000000..5578f2b54641 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostAgentLaunchOutcome } from './agent-launch-through-host' + +const host = vi.hoisted(() => ({ + launchAgentThroughHost: vi.fn(), + windowMakesHostLaunchTab: vi.fn(() => true) +})) +vi.mock('@/lib/agent-launch-through-host', () => host) +const pasteAgentLaunchPromptOnceReady = vi.hoisted(() => + vi.fn(async () => ({ delivered: true, failureNotified: false })) +) +vi.mock('@/lib/launch-agent-tab-prompt-paste', () => ({ pasteAgentLaunchPromptOnceReady })) +const toast = vi.hoisted(() => ({ error: vi.fn() })) +vi.mock('sonner', () => ({ toast })) + +const { launchNewTabPromptThroughHost, newTabPromptLaunchesThroughHost } = + await import('./launch-agent-new-tab-host-route') + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + +function deferredOutcome() { + let resolve!: (outcome: HostAgentLaunchOutcome) => void + const promise = new Promise((done) => (resolve = done)) + host.launchAgentThroughHost.mockReturnValue({ tabId: TAB, outcome: promise }) + return resolve +} + +function launch() { + return launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'fix the failing checks', + pasteContent: 'fix the failing checks', + submit: true + }) +} + +beforeEach(() => { + vi.clearAllMocks() + host.windowMakesHostLaunchTab.mockReturnValue(true) +}) + +describe('an AI button launched through the host', () => { + // Why: the paste must meet the host's agent, never a shell this window spawned first, and its + // readiness budget must not be spent while the host is still spawning. + it('starts no readiness wait or paste before the host has its agent in the tab', async () => { + const answer = deferredOutcome() + const { tabId, promptDeliveryResult } = launch() + await Promise.resolve() + expect(pasteAgentLaunchPromptOnceReady).not.toHaveBeenCalled() + + answer({ kind: 'started' }) + + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(pasteAgentLaunchPromptOnceReady).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ tabId, worktreeId: 'wt-1', submit: true }) + ) + expect(tabId).toBe(TAB) + }) + + it('never pastes for a launch refused before the agent ran, and says so once', async () => { + deferredOutcome()({ kind: 'not-started', unconfirmed: false, code: 'worktree_not_found' }) + + await expect(launch().promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + expect(pasteAgentLaunchPromptOnceReady).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledOnce() + }) + + it('says nothing and pastes nothing when the user closed the tab, or its pane explains', async () => { + for (const outcome of [{ kind: 'closed-by-user' }, { kind: 'pane-says' }] as const) { + deferredOutcome()(outcome) + await expect(launch().promptDeliveryResult).resolves.toEqual({ + delivered: false, + failureNotified: true + }) + } + expect(pasteAgentLaunchPromptOnceReady).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) +}) + +describe('which new agent tabs start through the host', () => { + it('an AI button whose prompt is pasted once ready, in a terminal this window makes', () => { + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + ).toBe(true) + }) + + it('never a typed prompt, nor a launch the host could turn into a chat', () => { + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'auto-submit', pastesPrompt: true }) + ).toBe(false) + expect(newTabPromptLaunchesThroughHost({ promptDelivery: 'draft', pastesPrompt: true })).toBe( + false + ) + host.windowMakesHostLaunchTab.mockReturnValue(false) + expect( + newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts new file mode 100644 index 000000000000..6dd9f8fb2c1e --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -0,0 +1,87 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { agentLaunchPaneNoticeText } from '@/components/terminal-pane/agent-launch-pane-notice-text' +import { + launchAgentThroughHost, + windowMakesHostLaunchTab, + type HostAgentLaunchArgs, + type HostAgentLaunchOutcome +} from '@/lib/agent-launch-through-host' +import { pasteAgentLaunchPromptOnceReady } from '@/lib/launch-agent-tab-prompt-paste' + +/** + * Whether a new agent tab starts through the host's `agent.launch`: an AI button's launch, whose + * prompt is pasted once the agent is ready, in a terminal this window makes. Temporary: the window + * keeps pasting the prompt as main does until the host delivers it. A typed prompt (`auto-submit`, + * `draft`) keeps main's launch, and so does a launch the host could turn into a chat. + */ +export function newTabPromptLaunchesThroughHost(args: { + promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' + pastesPrompt: boolean +}): boolean { + return ( + args.promptDelivery === 'submit-after-ready' && args.pastesPrompt && windowMakesHostLaunchTab() + ) +} + +/** The tab is gone, so the pane's own words go in a notice, with its prompt to copy. */ +function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: string): void { + if (outcome.kind !== 'not-started') { + return + } + toast.error( + agentLaunchPaneNoticeText( + outcome.unconfirmed + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: outcome.code ?? '' } + ), + { + action: { + label: translate( + 'auto.components.terminal.pane.AgentLaunchPaneNotice.copyPrompt', + 'Copy prompt' + ), + onClick: () => void window.api.ui.writeClipboardText(prompt) + } + } + ) +} + +/** + * Starts the agent through the host with no prompt, then pastes the prompt as main does, once the + * host's agent holds this tab's pane: never before, so the paste never meets a shell this window + * spawned, and its readiness budget is not spent on the host's spawn. + */ +export function launchNewTabPromptThroughHost( + args: HostAgentLaunchArgs & { + /** What is pasted, which can differ from the prompt the user wrote. */ + pasteContent: string + submit: boolean + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void + } +): { + tabId: string + promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> +} { + const { pasteContent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed, ...launch } = args + const { tabId, outcome } = launchAgentThroughHost(launch) + const promptDeliveryResult = outcome.then((launched) => { + if (launched.kind === 'started') { + return pasteAgentLaunchPromptOnceReady({ + worktreeId: args.worktreeId, + tabId, + agent: args.agent, + content: pasteContent, + submit, + prompt: args.prompt, + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) + } + // The pane, or this notice for a tab that went, already says why: never a second notice. + showLaunchNotStartedNotice(launched, args.prompt) + return { delivered: false, failureNotified: true } + }) + return { tabId, promptDeliveryResult } +} diff --git a/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts new file mode 100644 index 000000000000..a54bb73534a4 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts @@ -0,0 +1,49 @@ +import { createPasteReadinessTimeoutNotice } from '@/lib/launch-agent-paste-timeout-notice' +import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' +import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed' +import type { TuiAgent } from '../../../shared/tui-agent' + +/** + * Pastes a new agent tab's prompt once its agent is ready, and says whether it landed. The one copy + * of this delivery, for a tab whose terminal this window spawned and for one an `agent.launch` + * spawned into it. + */ +export function pasteAgentLaunchPromptOnceReady(args: { + worktreeId: string + tabId: string + agent: TuiAgent + /** What is pasted, which can differ from the prompt the user wrote. */ + content: string + submit: boolean + /** The prompt as written, which Command Code's working row shows. */ + prompt: string + onPromptDelivered?: () => void + onPromptDeliveryUnconfirmed?: () => void +}): Promise<{ delivered: boolean; failureNotified: boolean }> { + const { worktreeId, tabId, agent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed } = args + const timeoutNotice = createPasteReadinessTimeoutNotice({ + worktreeId, + tabId, + agent, + submitted: submit + }) + return deliverLaunchPromptToAgentTab({ + tabId, + content: args.content, + agent, + submit, + forcePaste: true, + onTimeout: timeoutNotice.onTimeout, + ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) + }).then((delivered) => { + if (delivered) { + if (agent === 'command-code' && submit) { + // Why: Command Code has no prompt-submit hook; when Orca submits a + // generated prompt after readiness, seed working at delivery time. + seedCommandCodeSubmittedPromptStatus(worktreeId, tabId, args.prompt) + } + onPromptDelivered?.() + } + return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } + }) +} diff --git a/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts b/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts new file mode 100644 index 000000000000..390af35ebe53 --- /dev/null +++ b/src/renderer/src/lib/source-control-agent-action-launch-check.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { checkSourceControlAgentActionLaunch } from './source-control-agent-action-launch-check' + +const BASE = { + agent: 'codex', + commandInput: 'Fix checks', + detectedAgents: ['codex'], + platform: 'linux' +} as const + +describe('checkSourceControlAgentActionLaunch', () => { + it('passes a launch the user has nothing to fix in', () => { + expect(checkSourceControlAgentActionLaunch({ ...BASE, detectedAgents: ['codex'] })).toEqual({ + ok: true + }) + }) + + it('rejects disabled agents', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + disabledAgents: ['codex'] + }) + ).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' }) + }) + + it('rejects agents not detected on the current host', () => { + expect( + checkSourceControlAgentActionLaunch({ ...BASE, agent: 'claude', detectedAgents: ['codex'] }) + ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) + }) + + it('rejects an empty command input', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + commandInput: ' ' + }) + ).toEqual({ ok: false, error: 'Command input is empty.' }) + }) + + it('rejects invalid per-action CLI arguments', () => { + expect( + checkSourceControlAgentActionLaunch({ + ...BASE, + detectedAgents: ['codex'], + agentArgs: '--model "unterminated' + }) + ).toEqual({ + ok: false, + error: 'CLI arguments are invalid: Unclosed quote in command template.' + }) + }) +}) diff --git a/src/renderer/src/lib/source-control-agent-action-launch-check.ts b/src/renderer/src/lib/source-control-agent-action-launch-check.ts new file mode 100644 index 000000000000..cd45284f2000 --- /dev/null +++ b/src/renderer/src/lib/source-control-agent-action-launch-check.ts @@ -0,0 +1,100 @@ +import { buildAgentStartupPlan, planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../shared/tui-agent' +import { translate } from '@/i18n/i18n' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' + +/** + * What the source-control dialog checks before it starts an agent: what the user can fix here (the + * agent, its arguments, the input). How the prompt then reaches the agent is the host's to decide + * (`agent.launch`), so nothing here predicts it. + */ +export type SourceControlLaunchCheckResult = { ok: true } | { ok: false; error: string } + +export function checkSourceControlAgentActionLaunch(args: { + agent: TuiAgent | null + commandInput: string + detectedAgents: TuiAgent[] + disabledAgents?: TuiAgent[] + cmdOverrides?: Partial> + agentArgs?: string | null + platform?: NodeJS.Platform + terminalWindowsShell?: string | null + /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only + * `orca-ide` rename must not be applied for remote launches. */ + isRemote?: boolean +}): SourceControlLaunchCheckResult { + const agent = args.agent + if (!agent) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.a7ac8717c7', + 'Choose an agent before starting.' + ) + } + } + if (!isTuiAgentEnabled(agent, args.disabledAgents)) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.b96e091fc9', + 'The selected agent is disabled in Settings.' + ) + } + } + if (!args.detectedAgents.includes(agent)) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.8eb541cc83', + 'The selected agent was not detected on this workspace host.' + ) + } + } + + if (!args.commandInput.trim()) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.46f1a2c9bd', + 'Command input is empty.' + ) + } + } + + const platform = args.platform ?? CLIENT_PLATFORM + const isRemote = args.isRemote ?? false + const shell = + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: args.terminalWindowsShell + }) ?? (platform === 'win32' ? 'powershell' : 'posix') + const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) + if (!plannedArgs.ok) { + return { ok: false, error: plannedArgs.error } + } + // The agent's own command, prompt aside: an override or arguments it cannot build are the user's. + const launchable = buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: args.cmdOverrides ?? {}, + platform, + shell, + isRemote, + agentArgs: args.agentArgs, + allowEmptyPromptLaunch: true + }) + if (!launchable) { + return { + ok: false, + error: translate( + 'auto.lib.source.control.agent.action.plan.3f0ea9aa0d', + 'Could not build the agent launch command.' + ) + } + } + return { ok: true } +} diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts deleted file mode 100644 index a1cb9b6d3edd..000000000000 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { planSourceControlAgentActionLaunch } from './source-control-agent-action-plan' - -describe('planSourceControlAgentActionLaunch', () => { - it('rejects disabled agents', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - disabledAgents: ['codex'], - platform: 'darwin' - }) - ).toEqual({ ok: false, error: 'The selected agent is disabled in Settings.' }) - }) - - it('rejects agents not detected on the current host', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'claude', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) - }) - - it('mirrors submit-after-ready delivery without embedding the prompt in the command', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - - expect(result.ok && result.delivery).toBe('paste-submit') - expect(result.ok && result.commandLabel).toBe('codex') - expect(result.ok && result.summary).toContain('pastes and submits') - expect(result.ok && result.caveat).toContain('PATH') - }) - - it('includes per-action CLI arguments in submit-after-ready launch plans', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - agentArgs: '--model gpt-5.5', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - - expect(result.ok && result.commandLabel).toBe("codex '--model' 'gpt-5.5'") - }) - - it.each([ - { - terminalWindowsShell: 'cmd.exe', - expectedCommand: 'powershell.exe -NoProfile -EncodedCommand' - }, - { - terminalWindowsShell: 'git-bash', - expectedCommand: 'ORCA_HERMES_STARTUP_QUERY' - } - ])( - 'uses $terminalWindowsShell quoting for Hermes source-control prompts', - ({ terminalWindowsShell, expectedCommand }) => { - const result = planSourceControlAgentActionLaunch({ - agent: 'hermes', - commandInput: 'Review the change', - promptDelivery: 'auto-submit', - detectedAgents: ['hermes'], - platform: 'win32', - terminalWindowsShell - }) - - expect(result.ok && result.plan.launchCommand).toContain(expectedCommand) - expect(result.ok && result.plan.env?.ORCA_HERMES_STARTUP_QUERY).toBe('Review the change') - } - ) - - it('rejects invalid per-action CLI arguments', () => { - expect( - planSourceControlAgentActionLaunch({ - agent: 'codex', - commandInput: 'Fix checks', - agentArgs: '--model "unterminated', - promptDelivery: 'submit-after-ready', - detectedAgents: ['codex'], - platform: 'linux' - }) - ).toEqual({ - ok: false, - error: 'CLI arguments are invalid: Unclosed quote in command template.' - }) - }) - - it('uses native draft launch when the selected agent supports it', () => { - const result = planSourceControlAgentActionLaunch({ - agent: 'claude', - commandInput: 'Fix checks', - promptDelivery: 'draft', - detectedAgents: ['claude'], - platform: 'darwin' - }) - - expect(result.ok && result.delivery).toBe('draft-native') - expect(result.ok && result.commandLabel).toContain('--prefill') - }) -}) diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts deleted file mode 100644 index 5e6681975e6a..000000000000 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - planAgentCliArgsSuffix, - type AgentStartupPlan -} from '@/lib/tui-agent-startup' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../../shared/tui-agent-selection' -import type { TuiAgent } from '../../../shared/tui-agent' -import { translate } from '@/i18n/i18n' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' -import type { SessionOptionValue } from '../../../shared/native-chat-session-options' - -export type SourceControlLaunchPlanDelivery = - | 'argv' - | 'draft-native' - | 'draft-paste' - | 'paste-submit' - -export type SourceControlLaunchPlanResult = - | { - ok: true - plan: AgentStartupPlan - delivery: SourceControlLaunchPlanDelivery - commandLabel: string - summary: string - caveat: string - } - | { ok: false; error: string } - -export function planSourceControlAgentActionLaunch(args: { - agent: TuiAgent | null - commandInput: string - promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' - detectedAgents: TuiAgent[] - disabledAgents?: TuiAgent[] - cmdOverrides?: Partial> - agentArgs?: string | null - sessionOptions?: Record - platform?: NodeJS.Platform - terminalWindowsShell?: string | null - /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - * `orca-ide` rename must not be applied for remote launches. */ - isRemote?: boolean -}): SourceControlLaunchPlanResult { - const agent = args.agent - if (!agent) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.a7ac8717c7', - 'Choose an agent before starting.' - ) - } - } - if (!isTuiAgentEnabled(agent, args.disabledAgents)) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.b96e091fc9', - 'The selected agent is disabled in Settings.' - ) - } - } - if (!args.detectedAgents.includes(agent)) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.8eb541cc83', - 'The selected agent was not detected on this workspace host.' - ) - } - } - - const trimmedInput = args.commandInput.trim() - if (!trimmedInput) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.46f1a2c9bd', - 'Command input is empty.' - ) - } - } - - const cmdOverrides = args.cmdOverrides ?? {} - const platform = args.platform ?? CLIENT_PLATFORM - const isRemote = args.isRemote ?? false - const shell = - resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: args.terminalWindowsShell - }) ?? (platform === 'win32' ? 'powershell' : 'posix') - const plannedArgs = planAgentCliArgsSuffix(args.agentArgs, shell) - if (!plannedArgs.ok) { - return { ok: false, error: plannedArgs.error } - } - let startupPlan: AgentStartupPlan | null = null - let delivery: SourceControlLaunchPlanDelivery - - if (args.promptDelivery === 'submit-after-ready') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'paste-submit' - } else if (args.promptDelivery === 'draft') { - const draftLaunchPlan = buildAgentDraftLaunchPlan({ - agent, - draft: trimmedInput, - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions - }) - if (draftLaunchPlan) { - startupPlan = { - agent: draftLaunchPlan.agent, - launchCommand: draftLaunchPlan.launchCommand, - expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, - launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.sessionOptions - ? { sessionOptions: draftLaunchPlan.sessionOptions } - : {}), - ...(draftLaunchPlan.startupCommandDelivery - ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } - : {}), - ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } - delivery = 'draft-native' - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' - } - } else if (TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start') { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: '', - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: true - }) - delivery = 'draft-paste' - } else { - startupPlan = buildAgentStartupPlan({ - agent, - prompt: trimmedInput, - cmdOverrides, - platform, - shell, - isRemote, - agentArgs: args.agentArgs, - sessionOptions: args.sessionOptions, - allowEmptyPromptLaunch: false - }) - delivery = 'argv' - } - - if (!startupPlan) { - return { - ok: false, - error: translate( - 'auto.lib.source.control.agent.action.plan.3f0ea9aa0d', - 'Could not build the agent launch command.' - ) - } - } - - const summary = - delivery === 'paste-submit' - ? 'The agent starts with no prompt, then Orca pastes and submits the command input after the TUI is ready.' - : delivery === 'draft-native' - ? 'The command input is prefilled as an editable draft by the agent launch command.' - : delivery === 'draft-paste' - ? 'The agent starts with no prompt, then Orca pastes the command input as an editable draft after the TUI is ready.' - : 'The command input is included in the launch command and submitted as the first turn.' - - return { - ok: true, - plan: startupPlan, - delivery, - commandLabel: startupPlan.launchCommand, - summary, - caveat: - 'This check builds Orca’s launch plan only. PATH, binary availability, account setup, and terminal startup failures are still caught by the real launch watchdog.' - } -} diff --git a/src/shared/agent-launch-tab-publication.ts b/src/shared/agent-launch-tab-publication.ts index 2eb8741988d0..b4defa0e688b 100644 --- a/src/shared/agent-launch-tab-publication.ts +++ b/src/shared/agent-launch-tab-publication.ts @@ -43,7 +43,8 @@ export type AgentLaunchTabPublishReply = | { requestId: string tabId: string - /** False when a tab with this id already existed and was reused: a retry. */ + /** True when the tab exists only for this launch: the window made it now, or made it for this + * launch just before asking (a desktop launch). False for a retry that found the tab. */ created: boolean placement: AgentLaunchPlacementReceipt } 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 4/5] 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: { From 23af42cac24eaeb611e5b89c1ef543cfbdc0b53a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:49:15 -0700 Subject: [PATCH 5/5] fix(agent-launch): watch an AI button's agent for readiness from its first output The window started watching for the agent's ready signal only after the host answered agent.launch, so an agent that had already turned on bracketed paste by then was never seen ready and its prompt waited out the readiness budget. Readiness is now watched from the moment the tab's terminal exists, as main watches it; the paste is written, and the chat copy seeded, only once the host says its agent started in that tab. --- .../src/lib/agent-launch-prompt-delivery.ts | 46 ++++-- src/renderer/src/lib/agent-paste-draft.ts | 9 +- ...agent-new-tab-host-route-readiness.test.ts | 156 ++++++++++++++++++ .../launch-agent-new-tab-host-route.test.ts | 34 ++-- .../lib/launch-agent-new-tab-host-route.ts | 33 ++-- .../src/lib/launch-agent-tab-prompt-paste.ts | 3 + 6 files changed, 242 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index ad9e1f1d8c84..50b0a8796806 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -35,23 +35,38 @@ export function deliverLaunchPromptToAgentTab(args: { onTimeout?: () => void /** The paste was written without ever observing the agent's composer. */ onUnconfirmedDelivery?: () => void + /** Whether the paste may be written; the chat copy is seeded only once it opens. */ + sendGate?: Promise }): Promise { const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args const shouldSeed = submit === true && content.trim().length > 0 && isNativeChatSupportedAgent(agent) - - if (shouldSeed) { - useAppStore.getState().seedNativeChatLaunchPrompt({ - tabId, - agent, - text: content, - createdAt: Date.now() - }) - } else if (submit !== true) { - // Why: an unsubmitted draft lives only in the TUI input buffer; seed the - // chat-composer copy so the context isn't invisible in the GUI view. - seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + let seeded = false + const seedChatCopy = (): void => { + if (shouldSeed) { + seeded = true + useAppStore.getState().seedNativeChatLaunchPrompt({ + tabId, + agent, + text: content, + createdAt: Date.now() + }) + } else if (submit !== true) { + // Why: an unsubmitted draft lives only in the TUI input buffer; seed the + // chat-composer copy so the context isn't invisible in the GUI view. + seedNativeChatLaunchDraftForAgentTab({ tabId, agent, text: content }) + } + } + // Chained ahead of the paste's own wait on the gate, so the copy always precedes the send. + const sendGate = args.sendGate?.then((open) => { + if (open) { + seedChatCopy() + } + return open + }) + if (!sendGate) { + seedChatCopy() } // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, @@ -67,16 +82,17 @@ export function deliverLaunchPromptToAgentTab(args: { forcePaste, timeoutMs, onTimeout, - onUnconfirmedDelivery + onUnconfirmedDelivery, + ...(sendGate ? { sendGate } : {}) }).then( (delivered) => { - if (shouldSeed && !delivered && !deliversViaNativePrefill) { + if (seeded && !delivered && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } return delivered || deliversViaNativePrefill }, (error) => { - if (shouldSeed && !deliversViaNativePrefill) { + if (seeded && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } throw error diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 042fe0a6956c..8a6fd9629254 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -84,6 +84,8 @@ export async function pasteDraftWhenAgentReady(args: { timeoutMs?: number onTimeout?: () => void onUnconfirmedDelivery?: () => void + /** Whether the paste may be written at all; readiness is observed while it is pending. */ + sendGate?: Promise }): Promise { const { tabId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args @@ -102,13 +104,18 @@ export async function pasteDraftWhenAgentReady(args: { const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) - const readiness = await waitForAgentDraftInputReadyOnTab({ + const readinessResult = waitForAgentDraftInputReadyOnTab({ tabId, spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, readySignal, settings }) + // Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses. + if (args.sendGate && !(await args.sendGate)) { + return false + } + const readiness = await readinessResult if (!readiness) { onTimeout?.() return false diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts new file mode 100644 index 000000000000..23206c874359 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route-readiness.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostAgentLaunchOutcome } from './agent-launch-through-host' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' + +const testState = vi.hoisted(() => { + const ptyIdsByTabId: Record = {} + const writes: { at: number; data: string }[] = [] + return { + appState: { + settings: {}, + activeWorktreeId: 'wt-1', + tabsByWorktree: {}, + ptyIdsByTabId, + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: vi.fn() + }, + storeSubscribers: new Set<(state: unknown) => void>(), + ptyWatchers: new Map void>(), + writes, + launchAgentThroughHost: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => testState.appState, + subscribe: (subscriber: (state: unknown) => void) => { + testState.storeSubscribers.add(subscriber) + return () => testState.storeSubscribers.delete(subscriber) + } + } +})) +vi.mock('@/lib/agent-launch-through-host', () => ({ + launchAgentThroughHost: testState.launchAgentThroughHost, + windowMakesHostLaunchTab: () => true +})) +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: (ptyId: string, watcher: (data: string) => void) => { + testState.ptyWatchers.set(ptyId, watcher) + return () => testState.ptyWatchers.delete(ptyId) + } +})) +vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ + replayPreHandlerPtyData: vi.fn() +})) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + inspectRuntimeTerminalProcess: vi.fn(async () => null), + sendRuntimePtyInputVerified: async (_settings: unknown, _ptyId: string, data: string) => { + testState.writes.push({ at: Date.now(), data }) + return true + } +})) +vi.mock('@/runtime/runtime-terminal-stream', () => ({ subscribeToRuntimeTerminalData: vi.fn() })) +vi.mock('./agent-ready-wait', () => ({ waitForAgentReady: vi.fn(async () => ({ ready: false })) })) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: () => 'claude' })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) + +const { launchNewTabPromptThroughHost } = await import('./launch-agent-new-tab-host-route') + +function attachHostPty(ptyId: string): void { + testState.appState.ptyIdsByTabId = { [TAB]: [ptyId] } + for (const subscriber of testState.storeSubscribers) { + subscriber(testState.appState) + } +} + +describe('an AI button whose agent is ready before the host answers', () => { + beforeEach(() => { + vi.useFakeTimers({ now: 0 }) + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout + }) + testState.appState.ptyIdsByTabId = {} + testState.storeSubscribers.clear() + testState.ptyWatchers.clear() + testState.writes = [] + testState.appState.seedNativeChatLaunchPrompt.mockClear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + // Regression: the paste watched the terminal only after the host's reply, so an agent that had + // already enabled bracketed paste was never seen ready and its prompt waited out the budget. + it('is pasted at its own ready signal plus the quiet window, not after the timeout', async () => { + let answer!: (outcome: HostAgentLaunchOutcome) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + outcome: new Promise((done) => (answer = done)) + }) + const onPromptDeliveryUnconfirmed = vi.fn() + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true, + onPromptDeliveryUnconfirmed + }) + + await vi.advanceTimersByTimeAsync(50) + attachHostPty('pty-1') + await vi.advanceTimersByTimeAsync(10) + testState.ptyWatchers.get('pty-1')?.('claude drawn\r\n\x1b[?2004h> ') + await vi.advanceTimersByTimeAsync(240) + expect(testState.writes).toEqual([]) + expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + + answer({ kind: 'started' }) + await vi.advanceTimersByTimeAsync(1200) + expect(testState.writes).toEqual([]) + + // Ready at 60 ms + the 1.5 s quiet window. + await vi.advanceTimersByTimeAsync(60) + expect(testState.writes[0]).toEqual({ + at: 1560, + data: '\x1b[200~resolve the conflicts\x1b[201~' + }) + expect(testState.appState.seedNativeChatLaunchPrompt).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(2000) + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(onPromptDeliveryUnconfirmed).not.toHaveBeenCalled() + }) + + it('writes nothing, and seeds no chat copy, when the host says its agent did not start', async () => { + let answer!: (outcome: HostAgentLaunchOutcome) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + outcome: new Promise((done) => (answer = done)) + }) + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true + }) + + attachHostPty('pty-1') + testState.ptyWatchers.get('pty-1')?.('\x1b[?2004h> ') + answer({ kind: 'pane-says' }) + await vi.advanceTimersByTimeAsync(10_000) + + await expect(promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) + expect(testState.writes).toEqual([]) + expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() + expect(testState.appState.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts index 5578f2b54641..54213717101b 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.test.ts @@ -7,7 +7,10 @@ const host = vi.hoisted(() => ({ })) vi.mock('@/lib/agent-launch-through-host', () => host) const pasteAgentLaunchPromptOnceReady = vi.hoisted(() => - vi.fn(async () => ({ delivered: true, failureNotified: false })) + vi.fn(async (_args: { sendGate?: Promise }) => ({ + delivered: true, + failureNotified: false + })) ) vi.mock('@/lib/launch-agent-tab-prompt-paste', () => ({ pasteAgentLaunchPromptOnceReady })) const toast = vi.hoisted(() => ({ error: vi.fn() })) @@ -41,20 +44,31 @@ beforeEach(() => { }) describe('an AI button launched through the host', () => { - // Why: the paste must meet the host's agent, never a shell this window spawned first, and its - // readiness budget must not be spent while the host is still spawning. - it('starts no readiness wait or paste before the host has its agent in the tab', async () => { + function sendGate(): Promise { + const gate = pasteAgentLaunchPromptOnceReady.mock.calls.at(-1)?.[0].sendGate + if (!gate) { + throw new Error('the paste was set up without a send gate') + } + return gate + } + + // Why: readiness is watched from the tab's first output, as main watches it, but the paste must + // meet the host's agent, never a shell this window spawned first. + it('watches for readiness at once but writes only once the host has its agent in the tab', async () => { const answer = deferredOutcome() const { tabId, promptDeliveryResult } = launch() + expect(pasteAgentLaunchPromptOnceReady).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ tabId, worktreeId: 'wt-1', submit: true }) + ) + let gateOpen: boolean | undefined + void sendGate().then((open) => (gateOpen = open)) await Promise.resolve() - expect(pasteAgentLaunchPromptOnceReady).not.toHaveBeenCalled() + expect(gateOpen).toBeUndefined() answer({ kind: 'started' }) + await expect(sendGate()).resolves.toBe(true) await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) - expect(pasteAgentLaunchPromptOnceReady).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ tabId, worktreeId: 'wt-1', submit: true }) - ) expect(tabId).toBe(TAB) }) @@ -65,7 +79,7 @@ describe('an AI button launched through the host', () => { delivered: false, failureNotified: true }) - expect(pasteAgentLaunchPromptOnceReady).not.toHaveBeenCalled() + await expect(sendGate()).resolves.toBe(false) expect(toast.error).toHaveBeenCalledOnce() }) @@ -76,8 +90,8 @@ describe('an AI button launched through the host', () => { delivered: false, failureNotified: true }) + await expect(sendGate()).resolves.toBe(false) } - expect(pasteAgentLaunchPromptOnceReady).not.toHaveBeenCalled() expect(toast.error).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts index 6dd9f8fb2c1e..47a7f2675e2f 100644 --- a/src/renderer/src/lib/launch-agent-new-tab-host-route.ts +++ b/src/renderer/src/lib/launch-agent-new-tab-host-route.ts @@ -48,9 +48,10 @@ function showLaunchNotStartedNotice(outcome: HostAgentLaunchOutcome, prompt: str } /** - * Starts the agent through the host with no prompt, then pastes the prompt as main does, once the - * host's agent holds this tab's pane: never before, so the paste never meets a shell this window - * spawned, and its readiness budget is not spent on the host's spawn. + * Starts the agent through the host with no prompt and pastes the prompt as main does. Readiness is + * watched from the moment the tab's terminal exists, as main watches it, so an agent that is ready + * before the host answers is not missed; the paste is written only once the host has started its + * agent in this tab, so it never meets a shell this window spawned. */ export function launchNewTabPromptThroughHost( args: HostAgentLaunchArgs & { @@ -66,18 +67,24 @@ export function launchNewTabPromptThroughHost( } { const { pasteContent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed, ...launch } = args const { tabId, outcome } = launchAgentThroughHost(launch) + // Only the host's agent can fill this tab's terminal while the window's own spawn is held. + const pasted = pasteAgentLaunchPromptOnceReady({ + worktreeId: args.worktreeId, + tabId, + agent: args.agent, + content: pasteContent, + submit, + prompt: args.prompt, + sendGate: outcome.then( + (launched) => launched.kind === 'started', + () => false + ), + ...(onPromptDelivered ? { onPromptDelivered } : {}), + ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) + }) const promptDeliveryResult = outcome.then((launched) => { if (launched.kind === 'started') { - return pasteAgentLaunchPromptOnceReady({ - worktreeId: args.worktreeId, - tabId, - agent: args.agent, - content: pasteContent, - submit, - prompt: args.prompt, - ...(onPromptDelivered ? { onPromptDelivered } : {}), - ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) - }) + return pasted } // The pane, or this notice for a tab that went, already says why: never a second notice. showLaunchNotStartedNotice(launched, args.prompt) diff --git a/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts index a54bb73534a4..de3fa40cf452 100644 --- a/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts +++ b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts @@ -19,6 +19,8 @@ export function pasteAgentLaunchPromptOnceReady(args: { prompt: string onPromptDelivered?: () => void onPromptDeliveryUnconfirmed?: () => void + /** Whether the paste may be written; readiness is observed while it is pending. */ + sendGate?: Promise }): Promise<{ delivered: boolean; failureNotified: boolean }> { const { worktreeId, tabId, agent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed } = args const timeoutNotice = createPasteReadinessTimeoutNotice({ @@ -34,6 +36,7 @@ export function pasteAgentLaunchPromptOnceReady(args: { submit, forcePaste: true, onTimeout: timeoutNotice.onTimeout, + ...(args.sendGate ? { sendGate: args.sendGate } : {}), ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) }).then((delivered) => { if (delivered) {