diff --git a/src/main/agent-launch/agent-launch-token.ts b/src/main/agent-launch/agent-launch-token.ts new file mode 100644 index 000000000000..78655bfe5387 --- /dev/null +++ b/src/main/agent-launch/agent-launch-token.ts @@ -0,0 +1,7 @@ +/** An agent launch with a launch config proves itself by this token, in its env and on its PTY. */ +export function agentLaunchTokenFor( + launch: { launchConfig?: unknown; launchToken?: string }, + mint: () => string +): string | undefined { + return launch.launchConfig ? (launch.launchToken ?? mint()) : undefined +} diff --git a/src/main/ipc/pty-agent-launch-pane-attach.test.ts b/src/main/ipc/pty-agent-launch-pane-attach.test.ts index c1755edd2e91..a31850a7a76d 100644 --- a/src/main/ipc/pty-agent-launch-pane-attach.test.ts +++ b/src/main/ipc/pty-agent-launch-pane-attach.test.ts @@ -294,7 +294,18 @@ describe('a restored pane after a restart', () => { worktreeId, tabId, leafId, - env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId }, + // A pane that still carries its launch attaches to the agent it started, never plans again. + ...(daemon === 'live' + ? { + agentLaunch: { + launch: { + agent: 'claude', + target: { kind: 'existing', worktree: `id:${worktreeId}` } + } + } + } + : {}) }) ) diff --git a/src/main/ipc/pty-agent-pane-launch.test.ts b/src/main/ipc/pty-agent-pane-launch.test.ts new file mode 100644 index 000000000000..907fc573a40b --- /dev/null +++ b/src/main/ipc/pty-agent-pane-launch.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers } from './pty/register-handlers' +import { + markAgentLaunchesClosedByUser, + resetAgentLaunchPanesForTests +} from '../agent-launch/agent-launch-pane-attachment' +import { AGENT_LAUNCH_PANE_REFUSED_CODE } 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()) +) + +// An AI button's launch is planned inside its pane's spawn, with the host's planner, and writes no +// launch record. +describe('a pane that starts its own agent launch', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + const tabId = 'tab-pane-launch' + const leafId = '66666666-6666-4666-8666-666666666666' + const worktreeId = 'repo-1::/tmp/pane-launch' + const PANE_ADDRESS = { worktreeId, tabId, leafId } + const AI_BUTTON: AgentPaneLaunchRequest = { + launch: { agent: 'claude', target: { kind: 'existing', worktree: `id:${worktreeId}` } } + } + + afterEach(() => { + resetAgentLaunchPanesForTests() + }) + + function register( + providerSpawn: ReturnType, + plan: () => Promise> = async () => ({ command: 'claude' }) + ) { + installDaemonTestProvider({ spawn: providerSpawn }) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_pane'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + hasLiveTerminalForPaneKey: vi.fn(() => false), + reportAgentLaunchPaneVerdict: vi.fn(), + showTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + id: worktreeId, + path: '/tmp/pane-launch', + connectionId: null, + repo: null, + folderWorkspace: null + })), + planAgentPaneLaunch: vi.fn(plan), + getTerminalHandleForPaneKey: vi.fn(() => null), + closeTerminal: vi.fn(async () => ({})) + } + // 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 pane's own launch reaches. + const spawnRuntime = runtime as never + registerPtyHandlers(window, spawnRuntime) + return runtime + } + + function spawnPane( + agentLaunch: AgentPaneLaunchRequest = AI_BUTTON, + pane: Record = {} + ): Promise { + return Promise.resolve( + handlers.get('pty:spawn')!(null, { + ...pane, + cols: 132, + rows: 40, + cwd: '/tmp/pane-launch', + worktreeId, + tabId, + leafId, + command: '', + agentLaunch + }) + ) + } + + it("starts the host's planned command once, at the pane's size, and says it started", async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) + const runtime = register(providerSpawn) + + await expect(spawnPane()).resolves.toMatchObject({ id: 'pty-agent' }) + + expect(runtime.planAgentPaneLaunch).toHaveBeenCalledOnce() + expect(providerSpawn).toHaveBeenCalledOnce() + expect(providerSpawn).toHaveBeenCalledWith( + expect.objectContaining({ command: 'claude', cols: 132, rows: 40 }) + ) + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'proceed' + }) + }) + + // The command was quoted for the plan's runtime, so the PTY must run that runtime's shell. The tab's + // shell is inferred, not asked for, and would make the planner refuse a WSL-share repo. + it("runs the plan's runtime shell, not the tab's, as a host launch does", async () => { + const providerSpawn = vi.fn(async (_options: { shellOverride?: string }) => ({ + id: 'pty-agent' + })) + const runtime = register(providerSpawn, async () => ({ + command: 'claude', + launchRuntime: { + platform: process.platform, + pty: { shellOverride: '/bin/planned-shell', terminalWindowsWslDistro: null } + } + })) + + await spawnPane(AI_BUTTON, { shellOverride: 'cmd.exe' }) + + expect(runtime.planAgentPaneLaunch).toHaveBeenCalledWith( + worktreeId, + expect.not.objectContaining({ shellOverride: expect.anything() }) + ) + expect(providerSpawn.mock.calls[0]?.[0].shellOverride).toBe('/bin/planned-shell') + }) + + // Orchestration callers, hook evidence and title presence read the launch's token off its PTY. + it("gives the agent its launch token and agent identity, as the host's own launch did", async () => { + const providerSpawn = vi.fn(async (_options: { env?: Record }) => ({ + id: 'pty-agent', + incarnationId: 'inc-1' + })) + const runtime = register(providerSpawn, async () => ({ + command: 'claude', + launchAgent: 'claude', + launchConfig: { agentArgs: '', agentEnv: {} } + })) + + await spawnPane() + + const env = providerSpawn.mock.calls[0]?.[0]?.env ?? {} + expect(env.ORCA_AGENT_LAUNCH_TOKEN).toMatch(/.+/) + expect(JSON.stringify(runtime.registerPty.mock.calls)).toContain( + JSON.stringify({ launchToken: env.ORCA_AGENT_LAUNCH_TOKEN, launchAgent: 'claude' }) + ) + }) + + it('starts one agent when the pane spawns twice at once', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) + register(providerSpawn) + + await Promise.all([spawnPane(), spawnPane()]) + + expect(providerSpawn).toHaveBeenCalledOnce() + }) + + it('starts no shell when the plan fails, and the pane says why', async () => { + const providerSpawn = vi.fn(async () => ({ id: 'pty-shell' })) + const runtime = register(providerSpawn, async () => { + throw new Error('agent_launch_agent_disabled') + }) + + await expect(spawnPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'not-started', + code: 'agent_launch_agent_disabled' + }) + }) + + it('cannot confirm a start whose provider answer was lost', async () => { + const providerSpawn = vi.fn(async () => { + throw new Error('daemon reply lost') + }) + const runtime = register(providerSpawn) + + await expect(spawnPane()).rejects.toThrow(AGENT_LAUNCH_PANE_REFUSED_CODE) + + expect(providerSpawn).toHaveBeenCalledOnce() + expect(runtime.reportAgentLaunchPaneVerdict).toHaveBeenCalledWith(PANE_ADDRESS, { + kind: 'unconfirmed' + }) + }) + + it('starts nothing when the user closed the pane while it was planned', async () => { + let releasePlan!: () => void + const providerSpawn = vi.fn(async () => ({ id: 'pty-agent' })) + const runtime = register( + providerSpawn, + () => + new Promise((done) => { + releasePlan = () => done({ command: 'claude' }) + }) + ) + + const spawned = spawnPane() + await vi.waitFor(() => expect(runtime.planAgentPaneLaunch).toHaveBeenCalled()) + markAgentLaunchesClosedByUser(worktreeId, { kind: 'pane', tabId, leafId }) + releasePlan() + + await expect(spawned).rejects.toThrow('agent_launch_tab_closed') + expect(providerSpawn).not.toHaveBeenCalled() + expect(runtime.reportAgentLaunchPaneVerdict).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts new file mode 100644 index 000000000000..54a41b46384b --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts @@ -0,0 +1,84 @@ +// Finishes a pane's own agent launch on every way its spawn ends. + +import { + AGENT_LAUNCH_PANE_REFUSED_CODE, + type AgentLaunchPaneAddress, + type AgentLaunchPaneVerdict +} from '../../../../shared/agent-launch-pane-verdict' +import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' +import type { RunningAgentLaunchPane } from '../../../agent-launch/agent-launch-pane-attachment' +import type { AgentLaunchRuntime } from '../../../../shared/agent-launch-runtime' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { agentLaunchFailureCode } from '../../../runtime/rpc/methods/agent-launch-failure-code' +import type { PtyIpcSpawnState } from './spawn-state' + +export type PaneAgentLaunch = { + runtime: OrcaRuntimeService + address: AgentLaunchPaneAddress + paneKey: string + running: RunningAgentLaunchPane + /** The plan's runtime fact: the PTY's shell comes only from it, as for a host launch. */ + launchRuntime?: AgentLaunchRuntime + /** The provider was asked for the process: a failure after this proves nothing ran. */ + dispatched: boolean + /** Right before the provider spawn: refuses a launch whose pane the user closed meanwhile. */ + refuseIfClosed: () => void +} + +type PaneAgentLaunchEnd = + | { started: true } + /** The agent's process started, then exited before it registered. */ + | { exitedDuringStart: true } + | { error: unknown } + +function finish(launch: PaneAgentLaunch, verdict: AgentLaunchPaneVerdict): void { + launch.running.finish(verdict) + launch.runtime.reportAgentLaunchPaneVerdict(launch.address, verdict) +} + +/** Ends the pane's launch, once; returns the error its spawn throws, if it failed. */ +export function endPaneAgentLaunch(ctx: PtyIpcSpawnState, end: PaneAgentLaunchEnd): Error | null { + const launch = ctx.paneAgentLaunch + ctx.paneAgentLaunch = null + if (!launch) { + return null + } + if ('exitedDuringStart' in end) { + finish(launch, { kind: 'not-started', code: 'agent_session_exited_during_start' }) + return null + } + const closed = launch.running.closedByUser() + if ('started' in end) { + if (closed) { + stopClosed(launch) + } else { + finish(launch, { kind: 'proceed' }) + } + return null + } + if (closed || end.error instanceof AgentLaunchTabClosedError) { + stopClosed(launch) + return toError(end.error) + } + finish( + launch, + // The provider may have started it before its answer was lost. + launch.dispatched + ? { kind: 'unconfirmed' } + : { kind: 'not-started', code: agentLaunchFailureCode(end.error) } + ) + return new Error(AGENT_LAUNCH_PANE_REFUSED_CODE) +} + +/** The user closed it while it started: its agent, if one spawned, stops, as a closed tab's does. */ +function stopClosed(launch: PaneAgentLaunch): void { + const handle = launch.runtime.getTerminalHandleForPaneKey(launch.paneKey) + if (handle) { + void launch.runtime.closeTerminal(handle).catch(() => {}) + } + launch.running.finish({ kind: 'withdrawn' }) +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts new file mode 100644 index 000000000000..55b7b80e5c96 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts @@ -0,0 +1,128 @@ +// A pane's own agent launch, planned in its reserved spawn by the shared planner; no launch record. + +import { randomUUID } from 'node:crypto' +import { AgentLaunch } from '../../../../shared/rpc-contract/agent-launch-params' +import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' +import { hasClosedTerminalTabRecord } from '../../../../shared/closed-terminal-tab-tombstones' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' +import { trackRunningAgentLaunchPane } from '../../../agent-launch/agent-launch-pane-attachment' +import { agentLaunchTokenFor } from '../../../agent-launch/agent-launch-token' +import { resolveUnlaunchedIntent } from '../../../runtime/rpc/methods/agent-launch-intent-resolution' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import { resolveEarlyPaneKey } from './spawn-begin' +import type { PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs } from './spawn-types' + +/** Whether the user's close of this pane, or its whole tab, is already committed to main. */ +function closedInSession(store: Store | undefined, paneKey: string): boolean { + const pane = parsePaneKey(paneKey) + const session = store?.getWorkspaceSession() + return Boolean( + pane && + session && + (session.terminalSurfaceTombstonesByPaneKey?.[paneKey] || + hasClosedTerminalTabRecord(session.closedTerminalTabTombstonesByTabId, pane.tabId)) + ) +} + +/** The planned launch replaces whatever the pane would have run; the pane's own spawn facts stay. */ +function withPlannedLaunch( + args: PtySpawnIpcArgs, + planned: Awaited> +): PtySpawnIpcArgs { + const { + agentLaunch: _launch, + command: _command, + launchConfig: _config, + launchAgent: _agent, + startupCommandDelivery: _delivery, + telemetry: _telemetry, + // TEMPORARY parity (follow-up 2): a missing folder fails as the host's launch did, never the root. + cwdFallback: _fallback, + ...pane + } = args + const launchToken = agentLaunchTokenFor( + { + launchConfig: planned.launchConfig, + ...(typeof args.launchToken === 'string' ? { launchToken: args.launchToken } : {}) + }, + randomUUID + ) + return { + ...pane, + command: planned.command, + ...(planned.env || launchToken + ? { + env: { + ...args.env, + ...planned.env, + ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) + } + } + : {}), + ...(launchToken ? { launchToken } : {}), + ...(planned.launchConfig ? { launchConfig: planned.launchConfig } : {}), + ...(planned.launchAgent ? { launchAgent: planned.launchAgent } : {}), + ...(planned.startupCommandDelivery + ? { startupCommandDelivery: planned.startupCommandDelivery } + : {}), + ...(planned.telemetry ? { telemetry: planned.telemetry } : {}) + } +} + +export async function planPaneAgentLaunch(ctx: PtyIpcSpawnState): Promise { + const { args, deps } = ctx + // A live owner of the pane is adopted below: its agent already runs there. + if (args.agentLaunch === undefined || ctx.earlyStablePaneOwner) { + return + } + const runtime = deps.runtime + const paneKey = resolveEarlyPaneKey(args) + const pane = paneKey ? parsePaneKey(paneKey) : null + const request = typeof args.agentLaunch === 'object' ? args.agentLaunch : null + const launch = + request && 'launch' in request && typeof request.launch === 'object' + ? request.launch + : undefined + const params = AgentLaunch.safeParse({ ...launch, paneKey }) + if ( + !runtime || + !paneKey || + !pane || + !args.worktreeId || + !params.success || + params.data.reuseTerminal || + params.data.prompt + ) { + throw new Error('agent_launch_request_invalid') + } + const running = trackRunningAgentLaunchPane({ worktreeId: args.worktreeId, paneKey }) + ctx.paneAgentLaunch = { + runtime, + address: { worktreeId: args.worktreeId, tabId: pane.tabId, leafId: pane.leafId }, + paneKey, + running, + dispatched: false, + refuseIfClosed: () => { + if (running.closedByUser() || closedInSession(deps.store, paneKey)) { + throw new AgentLaunchTabClosedError() + } + } + } + const intent = await resolveUnlaunchedIntent(params.data, runtime, null) + if (intent.target.kind !== 'existing') { + throw new Error('agent_launch_request_invalid') + } + const launchPreferences = toAgentLaunchPreferences(intent.sessionOptions) + const planned = await runtime.planAgentPaneLaunch(intent.target.worktree, { + startupAgent: intent.agent, + ...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}), + ...(launchPreferences ? { launchPreferences } : {}), + ...(intent.launchSource ? { launchSource: intent.launchSource } : {}), + ...(intent.cwd ? { cwd: intent.cwd } : {}) + }) + ctx.paneAgentLaunch.launchRuntime = planned.launchRuntime + ctx.args = withPlannedLaunch(args, planned) +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index d009b7871f5c..2ae21ec3f8e0 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -86,6 +86,7 @@ async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const sequenceBeforeProviderSpawn = expectedPtyId ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 + if (!ctx.preAdoptedStablePane && ctx.paneAgentLaunch) { + ctx.paneAgentLaunch.dispatched = true + } const stablePaneSpawn = ctx.preAdoptedStablePane ? ctx.preAdoptedStablePane : await spawnForStablePane({ diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index cbc0f2e21d04..e28fec37ae4f 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -175,7 +175,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis } ctx.isClaudeLaunch = !ctx.preAdoptedStablePane && isClaudeLaunchCommand(args.command) ctx.terminalRuntimeOptions = - process.platform === 'win32' + ctx.paneAgentLaunch?.launchRuntime?.pty ?? + (process.platform === 'win32' ? resolveLocalWindowsTerminalRuntimeOptions({ requestedShellOverride: args.shellOverride, settings: ctx.deps.getSettings?.(), @@ -187,7 +188,7 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis args.shellOverride ?? (ctx.deps.getSettings?.()?.terminalDefaultShell?.trim() || undefined), terminalWindowsWslDistro: null - } + }) const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. diff --git a/src/main/ipc/pty/ipc/spawn-qoder-command.ts b/src/main/ipc/pty/ipc/spawn-qoder-command.ts index 27106c6bf63d..b4011937f4ba 100644 --- a/src/main/ipc/pty/ipc/spawn-qoder-command.ts +++ b/src/main/ipc/pty/ipc/spawn-qoder-command.ts @@ -4,7 +4,8 @@ import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows import type { PtyIpcSpawnState } from './spawn-state' export async function preparePtyIpcQoderCommand(ctx: PtyIpcSpawnState): Promise { - if (ctx.preAdoptedStablePane || ctx.args.launchAgent !== 'qoder') { + // A pane's own agent launch was resolved by its plan, for the runtime its PTY uses. + if (ctx.preAdoptedStablePane || ctx.paneAgentLaunch || ctx.args.launchAgent !== 'qoder') { return } const connectionId = ctx.args.connectionId diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 83e8a77841c5..98531ac10776 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -6,6 +6,8 @@ import { resolveEarlyPaneSpawnReservationKey } from './spawn-begin' import { releaseStoppedPaneBinding, stopReplacedPaneOwner } from '../pane/pane-owner-replacement' +import { planPaneAgentLaunch } from './spawn-agent-launch' +import { endPaneAgentLaunch } from './spawn-agent-launch-end' import { preparePtyIpcSpawnPreflight } from './spawn-preflight' import { assemblePtyIpcSpawnEnv } from './spawn-env' import { preparePtyIpcQoderCommand } from './spawn-qoder-command' @@ -60,6 +62,10 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg if (early) { return early } + // Awaited only for a launch: an ordinary spawn reaches its provider in the same turn as before. + if (ctx.args.agentLaunch !== undefined) { + await planPaneAgentLaunch(ctx) + } await preparePtyIpcSpawnPreflight(ctx) await assemblePtyIpcSpawnEnv(ctx) await preparePtyIpcQoderCommand(ctx) @@ -75,9 +81,17 @@ export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArg releaseAbandonedAgentTeamsLeader(ctx) return earlyReserved } + // Right before the provider spawn, with nothing awaited in between: a closed pane starts nothing. + ctx.paneAgentLaunch?.refuseIfClosed() await executePtyIpcSpawn(ctx) - return await commitPtyIpcSpawn(ctx) - } catch (err) { + const committed = await commitPtyIpcSpawn(ctx) + endPaneAgentLaunch( + ctx, + ctx.exitedBeforeRegistration ? { exitedDuringStart: true } : { started: true } + ) + return committed + } catch (caught) { + const err = endPaneAgentLaunch(ctx, { error: caught }) ?? caught releaseAbandonedAgentTeamsLeader(ctx) if (ctx.preSpawnHiddenMarkId !== null) { ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index 840ae20edd91..6b24c4d24b45 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -15,6 +15,7 @@ import type { PaneSpawnReservation } from '../pane/spawn-reservation' import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement' import { localProvider } from '../provider/registry' import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' +import type { PaneAgentLaunch } from './spawn-agent-launch-end' export type PtyIpcSpawnState = { deps: PtySpawnIpcDeps @@ -43,6 +44,10 @@ export type PtyIpcSpawnState = { releaseWorktreeSpawn: (() => void) | undefined provider: IPtyProvider preAdoptedStablePane: AdoptStablePaneResult | null + /** The agent launch this pane's spawn admitted, until the spawn ends it. */ + paneAgentLaunch: PaneAgentLaunch | null + /** The new process exited before it registered; the pane drains it. */ + exitedBeforeRegistration: boolean isClaudeLaunch: boolean claudeAuth: ClaudeRuntimeAuthPreparation | null terminalRuntimeOptions: { @@ -121,6 +126,8 @@ export function createPtyIpcSpawnState( releaseWorktreeSpawn: undefined, provider: localProvider, preAdoptedStablePane: null, + paneAgentLaunch: null, + exitedBeforeRegistration: false, isClaudeLaunch: false, claudeAuth: null, terminalRuntimeOptions: {}, diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 1f8a41cc289c..d41c402c342e 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -58,6 +58,8 @@ export type PtySpawnIpcArgs = { placement?: unknown // Untyped on purpose: isRestoreSpawnIntent is the one check; anything else is undeclared. spawnIntent?: unknown + // Untyped on purpose: planPaneAgentLaunch's AgentLaunch.safeParse is the one check. + agentLaunch?: unknown // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. telemetry?: { agent_kind?: unknown diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 44bee984f01a..938723aa8833 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -5,6 +5,7 @@ import { createDesktopTerminal } from './orca-runtime-create-terminal-desktop' import { buildRuntimeAgentTeamsLaunchPlan } from './orca-runtime-agent-teams-launch-plan' import { createPtySpawnCommitReporter } from './orca-runtime-report-pty-spawn-commit' import { recordPtySurface, spawnSurfaceClaimSequence } from './pty-recorded-surface-topology' +import { agentLaunchTokenFor } from '../agent-launch/agent-launch-token' export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreateDeduplication { async createTerminal( @@ -80,9 +81,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate tabId, leafId }) - const launchToken = launchOpts.launchConfig - ? (launchOpts.launchToken ?? dependencies.randomUUID()) - : undefined + const launchToken = agentLaunchTokenFor(launchOpts, dependencies.randomUUID) const baseEnv = { ...launchOpts.env, ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index eb96b9169bbb..0c77702bf402 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -25,6 +25,7 @@ import { findWorkspaceReferences } from './runtime-reference-find' import { createReferenceAgentIndex, referenceConnectionHosts } from './runtime-reference-agents' import type { RuntimeReferenceFindParams } from '../../shared/runtime-reference-contracts' import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { TerminalCreateOptions } from './runtime-terminal-contracts' class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { private async selectReferenceCatalogWorkspace(selector: string, cwd?: string) { @@ -129,6 +130,16 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { this.agentLaunchRecordWarmup.launchClientReady() } + /** Plans an agent launch a window pane's own spawn asks for, with the planner every host launch + * uses, including its runtime fact. */ + async planAgentPaneLaunch( + worktreeId: string, + opts: TerminalCreateOptions + ): Promise { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + return this.resolveAgentTerminalCreateOptions(workspace, opts) + } + /** 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 diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index de470176347c..ad6663256e70 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -22,6 +22,7 @@ import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect- import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' +import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request' import type { PtyManagementApi } from './pty-management-api' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' @@ -61,6 +62,8 @@ export type PtyApi = { placement?: TerminalPanePlacement // Visible restores are `user-resume`, hidden ones `automatic-restore`; the fork gate validates. spawnIntent?: SpawnIntent + // The agent launch this pane's spawn starts; main admits and plans it. + agentLaunch?: AgentPaneLaunchRequest // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 500ae1b9f9df..ac235e62b637 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -22,6 +22,7 @@ import type { PtyMainDeliveryDiagnostics } from '../../shared/pty-delivery-diagn import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' import type { PreloadApi } from '../api-types' import type { TerminalPanePlacement } from '../../shared/terminal-pane-placement' +import type { AgentPaneLaunchRequest } from '../../shared/agent-pane-launch-request' export const ptySessionControlApi = { spawn: (opts: { @@ -56,6 +57,8 @@ export const ptySessionControlApi = { placement?: TerminalPanePlacement // Visible restores are `user-resume`, hidden ones `automatic-restore`; the fork gate validates. spawnIntent?: SpawnIntent + // The agent launch this pane's spawn starts; main admits and plans it. + agentLaunch?: AgentPaneLaunchRequest // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 121bd210b040..d7394497f937 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -15,7 +15,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 { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' const SSH_PTY_CONNECTION_MISMATCH_MARKER = 'belongs to SSH connection' @@ -77,14 +77,6 @@ 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 } @@ -93,6 +85,7 @@ export async function connectIpcPty( // recorded before we asked for a PTY, so it belongs to that earlier owner, not to us. const priorIncarnationFence = currentPreHandlerPtySequence() const spawnResult = await spawnIpcPty(transportOptions, options, admittedSessionId) + settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, spawnResult.id) const retireDisposedFreshSpawn = async (): Promise => { if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { return @@ -163,6 +156,7 @@ export async function connectIpcPty( } return projectIpcPtyConnectResult(spawnResult) } catch (error) { + settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, null) if (context.isDestroyed()) { return } diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index 4ebd03185438..2f622150e710 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -90,7 +90,8 @@ export async function spawnIpcPty( projectRuntime, terminalColorQueryReplies, terminalKittyKeyboardProtocol, - telemetry + telemetry, + agentLaunch } = transportOptions const shouldSendLocalCwdFallback = cwdFallback === 'worktree' && !connectionId && !admittedSessionId @@ -142,6 +143,7 @@ export async function spawnIpcPty( ...(projectRuntime ? { projectRuntime } : {}), ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(terminalKittyKeyboardProtocol === true ? { terminalKittyKeyboardProtocol: true } : {}), - ...(telemetry ? { telemetry } : {}) + ...(telemetry ? { telemetry } : {}), + ...(agentLaunch && !admittedSessionId ? { agentLaunch } : {}) }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 2dc70000e293..e5314fe3086c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -18,6 +18,7 @@ import type { import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' +import type { AgentPaneLaunchRequest } from '../../../../shared/agent-pane-launch-request' export type PtyPaneStartup = { command: string @@ -31,6 +32,8 @@ export type PtyPaneStartup = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + /** The agent launch the pane's own spawn starts, instead of `command`. */ + agentLaunch?: AgentPaneLaunchRequest /** Explicit CLI override for host-owned agent launches; omission uses host settings. */ agentArgsOverride?: string | null draftPrompt?: string diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts index c48730296c87..30a9b5368d6b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-startup-renderer-query.ts @@ -7,6 +7,7 @@ export function shouldKeepHiddenStartupRendererQueriesLive( ): boolean { return ( Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || + Boolean(startup?.agentLaunch) || isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index dae430dc082e..f11290c6b258 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -69,6 +69,7 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt : {}), ...(session.launchToken ? { launchToken: session.launchToken } : {}), ...(session.paneStartup?.launchAgent ? { launchAgent: session.paneStartup.launchAgent } : {}), + ...(session.paneStartup?.agentLaunch ? { agentLaunch: session.paneStartup.agentLaunch } : {}), ...(session.paneStartup?.telemetry ? { telemetry: session.paneStartup.telemetry } : {}), onPtyExit: session.onExit, onPtySpawn: session.onPtySpawn, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts index 095668d4de68..d08931290fa9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-resize-geometry.ts @@ -211,7 +211,7 @@ export function installPtyResizeGeometry(session: ConnectPanePtySession): void { // Why: the settle only prevents visible artifacts, and a hidden pane or window paints none // (nor produces the frames the settle counts). session.shouldSettleStartupGridBeforeConnect = (): boolean => - Boolean(session.paneStartup?.command) && + Boolean(session.paneStartup?.command || session.paneStartup?.agentLaunch) && session.deps.isVisibleRef.current && getWindowParkVisible() && !session.unservedSshTargetId && 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 ca8d7a816b4a..a716b6f18660 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 @@ -100,32 +100,29 @@ 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 () => { + it("carries an AI button's launch in its pane's spawn, and says whether the agent started", async () => { const { createIpcPtyTransport } = await import('./pty-transport') - const { holdAgentLaunchPaneSpawn, releaseAgentLaunchPaneSpawn } = - await import('@/lib/agent-launch-pane-spawn-hold') + const { awaitAgentLaunchPaneStart } = await import('@/lib/agent-launch-pane-start') const spawn = window.api.pty.spawn as unknown as ReturnType - const pane = { worktreeId: 'wt', tabId: 'tab-held', leafId: 'leaf-held' } + const agentLaunch = { launch: { agent: 'claude' } } + const pane = { worktreeId: 'wt', tabId: 'tab-launch', leafId: 'leaf-launch' } - 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() + const started = awaitAgentLaunchPaneStart(pane.tabId, pane.leafId) + spawn.mockResolvedValueOnce({ id: 'pty-launch' }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the transport forwards the request untouched; main validates it. + await createIpcPtyTransport({ ...pane, agentLaunch } as never).connect({ + url: '', + callbacks: {} + }) + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'tab-launch', agentLaunch }) + ) + await expect(started).resolves.toBe('pty-launch') - 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() + spawn.mockRejectedValueOnce(new Error('agent_launch_pane_refused')) + const refused = awaitAgentLaunchPaneStart(pane.tabId, pane.leafId) + await createIpcPtyTransport(pane).connect({ url: '', callbacks: { onError: vi.fn() } }) + await expect(refused).resolves.toBeNull() }) it('keeps the recovery hint and raw diagnostic from a wrapped spawn error', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 0ff00c5a1677..956e9ebad304 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -22,6 +22,7 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import type { PtyDataMeta } from './pty-dispatcher' import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer-types' import type { AcceptedInputOptions, PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' +import type { AgentPaneLaunchRequest } from '../../../../shared/agent-pane-launch-request' export type PtyBufferSnapshot = { data: string @@ -287,6 +288,7 @@ export type IpcPtyTransportOptions = { agentLaunchPreferences?: AgentLaunchPreferences launchToken?: string launchAgent?: TuiAgent + agentLaunch?: AgentPaneLaunchRequest startupCommandDelivery?: StartupCommandDelivery connectionId?: string | null executionHostId?: ExecutionHostId | null 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 7a319260f214..1762fd45e428 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,5 +1,4 @@ import { presentAgentLaunchTab } from '@/lib/agent-launch-tab-presentation' -import { releaseAgentLaunchPaneSpawn } from '@/lib/agent-launch-pane-spawn-hold' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' @@ -93,11 +92,6 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v ? worktreeTabs.find((candidate) => candidate.id === ownership.tabId) : undefined 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 a108deb9b377..13cacf051d83 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 @@ -63,23 +63,18 @@ 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 () => { +describe('a tab this window already shows, which the host reveals an agent into', () => { + it('takes the agent under its own id, never a second tab', 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', @@ -89,10 +84,7 @@ describe('a launch tab this window made, which the host never showed early', () 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/lib/agent-launch-caller-permissions.test.ts b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts index 2d9bda0ce4e3..a97468d6bc49 100644 --- a/src/renderer/src/lib/agent-launch-caller-permissions.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-permissions.test.ts @@ -57,7 +57,8 @@ vi.mock('@/runtime/runtime-rpc-result', () => ({ RuntimeRpcCallError: Error })) function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { return newTabPromptLaunchesThroughHost({ promptDelivery: profile.args.promptDelivery ?? 'auto-submit', - pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0, + isRemote: false }) } @@ -67,7 +68,7 @@ function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { * setting, shipped bypass default included. */ function expectHostRequestArguments(profile: AgentLaunchCallerProfile): void { - const request = hostLaunchRequest(callRuntimeRpc) + const request = hostLaunchRequest(store) expect(queuedStartupCommand(store)).toBeUndefined() if (profile.args.agentArgs === undefined) { expect(request).not.toHaveProperty('agentArgs') 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 85f3827649fb..3b20851e43ca 100644 --- a/src/renderer/src/lib/agent-launch-caller-placement.test.ts +++ b/src/renderer/src/lib/agent-launch-caller-placement.test.ts @@ -57,7 +57,8 @@ vi.mock('@/runtime/runtime-rpc-result', () => ({ RuntimeRpcCallError: Error })) function launchesThroughHost(profile: AgentLaunchCallerProfile): boolean { return newTabPromptLaunchesThroughHost({ promptDelivery: profile.args.promptDelivery ?? 'auto-submit', - pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0 + pastesPrompt: (profile.args.prompt?.trim() ?? '').length > 0, + isRemote: false }) } @@ -125,9 +126,16 @@ describe('agent launch caller placement and telemetry', () => { 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) + // The host plans the agent where the request names; the pane's spawn starts there too. + expect(hostLaunchRequest(store)?.cwd).toBe(profile.args.initialCwd) + if (profile.args.initialCwd?.trim()) { + expect(store.queueTabInitialCwd).toHaveBeenCalledExactlyOnceWith( + createdTabOptions(store)?.id, + profile.args.initialCwd + ) + } else { + expect(store.queueTabInitialCwd).not.toHaveBeenCalled() + } return } if (profile.args.initialCwd) { @@ -151,7 +159,7 @@ describe('agent launch caller placement and telemetry', () => { 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({ + expect(hostLaunchRequest(store)).toMatchObject({ agent: profile.args.agent, launchSource: profile.args.launchSource ?? 'tab_bar_quick_launch' }) 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 9422388e1034..8ae8b40595e4 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -1,4 +1,5 @@ import { vi } from 'vitest' +import { settleAgentLaunchPaneStart } from './agent-launch-pane-start' /** * The store double `launchAgentInNewTab` reads, shared by the caller-behaviour suite. @@ -78,14 +79,25 @@ export function createLaunchFunnelStore(): { return store } -/** A tab made with its own id joins the workspace, as the real store's does; launches read it back. */ +type CreatedTabOptions = { + id?: string + initialLeafId?: string + pendingStartup?: { agentLaunch?: unknown } +} + +/** A tab made with its own id joins the workspace, as the real store's does; launches read it back. + * A launch tab's pane spawn answers that its agent started, as a mounted pane's would. */ function createTabInto(store: { tabsByWorktree: Record }) { - return (worktreeId: string, _groupId?: string, _x?: unknown, opts?: { id?: string }) => { + return (worktreeId: string, _groupId?: string, _x?: unknown, opts?: CreatedTabOptions) => { if (!opts?.id) { return { id: 'tab-1' } } const tab = { id: opts.id } store.tabsByWorktree[worktreeId] = [...(store.tabsByWorktree[worktreeId] ?? []), tab] + const { initialLeafId } = opts + if (opts.pendingStartup?.agentLaunch && initialLeafId) { + queueMicrotask(() => settleAgentLaunchPaneStart(tab.id, initialLeafId, `${tab.id}-pty`)) + } return tab } } @@ -127,9 +139,9 @@ export function createdTabGroupId(store: LaunchFunnelStore): string | undefined return store.createTab.mock.calls[0]?.[1] } -/** The `agent.launch` request a launch sent its host, or undefined when the window planned it. */ -export function hostLaunchRequest( - callRuntimeRpc: ReturnType -): Record | undefined { - return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launch')?.[2] +/** The launch an AI button's tab queued for its pane's spawn, or undefined when the window planned it. */ +export function hostLaunchRequest(store: LaunchFunnelStore): Record | undefined { + const options: { pendingStartup?: { agentLaunch?: { launch?: Record } } } = + store.createTab.mock.calls[0]?.[3] ?? {} + return options.pendingStartup?.agentLaunch?.launch } diff --git a/src/renderer/src/lib/agent-launch-pane-closes.ts b/src/renderer/src/lib/agent-launch-pane-closes.ts index 84fefe8b1b48..ef4f5c165fc9 100644 --- a/src/renderer/src/lib/agent-launch-pane-closes.ts +++ b/src/renderer/src/lib/agent-launch-pane-closes.ts @@ -6,6 +6,8 @@ * note it without an import cycle. */ +import { settleAgentLaunchPaneStart } from './agent-launch-pane-start' + const MAX_REMEMBERED_CLOSES = 32 const closedPaneKeys = new Set() @@ -15,6 +17,7 @@ function closeKey(tabId: string, leafId: string): string { } export function noteAgentLaunchPaneClosedByUser(tabId: string, leafId: string): void { + settleAgentLaunchPaneStart(tabId, leafId, null) const paneKey = closeKey(tabId, leafId) closedPaneKeys.delete(paneKey) closedPaneKeys.add(paneKey) 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 deleted file mode 100644 index bcbc87d7e9a0..000000000000 --- a/src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 7e72e8399b29..000000000000 --- a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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 reveals the agent's terminal there, so the hold ends there, or when the - * launch is over. Session-only: after a reload the pane spawns as any pane does. - */ - -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-pane-start.test.ts b/src/renderer/src/lib/agent-launch-pane-start.test.ts new file mode 100644 index 000000000000..237cf283c04c --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-start.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { awaitAgentLaunchPaneStart, settleAgentLaunchPaneStart } from './agent-launch-pane-start' +import { noteAgentLaunchPaneClosedByUser } from './agent-launch-pane-closes' + +const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' +const LEAF = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' + +describe("an AI button's pane says whether its agent started", () => { + it('once: the first answer stands, and only for that pane', async () => { + const started = awaitAgentLaunchPaneStart(TAB, LEAF) + settleAgentLaunchPaneStart(TAB, 'another-leaf', 'pty-other') + settleAgentLaunchPaneStart(TAB, LEAF, 'pty-1') + settleAgentLaunchPaneStart(TAB, LEAF, null) + await expect(started).resolves.toBe('pty-1') + }) + + it('no, when the user closes it before its spawn returns', async () => { + const started = awaitAgentLaunchPaneStart(TAB, LEAF) + noteAgentLaunchPaneClosedByUser(TAB, LEAF) + await expect(started).resolves.toBeNull() + }) +}) diff --git a/src/renderer/src/lib/agent-launch-pane-start.ts b/src/renderer/src/lib/agent-launch-pane-start.ts new file mode 100644 index 000000000000..216d2aa1f57c --- /dev/null +++ b/src/renderer/src/lib/agent-launch-pane-start.ts @@ -0,0 +1,30 @@ +// Session-only: this pane's own spawn answers first, and its answer gates the prompt's paste. + +const pendingStarts = new Map void>() + +// Not `makePaneKey`: that validates the leaf id, and a launch must never fail over bookkeeping. +function startKey(tabId: string, leafId: string): string { + return JSON.stringify([tabId, leafId]) +} + +/** Registered before the pane exists, so its first spawn already answers it: with its PTY, or null. */ +export function awaitAgentLaunchPaneStart(tabId: string, leafId: string): Promise { + const key = startKey(tabId, leafId) + pendingStarts.get(key)?.(null) + return new Promise((resolve) => { + pendingStarts.set(key, resolve) + }) +} + +export function settleAgentLaunchPaneStart( + tabId: string | undefined, + leafId: string | undefined, + startedPtyId: string | null +): void { + if (tabId && leafId) { + const key = startKey(tabId, leafId) + const settle = pendingStarts.get(key) + pendingStarts.delete(key) + settle?.(startedPtyId) + } +} diff --git a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts index 4cff8e1df090..7f9dd78daacf 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -8,6 +8,7 @@ import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids' import { useAppStore } from '@/store' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' +import { settleAgentLaunchPaneStart } from './agent-launch-pane-start' export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): void { const { worktreeId, tabId, leafId, verdict } = event @@ -17,6 +18,10 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): if (tab?.agentLaunchPane?.leafId !== leafId) { return } + if (verdict.kind !== 'proceed') { + // Reported before the pane's spawn returns: its prompt is never pasted. + settleAgentLaunchPaneStart(tabId, leafId, null) + } switch (verdict.kind) { case 'proceed': // Settled: the agent attached, or the pane is an ordinary terminal again. diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 50b0a8796806..89e6bc90a0e2 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -35,8 +35,8 @@ 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 + /** The launch pane's PTY once its agent started; the chat copy is seeded only then. */ + launchPty?: Promise }): Promise { const { tabId, agent, content, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args @@ -59,13 +59,13 @@ export function deliverLaunchPromptToAgentTab(args: { } } // 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) { + const launchPty = args.launchPty?.then((ptyId) => { + if (ptyId) { seedChatCopy() } - return open + return ptyId }) - if (!sendGate) { + if (!launchPty) { seedChatCopy() } @@ -83,7 +83,7 @@ export function deliverLaunchPromptToAgentTab(args: { timeoutMs, onTimeout, onUnconfirmedDelivery, - ...(sendGate ? { sendGate } : {}) + ...(launchPty ? { launchPty } : {}) }).then( (delivered) => { if (seeded && !delivered && !deliversViaNativePrefill) { 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 ffdfa42ececf..e47abcacc67d 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,15 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-2' }) }) + it('never claims a tab it found already shown as one to take back', () => { + store.getState().createTab(WT, undefined, undefined, { + id: TAB_ID, + initialLeafId: LEAF_ID, + agentLaunchPane: { leafId: LEAF_ID } + }) + expect(publishAgentLaunchTab(request())).toMatchObject({ tabId: TAB_ID, created: 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-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts index 998b392a57a2..8760ca7ca0e0 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -1,6 +1,5 @@ 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' @@ -24,60 +23,22 @@ vi.mock('@/store', () => ({ } } })) -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 { settleAgentLaunchPaneStart } = await import('./agent-launch-pane-start') +const { applyAgentLaunchPaneVerdict } = await import('./agent-launch-pane-verdict-application') +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 queuedLaunch(tabId: string) { + return store.getState().pendingStartupByTabId[tabId]?.agentLaunch } function launch() { @@ -91,114 +52,56 @@ function launch() { }) } -/** 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) - +describe('a desktop AI-button launch', () => { + it('shows its tab at the click, in its split, with the launch queued for its pane to start', () => { 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(tab.agentLaunchPane?.leafId).toBeTruthy() + 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.launch', { - agent: 'claude', - target: { kind: 'existing', worktree: `id:${WT}` }, - agentArgs: null, - launchSource: 'source_control_recovery', - placement: { groupId: store.getState().activeGroupIdByWorktree[WT] }, - presentation: 'focused', - paneKey: `${tabId}:${leafId}` + expect(queuedLaunch(tabId)).toEqual({ + launch: { + agent: 'claude', + target: { kind: 'existing', worktree: `id:${WT}` }, + agentArgs: null, + launchSource: 'source_control_recovery' + } }) }) - // Nothing reads a click's launch record, so a record store that cannot open never stops one. - it('names no operation, so the host writes no launch record; each click has its own pane', () => { - callRuntimeRpc.mockReturnValue(new Promise(() => {})) - launch() - const first = lastParams() - launch() - const second = lastParams() - expect(first).not.toHaveProperty('operationId') - expect(second.paneKey).not.toBe(first.paneKey) - }) - - it('says the agent started only once the host answers, and its tab is then an ordinary one', 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() - expect(launchTab(tabId)?.agentLaunchPane).toBeUndefined() - }) - - // Temporary until the pane's own spawn starts its agent: the window says it, not the pane. - it('keeps a tab the host took and then failed in, and says it could not confirm the start', async () => { - const reply = deferred() - callRuntimeRpc.mockReturnValue(reply.promise) - const { tabId, outcome } = launch() - hostTakesPane(tabId) - - reply.reject(rpcError('terminal_reveal_failed')) - - await expect(outcome).resolves.toEqual({ - kind: 'not-started', - unconfirmed: true, - code: 'terminal_reveal_failed' - }) - expect(launchTab(tabId)).toBeDefined() + // Nothing reads a click's launch record, so its launch carries no operation and writes none. + it('names no operation, and each click has its own pane', () => { + const first = launch().tabId + const second = launch().tabId + expect(queuedLaunch(first)?.launch).not.toHaveProperty('operationId') + expect(second).not.toBe(first) }) - // Temporary until the pane's own spawn starts its agent: the tab goes and the window says why. - 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' + it("says the agent started once its pane's spawn returns, and not when main says it failed", async () => { + const started = launch() + const leafId = launchTab(started.tabId)!.agentLaunchPane!.leafId + settleAgentLaunchPaneStart(started.tabId, leafId, 'pty-1') + await expect(started.started).resolves.toBe('pty-1') + + const failed = launch() + const failedLeaf = launchTab(failed.tabId)!.agentLaunchPane!.leafId + applyAgentLaunchPaneVerdict({ + worktreeId: WT, + tabId: failed.tabId, + leafId: failedLeaf, + verdict: { kind: 'not-started', code: 'spawn claude ENOENT' } }) - 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 () => { - const reply = deferred() - callRuntimeRpc.mockReturnValue(reply.promise) - const { tabId, outcome } = launch() - store.getState().closeTab(tabId) - - reply.resolve(terminalResult(lastPaneKey())) - - await expect(outcome).resolves.toEqual({ kind: 'closed-by-user' }) + settleAgentLaunchPaneStart(failed.tabId, failedLeaf, 'pty-2') + await expect(failed.started).resolves.toBeNull() }) }) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 0b64b860b4fb..a61d06959a6b 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -1,26 +1,19 @@ /** - * A desktop launch started through the host's `agent.launch`, with no prompt. + * A desktop AI-button launch, whose prompt this window pastes once the agent is ready, as main does. * * 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 starts the agent into that tab's - * pane and writes no launch record: nothing reads one for a click, and a record store that cannot - * open must not stop it. The pane's spawn waits until the host has taken it - * (`agent-launch-pane-spawn-hold`), then attaches to the agent. The prompt stays this window's to - * paste, as main pastes it, once the agent has started. + * split it was asked for, as any new agent tab is made. The tab's pane carries the launch in its own + * spawn, as "+" carries its command: main plans it with the host's planner and starts it in that + * pane, at the pane's real size, writing no launch record. A pane whose agent could not start says + * why. */ import { useAppStore } from '@/store' import { createBrowserUuid } from '@/lib/browser-uuid' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' -import { - holdAgentLaunchPaneSpawn, - isAgentLaunchPaneSpawnHeld -} from '@/lib/agent-launch-pane-spawn-hold' +import { rememberAgentLaunchPanePrompt } from '@/lib/agent-launch-pane-prompt' +import { awaitAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' -import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' -import { isAgentLaunchResult } from '../../../shared/agent-launch-intent' -import { makePaneKey } from '../../../shared/stable-pane-id' import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../../shared/tui-agent' import type { LaunchSource } from '../../../shared/telemetry-events' @@ -47,38 +40,6 @@ export type HostAgentLaunchArgs = { 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 user closed the tab while it started, and with it the launch: nothing more to say. */ - | { kind: 'closed-by-user' } - /** The window says it: nothing started (its tab is gone), or the host took the pane and then - * failed, so whether its agent runs there is unknown. */ - | { kind: 'not-started'; unconfirmed: boolean; code?: string } - -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)) { - // Not the user's close, so not reopenable; its tombstone still stops an agent that starts late. - useAppStore.getState().closeTab(tabId, { - reason: 'cleanup', - recordInteraction: false, - captureRecentlyClosed: false - }) - } -} - -// Only a terminal in this pane is one the window can paste into. -function outcomeFromResult(result: unknown): HostAgentLaunchOutcome { - return isAgentLaunchResult(result) && result.outcome.kind === 'terminal' - ? { kind: 'started' } - : { kind: 'not-started', unconfirmed: true } -} - function stringSessionOptions(options: Record | undefined): { sessionOptions?: Record } { @@ -91,45 +52,11 @@ function stringSessionOptions(options: Record | unde function launchParams(args: HostAgentLaunchArgs) { return { agent: args.agent, - target: { kind: 'existing', worktree: `id:${args.worktreeId}` }, + target: { kind: 'existing' as const, 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' - } -} - -async function settleLaunch( - args: HostAgentLaunchArgs, - pane: { tabId: string; leafId: string }, - send: Promise, - releaseHold: () => void -): Promise { - const closedByUser = (): boolean => !tabExists(args.worktreeId, pane.tabId) - try { - const result = await send - // Closed while it started: the window stopped its agent as the tab's reveal arrived. - return closedByUser() ? { kind: 'closed-by-user' } : outcomeFromResult(result) - } catch (error) { - if (closedByUser()) { - return { kind: 'closed-by-user' } - } - const code = error instanceof RuntimeRpcCallError ? error.code : undefined - // The host revealed the pane once its agent spawned; a pane it never took would open as a - // shell, so its tab goes. A taken pane holds whatever runs there. - const hostTookPane = !isAgentLaunchPaneSpawnHeld(pane.tabId, pane.leafId) - if (!hostTookPane) { - closeLaunchTab(args.worktreeId, pane.tabId) - } - return { kind: 'not-started', unconfirmed: hostTookPane, ...(code ? { code } : {}) } - } finally { - releaseHold() - // The launch is over: its tab is an ordinary one, as a settled verdict leaves it. - if (!closedByUser()) { - useAppStore.getState().setTabAgentLaunchPane(pane.tabId, undefined) - } + ...(args.launchSource ? { launchSource: args.launchSource } : {}) } } @@ -141,18 +68,13 @@ export function windowMakesHostLaunchTab(): boolean { export function launchAgentThroughHost(args: HostAgentLaunchArgs): { tabId: string - outcome: Promise + /** The tab pane's PTY once its agent started there; null when it could not, or the user closed it. */ + started: 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.launch', { - ...launchParams(args), - // The pane is this click's. - paneKey: makePaneKey(tabId, leafId) - }) + const started = awaitAgentLaunchPaneStart(tabId, leafId) store.createTab(args.worktreeId, args.groupId, undefined, { id: tabId, initialLeafId: leafId, @@ -160,11 +82,21 @@ export function launchAgentThroughHost(args: HostAgentLaunchArgs): { launchAgent: args.agent, quickCommandLabel: args.quickCommandLabel, ...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), - ...(args.viewMode ? { viewMode: args.viewMode } : {}) + ...(args.viewMode ? { viewMode: args.viewMode } : {}), + // Queued before the pane mounts, as "+" queues its command: its first spawn carries the launch. + pendingStartup: { + command: '', + launchAgent: args.agent, + agentLaunch: { launch: launchParams(args) } + } }) + if (args.cwd?.trim()) { + store.queueTabInitialCwd(tabId, args.cwd) + } + 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) } + return { tabId, started } } diff --git a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts index 9c4309b433d9..82a45c4dc24b 100644 --- a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts @@ -1,16 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady } from './agent-paste-draft' -const testState = vi.hoisted(() => ({ - waitForReady: vi.fn(), - inspectProcess: vi.fn(), - sendInput: vi.fn() -})) +const testState = vi.hoisted(() => { + const ptyIdsByTabId: Record = { 'tab-1': ['pty-1'] } + return { + waitForReady: vi.fn(), + inspectProcess: vi.fn(), + sendInput: vi.fn(), + ptyIdsByTabId, + listeners: new Set<(state: unknown) => void>() + } +}) + +function storeState() { + return { settings: {}, tabsByWorktree: {}, ptyIdsByTabId: testState.ptyIdsByTabId } +} vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ settings: {}, tabsByWorktree: {}, ptyIdsByTabId: { 'tab-1': ['pty-1'] } }), - subscribe: () => () => {} + getState: storeState, + subscribe: (listener: (state: unknown) => void) => { + testState.listeners.add(listener) + return () => testState.listeners.delete(listener) + } } })) @@ -150,6 +162,37 @@ describe('pty-bound agent draft readiness budget', () => { expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1) }) + // An AI button's pane may settle its grid for longer than the spawn budget before it spawns. + it.each([true, false])( + 'delivers once the pane says whether it started, however late (%s)', + async (started) => { + testState.waitForReady.mockResolvedValue(true) + let answer!: (startedPtyId: string | null) => void + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-late', + content: 'task', + agent: 'claude', + submit: true, + forcePaste: true, + launchPty: new Promise((resolve) => { + answer = resolve + }) + }) + + await vi.advanceTimersByTimeAsync(13_000) + testState.ptyIdsByTabId['tab-late'] = ['pty-late'] + testState.listeners.forEach((listener) => listener(storeState())) + answer(started ? 'pty-late' : null) + await vi.advanceTimersByTimeAsync(12_000) + + await expect(promise).resolves.toBe(started) + const writes = testState.sendInput.mock.calls.map((call) => String(call[1])) + expect(writes.filter((data) => data.includes('task'))).toHaveLength(started ? 1 : 0) + expect(writes.filter((data) => data === '\r')).toHaveLength(started ? 1 : 0) + expect(writes).toHaveLength(started ? 2 : 0) + } + ) + it('keeps the 8s readiness deadline for agents without an agent-specific budget', async () => { const onTimeout = vi.fn() const promise = pasteDraftToAgentPtyWhenReady({ diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index a5c829e8acae..516826acbd4e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -68,8 +68,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 + /** The launch pane's PTY once its spawn says the agent started there; null when it didn't. */ + launchPty?: Promise }): Promise { const { tabId, content, agent, submit, forcePaste, timeoutMs, onTimeout, onUnconfirmedDelivery } = args @@ -91,10 +91,11 @@ export async function pasteDraftWhenAgentReady(args: { tabId, spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, - readySignal + readySignal, + ...(args.launchPty ? { launchPty: args.launchPty } : {}) }) - // Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses. - if (args.sendGate && !(await args.sendGate)) { + // Why: a pane that didn't start is the caller's own outcome to report, not a readiness timeout. + if (args.launchPty && !(await args.launchPty)) { return false } const readiness = await readinessResult @@ -245,13 +246,13 @@ function waitForAgentDraftInputReadyOnTab(args: { spawnTimeoutMs: number readinessTimeoutMs: number readySignal: Parameters[2] + launchPty?: Promise }): Promise<{ ptyId: string; ready: boolean } | null> { return new Promise((resolve) => { - let selectedPtyId: string | null = null + const readinessByPtyId = new Map>() let settled = false let spawnTimer: number | null = null let unsubscribeStore: (() => void) | null = null - const finish = (result: { ptyId: string; ready: boolean } | null): void => { if (settled) { return @@ -263,29 +264,45 @@ function waitForAgentDraftInputReadyOnTab(args: { unsubscribeStore?.() resolve(result) } - const bindPty = (ptyId: string): void => { - if (selectedPtyId || settled) { + const watchPty = (ptyId: string): Promise => { + const existing = readinessByPtyId.get(ptyId) + if (existing) { + return existing + } + const readiness = waitForAgentDraftInputReady( + ptyId, + args.readinessTimeoutMs, + args.readySignal + ) + readinessByPtyId.set(ptyId, readiness) + return readiness + } + const deliverPty = (ptyId: string): void => { + if (settled) { return } - selectedPtyId = ptyId if (spawnTimer !== null) { window.clearTimeout(spawnTimer) } unsubscribeStore?.() - // Why: Zustand subscribers run inside updateTabPtyId. Registering the - // sidecar here precedes the transport's immediate pre-handler drain. - void waitForAgentDraftInputReady(ptyId, args.readinessTimeoutMs, args.readySignal).then( - (ready) => finish({ ptyId, ready }) - ) + void watchPty(ptyId).then((ready) => finish({ ptyId, ready })) } const bindFromState = (state: ReturnType): void => { - const ptyId = state.ptyIdsByTabId[args.tabId]?.[0] - if (ptyId) { - bindPty(ptyId) + const ptyIds = state.ptyIdsByTabId[args.tabId] ?? [] + if (args.launchPty) { + // Each pane's readiness attaches before its primary handler drains startup bytes. + for (const ptyId of ptyIds) { + watchPty(ptyId) + } + } else if (ptyIds[0]) { + deliverPty(ptyIds[0]) } } - - spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) + if (args.launchPty) { + void args.launchPty.then((ptyId) => (ptyId ? deliverPty(ptyId) : finish(null))) + } else { + spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) + } unsubscribeStore = useAppStore.subscribe(bindFromState) bindFromState(useAppStore.getState()) }) 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 d5a1705067c0..bff61b705a3f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -240,7 +240,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } if ( pasteDraftAfterLaunch !== null && - newTabPromptLaunchesThroughHost({ promptDelivery, pastesPrompt: true }) + newTabPromptLaunchesThroughHost({ promptDelivery, pastesPrompt: true, isRemote }) ) { const launched = launchNewTabPromptThroughHost({ agent, 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 index 89e3fd05f56f..e2907b21e70b 100644 --- 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 @@ -1,11 +1,10 @@ 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 }[] = [] + const writes: { at: number; ptyId?: string; data: string }[] = [] return { appState: { settings: {}, @@ -42,14 +41,11 @@ vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ 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 (_ptyId: string, data: string) => { - testState.writes.push({ at: Date.now(), data }) + sendRuntimePtyInputVerified: async (ptyId: string, data: string) => { + testState.writes.push({ at: Date.now(), ...(ptyId === 'pty-1' ? {} : { ptyId }), data }) return true } })) @@ -59,6 +55,8 @@ vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: () => ' vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } })) const { launchNewTabPromptThroughHost } = await import('./launch-agent-new-tab-host-route') +const { bufferPreHandlerPtyData, drainPreHandlerPtyData, clearPreHandlerPtyState } = + await import('@/components/terminal-pane/pty-pre-handler-buffer') function attachHostPty(ptyId: string): void { testState.appState.ptyIdsByTabId = { [TAB]: [ptyId] } @@ -82,6 +80,7 @@ describe('an AI button whose agent is ready before the host answers', () => { }) afterEach(() => { + clearPreHandlerPtyState('launch-agent') vi.unstubAllGlobals() vi.useRealTimers() }) @@ -89,10 +88,10 @@ describe('an AI button whose agent is ready before the host answers', () => { // 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 + let answer!: (startedPtyId: string | null) => void testState.launchAgentThroughHost.mockReturnValue({ tabId: TAB, - outcome: new Promise((done) => (answer = done)) + started: new Promise((done) => (answer = done)) }) const onPromptDeliveryUnconfirmed = vi.fn() const { promptDeliveryResult } = launchNewTabPromptThroughHost({ @@ -112,7 +111,7 @@ describe('an AI button whose agent is ready before the host answers', () => { expect(testState.writes).toEqual([]) expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() - answer({ kind: 'started' }) + answer('pty-1') await vi.advanceTimersByTimeAsync(1200) expect(testState.writes).toEqual([]) @@ -129,11 +128,11 @@ describe('an AI button whose agent is ready before the host answers', () => { 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 + it('writes nothing, and seeds no chat copy, when its pane says the agent did not start', async () => { + let answer!: (startedPtyId: string | null) => void testState.launchAgentThroughHost.mockReturnValue({ tabId: TAB, - outcome: new Promise((done) => (answer = done)) + started: new Promise((done) => (answer = done)) }) const { promptDeliveryResult } = launchNewTabPromptThroughHost({ agent: 'claude', @@ -145,7 +144,7 @@ describe('an AI button whose agent is ready before the host answers', () => { attachHostPty('pty-1') testState.ptyWatchers.get('pty-1')?.('\x1b[?2004h> ') - answer({ kind: 'not-started', unconfirmed: true }) + answer(null) await vi.advanceTimersByTimeAsync(10_000) await expect(promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) @@ -153,4 +152,72 @@ describe('an AI button whose agent is ready before the host answers', () => { expect(testState.appState.seedNativeChatLaunchPrompt).not.toHaveBeenCalled() expect(testState.appState.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() }) + + // Regression: a split made while the agent started bound its shell first, and took the prompt. + it("goes only to its own pane's PTY when another split's shell is ready first", async () => { + let answer!: (startedPtyId: string | null) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + started: new Promise((done) => (answer = done)) + }) + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'resolve the conflicts', + pasteContent: 'resolve the conflicts', + submit: true + }) + + await vi.advanceTimersByTimeAsync(13_000) + attachHostPty('split-shell') + testState.ptyWatchers.get('split-shell')?.('shell drawn\r\n\x1b[?2004h$ ') + await vi.advanceTimersByTimeAsync(1501) + testState.appState.ptyIdsByTabId = { [TAB]: ['split-shell', 'launch-agent'] } + answer('launch-agent') + await vi.advanceTimersByTimeAsync(10) + testState.ptyWatchers.get('launch-agent')?.('claude drawn\r\n\x1b[?2004h> ') + await vi.advanceTimersByTimeAsync(3000) + + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(testState.writes.map(({ ptyId, data }) => ({ ptyId, data }))).toEqual([ + { ptyId: 'launch-agent', data: '\x1b[200~resolve the conflicts\x1b[201~' }, + { ptyId: 'launch-agent', data: '\r' } + ]) + }) + + // Regression: with a split bound first, the launch pane's ready bytes were drained to its primary + // handler before its own watcher attached, so a ready agent never got its prompt. + it("sees the launch pane's ready output even when a split bound first and it drained at once", async () => { + let answer!: (startedPtyId: string | null) => void + testState.launchAgentThroughHost.mockReturnValue({ + tabId: TAB, + started: new Promise((done) => (answer = done)) + }) + const { promptDeliveryResult } = launchNewTabPromptThroughHost({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'task', + pasteContent: 'task', + submit: true + }) + + await vi.advanceTimersByTimeAsync(13_000) + attachHostPty('split-shell') + bufferPreHandlerPtyData( + 'launch-agent', + '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}\x1b[?2004h' + ) + // The connect continuation answers, publishes the binding and drains, with no await between. + answer('launch-agent') + testState.appState.ptyIdsByTabId = { [TAB]: ['split-shell', 'launch-agent'] } + for (const subscriber of testState.storeSubscribers) { + subscriber(testState.appState) + } + drainPreHandlerPtyData('launch-agent', vi.fn()) + await vi.advanceTimersByTimeAsync(25_000) + + await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(new Set(testState.writes.map(({ ptyId }) => ptyId))).toEqual(new Set(['launch-agent'])) + expect(testState.writes.filter(({ data }) => data.includes('task'))).toHaveLength(1) + }) }) 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 627a6d8321ba..fae9a87f73f6 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 @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { HostAgentLaunchOutcome } from './agent-launch-through-host' const host = vi.hoisted(() => ({ launchAgentThroughHost: vi.fn(), @@ -7,24 +6,22 @@ const host = vi.hoisted(() => ({ })) vi.mock('@/lib/agent-launch-through-host', () => host) const pasteAgentLaunchPromptOnceReady = vi.hoisted(() => - vi.fn(async (_args: { sendGate?: Promise }) => ({ + vi.fn(async (_args: { launchPty?: Promise }) => ({ 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 }) +function deferredStart() { + let resolve!: (startedPtyId: string | null) => void + const started = new Promise((done) => (resolve = done)) + host.launchAgentThroughHost.mockReturnValue({ tabId: TAB, started }) return resolve } @@ -43,74 +40,85 @@ beforeEach(() => { host.windowMakesHostLaunchTab.mockReturnValue(true) }) -describe('an AI button launched through the host', () => { - 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') +describe('an AI button launched in its own pane', () => { + function launchPty(): Promise { + const pty = pasteAgentLaunchPromptOnceReady.mock.calls.at(-1)?.[0].launchPty + if (!pty) { + throw new Error("the paste was set up without the launch pane's PTY") } - return gate + return pty } - // 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() + // Why: the paste must meet the agent its pane started, never another pane or a failed launch. + it("pastes into the PTY its pane's spawn started the agent in", async () => { + const answer = deferredStart() 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)) + let startedPty: string | null | undefined + void launchPty().then((ptyId) => (startedPty = ptyId)) await Promise.resolve() - expect(gateOpen).toBeUndefined() + expect(startedPty).toBeUndefined() - answer({ kind: 'started' }) + answer('pty-agent') - await expect(sendGate()).resolves.toBe(true) + await expect(launchPty()).resolves.toBe('pty-agent') await expect(promptDeliveryResult).resolves.toEqual({ delivered: true, failureNotified: false }) 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 - }) - await expect(sendGate()).resolves.toBe(false) - expect(toast.error).toHaveBeenCalledOnce() - }) - - it('says nothing and pastes nothing when the user closed the tab', async () => { - deferredOutcome()({ kind: 'closed-by-user' }) + it('never pastes for a launch that did not start, whose pane or notice already said why', async () => { + deferredStart()(null) await expect(launch().promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) - await expect(sendGate()).resolves.toBe(false) - expect(toast.error).not.toHaveBeenCalled() + await expect(launchPty()).resolves.toBeNull() }) }) 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 }) + newTabPromptLaunchesThroughHost({ + promptDelivery: 'submit-after-ready', + pastesPrompt: true, + isRemote: false + }) ).toBe(true) }) it('never a typed prompt, nor a launch the host could turn into a chat', () => { expect( - newTabPromptLaunchesThroughHost({ promptDelivery: 'auto-submit', pastesPrompt: true }) + newTabPromptLaunchesThroughHost({ + promptDelivery: 'auto-submit', + pastesPrompt: true, + isRemote: false + }) + ).toBe(false) + expect( + newTabPromptLaunchesThroughHost({ + promptDelivery: 'draft', + pastesPrompt: true, + isRemote: false + }) + ).toBe(false) + // A workspace that runs over SSH: the pane's local spawn cannot start it. + expect( + newTabPromptLaunchesThroughHost({ + promptDelivery: 'submit-after-ready', + pastesPrompt: true, + isRemote: true + }) ).toBe(false) - expect(newTabPromptLaunchesThroughHost({ promptDelivery: 'draft', pastesPrompt: true })).toBe( - false - ) host.windowMakesHostLaunchTab.mockReturnValue(false) expect( - newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + newTabPromptLaunchesThroughHost({ + promptDelivery: 'submit-after-ready', + pastesPrompt: true, + isRemote: false + }) ).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 index 1ddb83b6f92d..adb9898b8692 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 @@ -1,57 +1,35 @@ -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 + type HostAgentLaunchArgs } 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. + * Whether a new agent tab starts from the host's plan, in its own pane's spawn: 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 + * or one in a workspace that runs over SSH, which the pane's local spawn cannot start. */ export function newTabPromptLaunchesThroughHost(args: { promptDelivery: 'auto-submit' | 'draft' | 'submit-after-ready' pastesPrompt: boolean + isRemote: 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) - } - } + args.promptDelivery === 'submit-after-ready' && + args.pastesPrompt && + !args.isRemote && + windowMakesHostLaunchTab() ) } /** - * 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. + * Starts the agent in a new tab's own pane 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 spawn returns is not missed; the paste is written only once the pane's spawn says the agent + * started there. */ export function launchNewTabPromptThroughHost( args: HostAgentLaunchArgs & { @@ -66,8 +44,7 @@ export function launchNewTabPromptThroughHost( promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> } { 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 { tabId, started } = launchAgentThroughHost(launch) const pasted = pasteAgentLaunchPromptOnceReady({ worktreeId: args.worktreeId, tabId, @@ -75,20 +52,13 @@ export function launchNewTabPromptThroughHost( content: pasteContent, submit, prompt: args.prompt, - sendGate: outcome.then( - (launched) => launched.kind === 'started', - () => false - ), + launchPty: started, ...(onPromptDelivered ? { onPromptDelivered } : {}), ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) }) - const promptDeliveryResult = outcome.then((launched) => { - if (launched.kind === 'started') { - return pasted - } - // This notice says why, once; a tab the user closed says nothing. - showLaunchNotStartedNotice(launched, args.prompt) - return { delivered: false, failureNotified: true } - }) + // The pane already says why it didn't start, and a closed tab needs nothing: no second notice. + const promptDeliveryResult = started.then((launched) => + launched ? pasted : { 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 index de3fa40cf452..1682fe7a9737 100644 --- a/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts +++ b/src/renderer/src/lib/launch-agent-tab-prompt-paste.ts @@ -19,8 +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 + /** The launch pane's PTY once its agent started; the paste goes there and nowhere else. */ + launchPty?: Promise }): Promise<{ delivered: boolean; failureNotified: boolean }> { const { worktreeId, tabId, agent, submit, onPromptDelivered, onPromptDeliveryUnconfirmed } = args const timeoutNotice = createPasteReadinessTimeoutNotice({ @@ -36,7 +36,7 @@ export function pasteAgentLaunchPromptOnceReady(args: { submit, forcePaste: true, onTimeout: timeoutNotice.onTimeout, - ...(args.sendGate ? { sendGate: args.sendGate } : {}), + ...(args.launchPty ? { launchPty: args.launchPty } : {}), ...(onPromptDeliveryUnconfirmed ? { onUnconfirmedDelivery: onPromptDeliveryUnconfirmed } : {}) }).then((delivered) => { if (delivered) { diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 566d6d9fa73b..3e6529b2fbe3 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -9,6 +9,7 @@ import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings' import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' import { getStructuredAgentSessionLaunchOwner, markStructuredAgentSessionLaunchCancelledSilently, @@ -57,6 +58,12 @@ export function buildWorktreePurgeState( } } for (const worktreeId of worktreeIdSet) { + // A launch tab that never spawned has no spawn left to answer its prompt's wait. + for (const tab of s.tabsByWorktree[worktreeId] ?? []) { + if (tab.agentLaunchPane) { + settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null) + } + } for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { if (tab.contentType === 'agent-session') { const target = structuredAgentSessionTargetForTab(s, tab) diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 80fd343101bf..a8e0a8f8c6d8 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -17,6 +17,7 @@ import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-c import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalActions } from './terminal-actions' +import type { AgentPaneLaunchRequest } from '../../../../shared/agent-pane-launch-request' export type TerminalState = { tabsByWorktree: Record @@ -56,6 +57,8 @@ export type TerminalState = { resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent + /** The agent launch the pane's own spawn starts, instead of `command`. */ + agentLaunch?: AgentPaneLaunchRequest agentArgsOverride?: string | null draftPrompt?: string sessionOptions?: Record diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index ce2bc2d43e00..1c14603940df 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -20,6 +20,7 @@ import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' import { dropNativeChatPendingAttachmentsForTab } from '@/components/native-chat/native-chat-pending-attachment-cache' import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' +import { settleAgentLaunchPaneStart } from '@/lib/agent-launch-pane-start' export function createTerminalTabCloseActions( set: TerminalStoreSet, @@ -61,13 +62,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) + // The user closing a launch tab whose agent is still starting: that close wins. Any + // close of it ends the wait for its start, so nothing is pasted into a tab that is gone. + if (closing.agentLaunchPane && !closing.agentLaunchPane.outcome) { + if (closeReason === 'user') { + noteAgentLaunchPaneClosedByUser(tabId, closing.agentLaunchPane.leafId) + } else { + settleAgentLaunchPaneStart(tabId, closing.agentLaunchPane.leafId, null) + } } // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). if (!closedTab) { diff --git a/src/shared/agent-pane-launch-request.ts b/src/shared/agent-pane-launch-request.ts new file mode 100644 index 000000000000..96ed8c5adf94 --- /dev/null +++ b/src/shared/agent-pane-launch-request.ts @@ -0,0 +1,7 @@ +import type { z } from 'zod' +import type { AgentLaunch } from './rpc-contract/agent-launch-params' + +/** `agent.launch` params a pane hands its own spawn; main names the pane, and nothing is recorded. */ +export type AgentPaneLaunchRequest = { + launch: Omit, 'paneKey' | 'operationId'> +}