From c93aa5412dced199d67bc067c3c8743637aca6b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:48:10 -0700 Subject: [PATCH 1/7] fix(agent-launch): an AI button plans its agent inside its own pane's spawn, with the host's planner and no launch record The window queues the launch on the tab; the pane's reserved spawn plans it with the planner every host launch uses and starts it at the pane's size, with its launch token. Every way the spawn ends finishes the launch, and the window pastes the prompt only once this pane says the agent started. An ordinary spawn reaches its provider without waiting on a plan. An OpenCode tab with a model keeps main's host hop untouched (TEMPORARY, until a local producer of model options exists). --- src/main/agent-launch/agent-launch-token.ts | 7 + .../ipc/pty-agent-launch-pane-attach.test.ts | 13 +- src/main/ipc/pty-agent-pane-launch.test.ts | 215 ++++++++++++++++++ .../ipc/pty/ipc/spawn-agent-launch-end.ts | 81 +++++++ src/main/ipc/pty/ipc/spawn-agent-launch.ts | 128 +++++++++++ src/main/ipc/pty/ipc/spawn-commit.ts | 1 + src/main/ipc/pty/ipc/spawn-execute.ts | 3 + src/main/ipc/pty/ipc/spawn-run.ts | 18 +- src/main/ipc/pty/ipc/spawn-state.ts | 7 + src/main/ipc/pty/ipc/spawn-types.ts | 2 + .../runtime/orca-runtime-create-terminal.ts | 5 +- src/main/runtime/orca-runtime.ts | 11 + src/preload/api/pty-api.ts | 3 + src/preload/api/pty-bridge-session-control.ts | 3 + .../terminal-pane/ipc-pty-connect.ts | 12 +- .../terminal-pane/ipc-pty-spawn-request.ts | 6 +- .../terminal-pane/pty-connection-types.ts | 3 + .../hidden-startup-renderer-query.ts | 1 + .../pty-connection/pane-transport-options.ts | 1 + .../pty-connection/pty-resize-geometry.ts | 2 +- .../pty-transport-connect-spawn.test.ts | 40 ++-- .../terminal-pane/pty-transport-types.ts | 2 + .../terminal-presentation-ipc-bridge.ts | 6 - ...eIpcEvents-agent-launch-closed-tab.test.ts | 12 +- .../agent-launch-caller-permissions.test.ts | 5 +- .../lib/agent-launch-caller-placement.test.ts | 18 +- .../lib/agent-launch-funnel-test-harness.ts | 26 ++- .../src/lib/agent-launch-pane-closes.ts | 3 + .../lib/agent-launch-pane-spawn-hold.test.ts | 46 ---- .../src/lib/agent-launch-pane-spawn-hold.ts | 57 ----- .../src/lib/agent-launch-pane-start.test.ts | 22 ++ .../src/lib/agent-launch-pane-start.ts | 30 +++ .../agent-launch-pane-verdict-application.ts | 5 + .../lib/agent-launch-tab-publication.test.ts | 9 + .../src/lib/agent-launch-through-host.test.ts | 171 +++----------- .../src/lib/agent-launch-through-host.ts | 116 ++-------- .../src/lib/launch-agent-in-new-tab.ts | 2 +- ...agent-new-tab-host-route-readiness.test.ts | 15 +- .../launch-agent-new-tab-host-route.test.ts | 73 +++--- .../lib/launch-agent-new-tab-host-route.ts | 72 ++---- .../teardown/worktree-purge-state.ts | 7 + .../src/store/terminals/terminal-state.ts | 3 + .../src/store/terminals/terminal-tab-close.ts | 16 +- src/shared/agent-pane-launch-request.ts | 7 + 44 files changed, 787 insertions(+), 498 deletions(-) create mode 100644 src/main/agent-launch/agent-launch-token.ts create mode 100644 src/main/ipc/pty-agent-pane-launch.test.ts create mode 100644 src/main/ipc/pty/ipc/spawn-agent-launch-end.ts create mode 100644 src/main/ipc/pty/ipc/spawn-agent-launch.ts delete mode 100644 src/renderer/src/lib/agent-launch-pane-spawn-hold.test.ts delete mode 100644 src/renderer/src/lib/agent-launch-pane-spawn-hold.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-start.test.ts create mode 100644 src/renderer/src/lib/agent-launch-pane-start.ts create mode 100644 src/shared/agent-pane-launch-request.ts 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..d6970f200c45 --- /dev/null +++ b/src/main/ipc/pty-agent-pane-launch.test.ts @@ -0,0 +1,215 @@ +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): Promise { + return Promise.resolve( + handlers.get('pty:spawn')!(null, { + 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' + }) + }) + + // 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..170e9dcb27fd --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts @@ -0,0 +1,81 @@ +// 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 { 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 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..81ee30fc3668 --- /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 } : {}), + ...(args.shellOverride ? { shellOverride: args.shellOverride } : {}) + }) + 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-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..73b39e4aeb49 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: parseAgentPaneLaunchRequest is the one check, and refuses what it rejects. + 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..7a7f0a7d8c6e 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; the spawn resolves Qoder's executable itself, for the shell it really runs. */ + async planAgentPaneLaunch( + worktreeId: string, + opts: TerminalCreateOptions + ): Promise { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + return this.buildAgentTerminalCreateOptions(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..0f383977a29b 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, true) 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, false) 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..d5c23bc830a9 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,28 @@ 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) + // 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(true) - 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.toBe(false) }) 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..edec7b67f28e 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, true)) + } 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..fdae0660d07f 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, false) 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..921d792d126a --- /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', false) + settleAgentLaunchPaneStart(TAB, LEAF, false) + settleAgentLaunchPaneStart(TAB, LEAF, true) + await expect(started).resolves.toBe(false) + }) + + it('no, when the user closes it before its spawn returns', async () => { + const started = awaitAgentLaunchPaneStart(TAB, LEAF) + noteAgentLaunchPaneClosedByUser(TAB, LEAF) + await expect(started).resolves.toBe(false) + }) +}) 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..32838935e928 --- /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. */ +export function awaitAgentLaunchPaneStart(tabId: string, leafId: string): Promise { + const key = startKey(tabId, leafId) + pendingStarts.get(key)?.(false) + return new Promise((resolve) => { + pendingStarts.set(key, resolve) + }) +} + +export function settleAgentLaunchPaneStart( + tabId: string | undefined, + leafId: string | undefined, + started: boolean +): void { + if (tabId && leafId) { + const key = startKey(tabId, leafId) + const settle = pendingStarts.get(key) + pendingStarts.delete(key) + settle?.(started) + } +} 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..d17d15f2def1 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, false) + } 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-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..947b2df739b7 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, true) + await expect(started.started).resolves.toBe(true) + + 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, true) + await expect(failed.started).resolves.toBe(false) }) }) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 0b64b860b4fb..7be5c6737235 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 + /** Whether the agent started in the tab's pane; false 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/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..22269b856261 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,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { HostAgentLaunchOutcome } from './agent-launch-through-host' const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' @@ -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!: (started: boolean) => 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(true) 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!: (started: boolean) => 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(false) await vi.advanceTimersByTimeAsync(10_000) await expect(promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) 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..c357c5254744 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(), @@ -13,18 +12,16 @@ const pasteAgentLaunchPromptOnceReady = vi.hoisted(() => })) ) 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!: (started: boolean) => void + const started = new Promise((done) => (resolve = done)) + host.launchAgentThroughHost.mockReturnValue({ tabId: TAB, started }) return resolve } @@ -43,7 +40,7 @@ beforeEach(() => { host.windowMakesHostLaunchTab.mockReturnValue(true) }) -describe('an AI button launched through the host', () => { +describe('an AI button launched in its own pane', () => { function sendGate(): Promise { const gate = pasteAgentLaunchPromptOnceReady.mock.calls.at(-1)?.[0].sendGate if (!gate) { @@ -53,9 +50,9 @@ describe('an AI button launched through the host', () => { } // 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() + // meet the agent its pane started, never a pane whose launch failed. + it("watches for readiness at once but writes only once its pane's spawn started the agent", async () => { + const answer = deferredStart() const { tabId, promptDeliveryResult } = launch() expect(pasteAgentLaunchPromptOnceReady).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tabId, worktreeId: 'wt-1', submit: true }) @@ -65,52 +62,64 @@ describe('an AI button launched through the host', () => { await Promise.resolve() expect(gateOpen).toBeUndefined() - answer({ kind: 'started' }) + answer(true) await expect(sendGate()).resolves.toBe(true) 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' }) - + it('never pastes for a launch that did not start, whose pane or notice already said why', async () => { + deferredStart()(false) 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' }) - await expect(launch().promptDeliveryResult).resolves.toEqual({ - delivered: false, - failureNotified: true - }) - await expect(sendGate()).resolves.toBe(false) - expect(toast.error).not.toHaveBeenCalled() }) }) describe('which new agent tabs start through the host', () => { it('an AI button whose prompt is pasted once ready, in a terminal this window makes', () => { expect( - newTabPromptLaunchesThroughHost({ promptDelivery: 'submit-after-ready', pastesPrompt: true }) + 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..148a444f12b6 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 - ), + sendGate: 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, or the notice for a tab that went, already says why: never a second notice. + const promptDeliveryResult = started.then((launched) => + launched ? pasted : { delivered: false, failureNotified: true } + ) return { tabId, promptDeliveryResult } } 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..6ee3f289a4c1 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, false) + } + } 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..783c7701db06 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, false) + } } // 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'> +} From 8923c2f28f28e57572483093c25d0ac1e4791c6d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:06:56 -0700 Subject: [PATCH 2/7] fix(agent-launch): an AI button's prompt waits for its pane to start before the spawn budget runs, so a pane that settles slowly still gets its prompt; stale comments --- src/main/ipc/pty/ipc/spawn-types.ts | 2 +- ...agent-paste-draft-readiness-budget.test.ts | 42 +++++++++++++++++-- src/renderer/src/lib/agent-paste-draft.ts | 16 ++++++- .../lib/launch-agent-new-tab-host-route.ts | 2 +- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 73b39e4aeb49..d41c402c342e 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -58,7 +58,7 @@ export type PtySpawnIpcArgs = { placement?: unknown // Untyped on purpose: isRestoreSpawnIntent is the one check; anything else is undeclared. spawnIntent?: unknown - // Untyped on purpose: parseAgentPaneLaunchRequest is the one check, and refuses what it rejects. + // 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?: { 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..bafe4be6bed4 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 @@ -4,13 +4,22 @@ import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady } from './agent const testState = vi.hoisted(() => ({ waitForReady: vi.fn(), inspectProcess: vi.fn(), - sendInput: vi.fn() + sendInput: vi.fn(), + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + 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 +159,33 @@ 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])( + 'starts the spawn budget when the pane says whether it started (%s)', + async (started) => { + let answer!: (started: boolean) => void + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-late', + content: 'task', + agent: 'codex', + submit: true, + forcePaste: true, + sendGate: new Promise((resolve) => { + answer = resolve + }) + }) + + await vi.advanceTimersByTimeAsync(13_000) + testState.ptyIdsByTabId['tab-late'] = ['pty-late'] + testState.listeners.forEach((listener) => listener(storeState())) + answer(started) + await vi.advanceTimersByTimeAsync(12_000) + + await expect(promise).resolves.toBe(started) + expect(testState.sendInput.mock.calls.length > 0).toBe(started) + } + ) + 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..d470884d713e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -91,7 +91,8 @@ export async function pasteDraftWhenAgentReady(args: { tabId, spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, - readySignal + readySignal, + ...(args.sendGate ? { spawnClockGate: args.sendGate } : {}) }) // Why: a closed gate is the caller's own outcome to report; the bounded wait above just lapses. if (args.sendGate && !(await args.sendGate)) { @@ -245,6 +246,8 @@ function waitForAgentDraftInputReadyOnTab(args: { spawnTimeoutMs: number readinessTimeoutMs: number readySignal: Parameters[2] + /** The spawn budget starts once this opens, so a pane that starts late still gets all of it. */ + spawnClockGate?: Promise }): Promise<{ ptyId: string; ready: boolean } | null> { return new Promise((resolve) => { let selectedPtyId: string | null = null @@ -285,7 +288,16 @@ function waitForAgentDraftInputReadyOnTab(args: { } } - spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) + const startSpawnClock = (): void => { + if (!settled && !selectedPtyId) { + spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) + } + } + if (args.spawnClockGate) { + void args.spawnClockGate.then((open) => (open ? startSpawnClock() : finish(null))) + } else { + startSpawnClock() + } unsubscribeStore = useAppStore.subscribe(bindFromState) bindFromState(useAppStore.getState()) }) 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 148a444f12b6..0e5caada946f 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 @@ -56,7 +56,7 @@ export function launchNewTabPromptThroughHost( ...(onPromptDelivered ? { onPromptDelivered } : {}), ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) }) - // The pane, or the notice for a tab that went, already says why: never a second notice. + // 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 } ) From f7f41eac5a531f41a82f05477d15d14d731563a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:08:04 -0700 Subject: [PATCH 3/7] test(agent-launch): the late pane-start test's store double is typed without an assertion --- .../agent-paste-draft-readiness-budget.test.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 bafe4be6bed4..e0b13dd1fe56 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,13 +1,16 @@ 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(), - ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, - listeners: new Set<(state: unknown) => void>() -})) +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 } From 41528fe595bfb73a2ac6eff8fe266466d8765fd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:01 -0700 Subject: [PATCH 4/7] test(agent-launch): the late pane-start test pins the prompt written once and Enter once, and nothing when the pane didn't start --- .../src/lib/agent-paste-draft-readiness-budget.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 e0b13dd1fe56..aa55ac38ab3f 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 @@ -166,11 +166,12 @@ describe('pty-bound agent draft readiness budget', () => { it.each([true, false])( 'starts the spawn budget when the pane says whether it started (%s)', async (started) => { + testState.waitForReady.mockResolvedValue(true) let answer!: (started: boolean) => void const promise = pasteDraftWhenAgentReady({ tabId: 'tab-late', content: 'task', - agent: 'codex', + agent: 'claude', submit: true, forcePaste: true, sendGate: new Promise((resolve) => { @@ -185,7 +186,10 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(12_000) await expect(promise).resolves.toBe(started) - expect(testState.sendInput.mock.calls.length > 0).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) } ) From ec32f6e80a69fa240efdce15597980e99135dffe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:42:23 -0700 Subject: [PATCH 5/7] fix(agent-launch): an AI button's prompt goes only to the PTY its pane started, never a split's shell that bound first; the pane's start answer names that PTY --- .../terminal-pane/ipc-pty-connect.ts | 4 +- .../pty-transport-connect-spawn.test.ts | 5 +- .../lib/agent-launch-funnel-test-harness.ts | 2 +- .../src/lib/agent-launch-pane-closes.ts | 2 +- .../src/lib/agent-launch-pane-start.test.ts | 10 ++-- .../src/lib/agent-launch-pane-start.ts | 12 ++-- .../agent-launch-pane-verdict-application.ts | 2 +- .../src/lib/agent-launch-prompt-delivery.ts | 14 ++--- .../src/lib/agent-launch-through-host.test.ts | 8 +-- .../src/lib/agent-launch-through-host.ts | 4 +- ...agent-paste-draft-readiness-budget.test.ts | 8 +-- src/renderer/src/lib/agent-paste-draft.ts | 55 +++++++++++++------ ...agent-new-tab-host-route-readiness.test.ts | 50 ++++++++++++++--- .../launch-agent-new-tab-host-route.test.ts | 35 ++++++------ .../lib/launch-agent-new-tab-host-route.ts | 2 +- .../src/lib/launch-agent-tab-prompt-paste.ts | 6 +- .../teardown/worktree-purge-state.ts | 2 +- .../src/store/terminals/terminal-tab-close.ts | 2 +- 18 files changed, 137 insertions(+), 86 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts index 0f383977a29b..d7394497f937 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -85,7 +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, true) + settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, spawnResult.id) const retireDisposedFreshSpawn = async (): Promise => { if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { return @@ -156,7 +156,7 @@ export async function connectIpcPty( } return projectIpcPtyConnectResult(spawnResult) } catch (error) { - settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, false) + settleAgentLaunchPaneStart(transportOptions.tabId, transportOptions.leafId, null) if (context.isDestroyed()) { return } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index d5c23bc830a9..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 @@ -108,6 +108,7 @@ describe('createIpcPtyTransport', () => { const pane = { worktreeId: 'wt', tabId: 'tab-launch', leafId: 'leaf-launch' } 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: '', @@ -116,12 +117,12 @@ describe('createIpcPtyTransport', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-launch', agentLaunch }) ) - await expect(started).resolves.toBe(true) + await expect(started).resolves.toBe('pty-launch') 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.toBe(false) + 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/lib/agent-launch-funnel-test-harness.ts b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts index edec7b67f28e..8ae8b40595e4 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -96,7 +96,7 @@ function createTabInto(store: { tabsByWorktree: Record store.tabsByWorktree[worktreeId] = [...(store.tabsByWorktree[worktreeId] ?? []), tab] const { initialLeafId } = opts if (opts.pendingStartup?.agentLaunch && initialLeafId) { - queueMicrotask(() => settleAgentLaunchPaneStart(tab.id, initialLeafId, true)) + queueMicrotask(() => settleAgentLaunchPaneStart(tab.id, initialLeafId, `${tab.id}-pty`)) } return tab } diff --git a/src/renderer/src/lib/agent-launch-pane-closes.ts b/src/renderer/src/lib/agent-launch-pane-closes.ts index fdae0660d07f..ef4f5c165fc9 100644 --- a/src/renderer/src/lib/agent-launch-pane-closes.ts +++ b/src/renderer/src/lib/agent-launch-pane-closes.ts @@ -17,7 +17,7 @@ function closeKey(tabId: string, leafId: string): string { } export function noteAgentLaunchPaneClosedByUser(tabId: string, leafId: string): void { - settleAgentLaunchPaneStart(tabId, leafId, false) + 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-start.test.ts b/src/renderer/src/lib/agent-launch-pane-start.test.ts index 921d792d126a..237cf283c04c 100644 --- a/src/renderer/src/lib/agent-launch-pane-start.test.ts +++ b/src/renderer/src/lib/agent-launch-pane-start.test.ts @@ -8,15 +8,15 @@ 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', false) - settleAgentLaunchPaneStart(TAB, LEAF, false) - settleAgentLaunchPaneStart(TAB, LEAF, true) - await expect(started).resolves.toBe(false) + 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.toBe(false) + 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 index 32838935e928..216d2aa1f57c 100644 --- a/src/renderer/src/lib/agent-launch-pane-start.ts +++ b/src/renderer/src/lib/agent-launch-pane-start.ts @@ -1,16 +1,16 @@ // Session-only: this pane's own spawn answers first, and its answer gates the prompt's paste. -const pendingStarts = new Map void>() +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. */ -export function awaitAgentLaunchPaneStart(tabId: string, leafId: string): Promise { +/** 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)?.(false) + pendingStarts.get(key)?.(null) return new Promise((resolve) => { pendingStarts.set(key, resolve) }) @@ -19,12 +19,12 @@ export function awaitAgentLaunchPaneStart(tabId: string, leafId: string): Promis export function settleAgentLaunchPaneStart( tabId: string | undefined, leafId: string | undefined, - started: boolean + startedPtyId: string | null ): void { if (tabId && leafId) { const key = startKey(tabId, leafId) const settle = pendingStarts.get(key) pendingStarts.delete(key) - settle?.(started) + 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 d17d15f2def1..7f9dd78daacf 100644 --- a/src/renderer/src/lib/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-launch-pane-verdict-application.ts @@ -20,7 +20,7 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): } if (verdict.kind !== 'proceed') { // Reported before the pane's spawn returns: its prompt is never pasted. - settleAgentLaunchPaneStart(tabId, leafId, false) + settleAgentLaunchPaneStart(tabId, leafId, null) } switch (verdict.kind) { case 'proceed': 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-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts index 947b2df739b7..8760ca7ca0e0 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -90,8 +90,8 @@ describe('a desktop AI-button launch', () => { 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, true) - await expect(started.started).resolves.toBe(true) + settleAgentLaunchPaneStart(started.tabId, leafId, 'pty-1') + await expect(started.started).resolves.toBe('pty-1') const failed = launch() const failedLeaf = launchTab(failed.tabId)!.agentLaunchPane!.leafId @@ -101,7 +101,7 @@ describe('a desktop AI-button launch', () => { leafId: failedLeaf, verdict: { kind: 'not-started', code: 'spawn claude ENOENT' } }) - settleAgentLaunchPaneStart(failed.tabId, failedLeaf, true) - await expect(failed.started).resolves.toBe(false) + 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 7be5c6737235..a61d06959a6b 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -68,8 +68,8 @@ export function windowMakesHostLaunchTab(): boolean { export function launchAgentThroughHost(args: HostAgentLaunchArgs): { tabId: string - /** Whether the agent started in the tab's pane; false when it could not, or the user closed it. */ - started: 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() 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 aa55ac38ab3f..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 @@ -164,17 +164,17 @@ describe('pty-bound agent draft readiness budget', () => { // An AI button's pane may settle its grid for longer than the spawn budget before it spawns. it.each([true, false])( - 'starts the spawn budget when the pane says whether it started (%s)', + 'delivers once the pane says whether it started, however late (%s)', async (started) => { testState.waitForReady.mockResolvedValue(true) - let answer!: (started: boolean) => void + let answer!: (startedPtyId: string | null) => void const promise = pasteDraftWhenAgentReady({ tabId: 'tab-late', content: 'task', agent: 'claude', submit: true, forcePaste: true, - sendGate: new Promise((resolve) => { + launchPty: new Promise((resolve) => { answer = resolve }) }) @@ -182,7 +182,7 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(13_000) testState.ptyIdsByTabId['tab-late'] = ['pty-late'] testState.listeners.forEach((listener) => listener(storeState())) - answer(started) + answer(started ? 'pty-late' : null) await vi.advanceTimersByTimeAsync(12_000) await expect(promise).resolves.toBe(started) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index d470884d713e..ce21674be3fa 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 @@ -92,10 +92,10 @@ export async function pasteDraftWhenAgentReady(args: { spawnTimeoutMs: PTY_SPAWN_TIMEOUT_MS, readinessTimeoutMs, readySignal, - ...(args.sendGate ? { spawnClockGate: args.sendGate } : {}) + ...(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 @@ -246,11 +246,14 @@ function waitForAgentDraftInputReadyOnTab(args: { spawnTimeoutMs: number readinessTimeoutMs: number readySignal: Parameters[2] - /** The spawn budget starts once this opens, so a pane that starts late still gets all of it. */ - spawnClockGate?: Promise + /** Names the PTY to deliver to; the tab's first is only watched until then. */ + launchPty?: Promise }): Promise<{ ptyId: string; ready: boolean } | null> { return new Promise((resolve) => { let selectedPtyId: string | null = null + // Until a launch names its pane's PTY, no watched PTY may be delivered to. + let deliverablePtyId: string | null = null + const readyByPtyId = new Map() let settled = false let spawnTimer: number | null = null let unsubscribeStore: (() => void) | null = null @@ -266,8 +269,14 @@ function waitForAgentDraftInputReadyOnTab(args: { unsubscribeStore?.() resolve(result) } + const settleIfDeliverable = (ptyId: string): void => { + const ready = readyByPtyId.get(ptyId) + if (ready !== undefined && (!args.launchPty || deliverablePtyId === ptyId)) { + finish({ ptyId, ready }) + } + } const bindPty = (ptyId: string): void => { - if (selectedPtyId || settled) { + if (selectedPtyId === ptyId || settled) { return } selectedPtyId = ptyId @@ -278,25 +287,35 @@ function waitForAgentDraftInputReadyOnTab(args: { // 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 }) + (ready) => { + readyByPtyId.set(ptyId, ready) + settleIfDeliverable(ptyId) + } ) } const bindFromState = (state: ReturnType): void => { const ptyId = state.ptyIdsByTabId[args.tabId]?.[0] - if (ptyId) { + if (ptyId && !selectedPtyId) { bindPty(ptyId) } } - const startSpawnClock = (): void => { - if (!settled && !selectedPtyId) { - spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) - } - } - if (args.spawnClockGate) { - void args.spawnClockGate.then((open) => (open ? startSpawnClock() : finish(null))) + if (args.launchPty) { + // A split made while the agent starts can bind its own shell first: the launch pane's PTY wins. + void args.launchPty.then((ptyId) => { + if (!ptyId) { + finish(null) + return + } + deliverablePtyId = ptyId + if (readyByPtyId.has(ptyId)) { + settleIfDeliverable(ptyId) + } else { + bindPty(ptyId) + } + }) } else { - startSpawnClock() + spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) } unsubscribeStore = useAppStore.subscribe(bindFromState) bindFromState(useAppStore.getState()) 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 22269b856261..2e854c91a4d9 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 @@ -4,7 +4,7 @@ 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: {}, @@ -47,8 +47,8 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ 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 } })) @@ -88,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!: (started: boolean) => void + let answer!: (startedPtyId: string | null) => void testState.launchAgentThroughHost.mockReturnValue({ tabId: TAB, - started: new Promise((done) => (answer = done)) + started: new Promise((done) => (answer = done)) }) const onPromptDeliveryUnconfirmed = vi.fn() const { promptDeliveryResult } = launchNewTabPromptThroughHost({ @@ -111,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(true) + answer('pty-1') await vi.advanceTimersByTimeAsync(1200) expect(testState.writes).toEqual([]) @@ -129,10 +129,10 @@ describe('an AI button whose agent is ready before the host answers', () => { }) it('writes nothing, and seeds no chat copy, when its pane says the agent did not start', async () => { - let answer!: (started: boolean) => void + let answer!: (startedPtyId: string | null) => void testState.launchAgentThroughHost.mockReturnValue({ tabId: TAB, - started: new Promise((done) => (answer = done)) + started: new Promise((done) => (answer = done)) }) const { promptDeliveryResult } = launchNewTabPromptThroughHost({ agent: 'claude', @@ -144,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(false) + answer(null) await vi.advanceTimersByTimeAsync(10_000) await expect(promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) @@ -152,4 +152,36 @@ 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' } + ]) + }) }) 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 c357c5254744..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 @@ -6,7 +6,7 @@ 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 })) @@ -19,8 +19,8 @@ const { launchNewTabPromptThroughHost, newTabPromptLaunchesThroughHost } = const TAB = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' function deferredStart() { - let resolve!: (started: boolean) => void - const started = new Promise((done) => (resolve = done)) + let resolve!: (startedPtyId: string | null) => void + const started = new Promise((done) => (resolve = done)) host.launchAgentThroughHost.mockReturnValue({ tabId: TAB, started }) return resolve } @@ -41,41 +41,40 @@ beforeEach(() => { }) describe('an AI button launched in its own pane', () => { - 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') + 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 agent its pane started, never a pane whose launch failed. - it("watches for readiness at once but writes only once its pane's spawn started the agent", async () => { + // 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(true) + 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 that did not start, whose pane or notice already said why', async () => { - deferredStart()(false) + deferredStart()(null) await expect(launch().promptDeliveryResult).resolves.toEqual({ delivered: false, failureNotified: true }) - await expect(sendGate()).resolves.toBe(false) + await expect(launchPty()).resolves.toBeNull() }) }) 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 0e5caada946f..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 @@ -52,7 +52,7 @@ export function launchNewTabPromptThroughHost( content: pasteContent, submit, prompt: args.prompt, - sendGate: started, + launchPty: started, ...(onPromptDelivered ? { onPromptDelivered } : {}), ...(onPromptDeliveryUnconfirmed ? { onPromptDeliveryUnconfirmed } : {}) }) 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 6ee3f289a4c1..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 @@ -61,7 +61,7 @@ export function buildWorktreePurgeState( // 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, false) + settleAgentLaunchPaneStart(tab.id, tab.agentLaunchPane.leafId, null) } } for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 783c7701db06..1c14603940df 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -68,7 +68,7 @@ export function createTerminalTabCloseActions( if (closeReason === 'user') { noteAgentLaunchPaneClosedByUser(tabId, closing.agentLaunchPane.leafId) } else { - settleAgentLaunchPaneStart(tabId, closing.agentLaunchPane.leafId, false) + settleAgentLaunchPaneStart(tabId, closing.agentLaunchPane.leafId, null) } } // Why: capture the first-matched tab's snapshot for the Cmd+Shift+T reopen stack (see capturedSnapshot below). From c6fb223df9815692f8bf3d2791d14dbc7cf21aef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:00:20 -0700 Subject: [PATCH 6/7] fix(agent-launch): an AI button's launch watches each split's readiness as it binds, so the launch pane's own ready output is seen even when a split bound first and it drained at once --- src/renderer/src/lib/agent-paste-draft.ts | 62 +++++++------------ ...agent-new-tab-host-route-readiness.test.ts | 42 ++++++++++++- 2 files changed, 63 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index ce21674be3fa..516826acbd4e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -246,18 +246,13 @@ function waitForAgentDraftInputReadyOnTab(args: { spawnTimeoutMs: number readinessTimeoutMs: number readySignal: Parameters[2] - /** Names the PTY to deliver to; the tab's first is only watched until then. */ launchPty?: Promise }): Promise<{ ptyId: string; ready: boolean } | null> { return new Promise((resolve) => { - let selectedPtyId: string | null = null - // Until a launch names its pane's PTY, no watched PTY may be delivered to. - let deliverablePtyId: string | null = null - const readyByPtyId = new Map() + 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 @@ -269,51 +264,42 @@ function waitForAgentDraftInputReadyOnTab(args: { unsubscribeStore?.() resolve(result) } - const settleIfDeliverable = (ptyId: string): void => { - const ready = readyByPtyId.get(ptyId) - if (ready !== undefined && (!args.launchPty || deliverablePtyId === ptyId)) { - finish({ ptyId, ready }) + 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 bindPty = (ptyId: string): void => { - if (selectedPtyId === ptyId || settled) { + 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) => { - readyByPtyId.set(ptyId, ready) - settleIfDeliverable(ptyId) - } - ) + void watchPty(ptyId).then((ready) => finish({ ptyId, ready })) } const bindFromState = (state: ReturnType): void => { - const ptyId = state.ptyIdsByTabId[args.tabId]?.[0] - if (ptyId && !selectedPtyId) { - 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]) } } - if (args.launchPty) { - // A split made while the agent starts can bind its own shell first: the launch pane's PTY wins. - void args.launchPty.then((ptyId) => { - if (!ptyId) { - finish(null) - return - } - deliverablePtyId = ptyId - if (readyByPtyId.has(ptyId)) { - settleIfDeliverable(ptyId) - } else { - bindPty(ptyId) - } - }) + void args.launchPty.then((ptyId) => (ptyId ? deliverPty(ptyId) : finish(null))) } else { spawnTimer = window.setTimeout(() => finish(null), args.spawnTimeoutMs) } 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 2e854c91a4d9..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 @@ -41,9 +41,6 @@ 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), @@ -58,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] } @@ -81,6 +80,7 @@ describe('an AI button whose agent is ready before the host answers', () => { }) afterEach(() => { + clearPreHandlerPtyState('launch-agent') vi.unstubAllGlobals() vi.useRealTimers() }) @@ -184,4 +184,40 @@ describe('an AI button whose agent is ready before the host answers', () => { { 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) + }) }) From f4938433cca4a8873ccc618fa4f1a5c809299c5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:02:21 -0700 Subject: [PATCH 7/7] fix(agent-launch): an AI button's pane plans with the host's current planner and runs its runtime fact's shell, so its command is quoted for the shell it is typed into, as a host launch is; the tab's inferred shell is not sent as a request --- src/main/ipc/pty-agent-pane-launch.test.ts | 29 ++++++++++++++++++- .../ipc/pty/ipc/spawn-agent-launch-end.ts | 3 ++ src/main/ipc/pty/ipc/spawn-agent-launch.ts | 4 +-- src/main/ipc/pty/ipc/spawn-preflight.ts | 5 ++-- src/main/ipc/pty/ipc/spawn-qoder-command.ts | 3 +- src/main/runtime/orca-runtime.ts | 4 +-- 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/pty-agent-pane-launch.test.ts b/src/main/ipc/pty-agent-pane-launch.test.ts index d6970f200c45..907fc573a40b 100644 --- a/src/main/ipc/pty-agent-pane-launch.test.ts +++ b/src/main/ipc/pty-agent-pane-launch.test.ts @@ -102,9 +102,13 @@ describe('a pane that starts its own agent launch', () => { return runtime } - function spawnPane(agentLaunch: AgentPaneLaunchRequest = AI_BUTTON): Promise { + 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', @@ -133,6 +137,29 @@ describe('a pane that starts its own agent launch', () => { }) }) + // 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 }) => ({ diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts index 170e9dcb27fd..54a41b46384b 100644 --- a/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts +++ b/src/main/ipc/pty/ipc/spawn-agent-launch-end.ts @@ -7,6 +7,7 @@ import { } 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' @@ -16,6 +17,8 @@ export type PaneAgentLaunch = { 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. */ diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts index 81ee30fc3668..55b7b80e5c96 100644 --- a/src/main/ipc/pty/ipc/spawn-agent-launch.ts +++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts @@ -121,8 +121,8 @@ export async function planPaneAgentLaunch(ctx: PtyIpcSpawnState): Promise ...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}), ...(launchPreferences ? { launchPreferences } : {}), ...(intent.launchSource ? { launchSource: intent.launchSource } : {}), - ...(intent.cwd ? { cwd: intent.cwd } : {}), - ...(args.shellOverride ? { shellOverride: args.shellOverride } : {}) + ...(intent.cwd ? { cwd: intent.cwd } : {}) }) + ctx.paneAgentLaunch.launchRuntime = planned.launchRuntime ctx.args = withPlannedLaunch(args, planned) } 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/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7a7f0a7d8c6e..0c77702bf402 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -131,13 +131,13 @@ class OrcaRuntimeService extends OrcaRuntimeWithMigrationCatalog { } /** Plans an agent launch a window pane's own spawn asks for, with the planner every host launch - * uses; the spawn resolves Qoder's executable itself, for the shell it really runs. */ + * uses, including its runtime fact. */ async planAgentPaneLaunch( worktreeId: string, opts: TerminalCreateOptions ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - return this.buildAgentTerminalCreateOptions(workspace, opts) + return this.resolveAgentTerminalCreateOptions(workspace, opts) } /** Whether a running process holds this pane now: such a pane is attached to, never launched into. */