diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts index e053d64461d4..73ae9f718709 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -76,6 +76,19 @@ describe('a launch into an existing workspace', () => { }) }) + // An AI button's click names no operation: the host writes no record for it, so a record store + // that cannot open never stops it. + it('starts a reserved pane with no operation id even when the launch record cannot open', async () => { + setAgentLaunchRecordStore(null) + const runtime = runtimeStub({ settings: TERMINAL_ONLY }) + + const result = await launch({ ...EXISTING_LAUNCH, paneKey: PANE_KEY }, runtime) + + expect(result.outcome.kind).toBe('terminal') + expect(terminalOptions(runtime)).toMatchObject({ tabId: TAB_ID, leafId: LEAF_ID }) + expect(runtime.openAgentSessionRecordStore).not.toHaveBeenCalled() + }) + it('leaves the pane to the runtime when the caller reserved none', async () => { // Every shipped caller sends nothing; its options must not gain a key. const runtime = runtimeStub({ settings: TERMINAL_ONLY }) 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 c1fc80cafa25..9422388e1034 100644 --- a/src/renderer/src/lib/agent-launch-funnel-test-harness.ts +++ b/src/renderer/src/lib/agent-launch-funnel-test-harness.ts @@ -51,8 +51,10 @@ export function createLaunchFunnelStore(): { seedNativeChatLaunchPrompt: ReturnType seedNativeChatLaunchDraft: ReturnType markNativeChatLaunchPromptFailed: ReturnType + setTabAgentLaunchPane: ReturnType + closeTab: ReturnType } { - return { + const store = { settings: launchFunnelSettings(), repos: [], allWorktrees: vi.fn(() => []), @@ -60,7 +62,7 @@ export function createLaunchFunnelStore(): { openFiles: [], browserTabsByWorktree: {}, tabBarOrderByWorktree: {}, - createTab: vi.fn(() => ({ id: 'tab-1' })), + createTab: vi.fn(), queueTabInitialCwd: vi.fn(), queueTabStartupCommand: vi.fn(), setActiveTabType: vi.fn(), @@ -68,7 +70,23 @@ export function createLaunchFunnelStore(): { setAgentStatus: vi.fn(), seedNativeChatLaunchPrompt: vi.fn(), seedNativeChatLaunchDraft: vi.fn(), - markNativeChatLaunchPromptFailed: vi.fn() + markNativeChatLaunchPromptFailed: vi.fn(), + setTabAgentLaunchPane: vi.fn(), + closeTab: vi.fn() + } + store.createTab.mockImplementation(createTabInto(store)) + return store +} + +/** A tab made with its own id joins the workspace, as the real store's does; launches read it back. */ +function createTabInto(store: { tabsByWorktree: Record }) { + return (worktreeId: string, _groupId?: string, _x?: unknown, opts?: { id?: string }) => { + if (!opts?.id) { + return { id: 'tab-1' } + } + const tab = { id: opts.id } + store.tabsByWorktree[worktreeId] = [...(store.tabsByWorktree[worktreeId] ?? []), tab] + return tab } } @@ -84,7 +102,7 @@ export function resetLaunchFunnelStore( store.openFiles = [] store.browserTabsByWorktree = {} store.tabBarOrderByWorktree = {} - store.createTab.mockReturnValue({ id: 'tab-1' }) + store.createTab.mockImplementation(createTabInto(store)) } /** The startup command `queueTabStartupCommand` received, or undefined when no tab was queued. */ @@ -109,9 +127,9 @@ export function createdTabGroupId(store: LaunchFunnelStore): string | undefined return store.createTab.mock.calls[0]?.[1] } -/** The `agent.launchReplay` request a launch sent its host, or undefined when the window planned it. */ +/** 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.launchReplay')?.[2] + return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launch')?.[2] } diff --git a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts index 6c22f7278b01..7e72e8399b29 100644 --- a/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts +++ b/src/renderer/src/lib/agent-launch-pane-spawn-hold.ts @@ -3,9 +3,8 @@ * * Such a pane must not spawn until the host has taken it: before that, main knows of no launch for * it and would give it a shell, which the host's own spawn would then be refused over. The host - * takes the pane when it asks this window to show the launch's tab, so the hold ends there, or when - * the launch is over. Session-only: after a reload the pane's spawn asks main, which waits for a launch - * still running there. + * 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 } 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 0377b17dee0f..ffdfa42ececf 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.test.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.test.ts @@ -122,27 +122,6 @@ describe('publishing a launch tab before its agent exists', () => { expect(launchPane()).toEqual({ leafId: LEAF_ID, operationId: 'op-2' }) }) - it('hands a tab this window made for the launch to the host, as the launch to take back', async () => { - const { holdAgentLaunchPaneSpawn, agentLaunchPaneSpawnHold } = - await import('./agent-launch-pane-spawn-hold') - holdAgentLaunchPaneSpawn(TAB_ID, LEAF_ID) - store.getState().createTab(WT, undefined, undefined, { - id: TAB_ID, - initialLeafId: LEAF_ID, - agentLaunchPane: { leafId: LEAF_ID } - }) - const held = agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID) - - const published = publishAgentLaunchTab(request()) - - // The host now owns the pane, so a refused launch takes the tab back rather than leaving a shell. - expect(published).toMatchObject({ tabId: TAB_ID, created: true }) - await expect(held).resolves.toBeUndefined() - expect(agentLaunchPaneSpawnHold(TAB_ID, LEAF_ID)).toBeNull() - // A retry finds no hold: the tab is no longer only this launch's. - expect(publishAgentLaunchTab(request({ requestId: 'request-2' })).created).toBe(false) - }) - it("remounts a pane that showed an earlier launch's outcome, so it spawns for the new launch", () => { const generation = () => store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === TAB_ID)?.generation ?? 0 diff --git a/src/renderer/src/lib/agent-launch-tab-publication.ts b/src/renderer/src/lib/agent-launch-tab-publication.ts index 6e2dc1d2b93e..20116563991f 100644 --- a/src/renderer/src/lib/agent-launch-tab-publication.ts +++ b/src/renderer/src/lib/agent-launch-tab-publication.ts @@ -17,7 +17,6 @@ import { useAppStore } from '../store' import { resolveUnifiedTabCreatePlacement } from '../store/slices/tabs/tabs-create-placement' import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { rememberAgentLaunchPanePrompt } from './agent-launch-pane-prompt' -import { releaseAgentLaunchPaneSpawn } from './agent-launch-pane-spawn-hold' function landedGroupId(worktreeId: string, tabId: string): string | undefined { return useAppStore @@ -77,9 +76,7 @@ export function publishAgentLaunchTab( remount: kept?.leafId === leafId && kept.outcome !== undefined }) } - // A tab this window made for this launch is the launch's to take back if it never runs. - const madeForThisLaunch = releaseAgentLaunchPaneSpawn(tabId, leafId) - return { tabId, created: madeForThisLaunch, placement: { groupId } } + return { tabId, created: false, placement: { groupId } } } const placement = resolveUnifiedTabCreatePlacement({ diff --git a/src/renderer/src/lib/agent-launch-through-host.test.ts b/src/renderer/src/lib/agent-launch-through-host.test.ts index abccb529cc46..998b392a57a2 100644 --- a/src/renderer/src/lib/agent-launch-through-host.test.ts +++ b/src/renderer/src/lib/agent-launch-through-host.test.ts @@ -37,7 +37,6 @@ createTabsSliceMockApi() const { launchAgentThroughHost } = await import('./agent-launch-through-host') const { agentLaunchPaneSpawnHold, releaseAgentLaunchPaneSpawn } = await import('./agent-launch-pane-spawn-hold') -const { agentLaunchPanePrompt } = await import('./agent-launch-pane-prompt') const WT = 'repo1::/tmp/feature' let store: ReturnType @@ -117,32 +116,31 @@ describe('a desktop launch through the host', () => { expect(tab).toMatchObject({ ptyId: null, launchAgent: 'claude' }) const leafId = tab.agentLaunchPane!.leafId expect(agentLaunchPaneSpawnHold(tab.id, leafId)).not.toBeNull() - expect(agentLaunchPanePrompt(tab.id)).toBe('fix the failing checks') expect(store.getState().activeTabId).toBe(tabId) // No prompt: the window pastes it, as main does, once the agent runs. - expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'agent.launchReplay', { + 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', - operationId: expect.stringMatching(/^\d+-[0-9a-f]{32}$/), paneKey: `${tabId}:${leafId}` }) }) - it('names every click as its own operation', () => { + // 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(second.operationId).not.toBe(first.operationId) + expect(first).not.toHaveProperty('operationId') expect(second.paneKey).not.toBe(first.paneKey) }) - it('says the agent started only once the host answers, with its pane attached', async () => { + 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() @@ -156,35 +154,27 @@ describe('a desktop launch through the host', () => { await expect(outcome).resolves.toEqual({ kind: 'started' }) expect(launchTab(tabId)).toBeDefined() + expect(launchTab(tabId)?.agentLaunchPane).toBeUndefined() }) - it('takes its tab back on a refusal, before the pane ever spawns', async () => { + // 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('agent_session_operation_conflict')) + reply.reject(rpcError('terminal_reveal_failed')) await expect(outcome).resolves.toEqual({ kind: 'not-started', - unconfirmed: false, - code: 'agent_session_operation_conflict' + unconfirmed: true, + code: 'terminal_reveal_failed' }) - expect(launchTab(tabId)).toBeUndefined() - }) - - it('leaves a launch the host took to its pane, which says how it ended', async () => { - const reply = deferred() - callRuntimeRpc.mockReturnValue(reply.promise) - const { tabId, outcome } = launch() - hostTakesPane(tabId) - - reply.reject(rpcError('agent_session_operation_unknown')) - - await expect(outcome).resolves.toEqual({ kind: 'pane-says' }) expect(launchTab(tabId)).toBeDefined() }) + // 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) @@ -202,7 +192,13 @@ describe('a desktop launch through the host', () => { // Why: the close was the user's own, as on the phone's "+". it('says nothing more when the user closed the tab while it started', async () => { - callRuntimeRpc.mockRejectedValueOnce(rpcError('agent_launch_tab_closed')) - await expect(launch().outcome).resolves.toEqual({ kind: 'closed-by-user' }) + 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' }) }) }) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 3ce247f95559..0b64b860b4fb 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -2,16 +2,16 @@ * A desktop launch started through the host's `agent.launch`, with no prompt. * * This window owns the workspace's tab layout, so it makes the agent's tab at the click, in the - * split it was asked for, as any new agent tab is made. The host records the launch under this - * click's operation id and starts the agent into that tab's pane. The pane's spawn waits until the - * host has taken it (`agent-launch-pane-spawn-hold`), then attaches to the agent or says why it could - * not start. The prompt stays this window's to paste, as main pastes it, once the agent has started. + * 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. */ import { useAppStore } from '@/store' import { createBrowserUuid } from '@/lib/browser-uuid' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' -import { rememberAgentLaunchPanePrompt } from '@/lib/agent-launch-pane-prompt' import { holdAgentLaunchPaneSpawn, isAgentLaunchPaneSpawnHeld @@ -19,9 +19,7 @@ import { 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 { createAgentSessionOperationId } from '@/runtime/agent-session-operation-id' import { isAgentLaunchResult } from '../../../shared/agent-launch-intent' -import { AGENT_LAUNCH_TAB_CLOSED_CODE } from '../../../shared/agent-launch-tab-closed' import { makePaneKey } from '../../../shared/stable-pane-id' import { isNativeChatEnabled } from '../../../shared/structured-native-chat-launch-route' import type { TuiAgent } from '../../../shared/tui-agent' @@ -53,36 +51,32 @@ export type HostAgentLaunchArgs = { export type HostAgentLaunchOutcome = /** The agent was started in this tab's pane, which is attached to it. */ | { kind: 'started' } - /** The pane shows how the launch ended: couldn't start, or couldn't confirm it started. */ - | { kind: 'pane-says' } /** The user closed the tab while it started, and with it the launch: nothing more to say. */ | { kind: 'closed-by-user' } - /** The tab is gone, so the window says it: nothing started, or whether it did is unknown. */ + /** 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 } -/** Refused at admission: nothing ran under this click, and the host takes back the tab it was shown. */ -const ADMISSION_REFUSAL_CODES = new Set([ - 'agent_session_operation_invalid', - 'agent_session_operation_conflict', - 'agent_session_operation_expired', - 'agent_session_operation_capacity' -]) - function tabExists(worktreeId: string, tabId: string): boolean { return (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) } function closeLaunchTab(worktreeId: string, tabId: string): void { if (tabExists(worktreeId, tabId)) { - useAppStore.getState().closeTab(tabId, { recordInteraction: false }) + // 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; anything else, its pane explains. +// 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: 'pane-says' } + : { kind: 'not-started', unconfirmed: true } } function stringSessionOptions(options: Record | undefined): { @@ -113,27 +107,29 @@ async function settleLaunch( send: Promise, releaseHold: () => void ): Promise { + const closedByUser = (): boolean => !tabExists(args.worktreeId, pane.tabId) try { - return outcomeFromResult(await send) + 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) { - const code = error instanceof RuntimeRpcCallError ? error.code : undefined - if (code === AGENT_LAUNCH_TAB_CLOSED_CODE) { + if (closedByUser()) { return { kind: 'closed-by-user' } } - // The host took the pane once it showed the tab; a pane it never took would open as a shell, - // and a refused one is the host's to take back. + const 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 || (code !== undefined && ADMISSION_REFUSAL_CODES.has(code))) { + if (!hostTookPane) { closeLaunchTab(args.worktreeId, pane.tabId) - return { - kind: 'not-started', - unconfirmed: code === 'agent_session_operation_unknown', - ...(code ? { code } : {}) - } } - return { kind: 'pane-says' } + 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) + } } } @@ -152,10 +148,9 @@ export function launchAgentThroughHost(args: HostAgentLaunchArgs): { const leafId = createBrowserUuid() // Before the tab exists, so its first mount already waits. const releaseHold = holdAgentLaunchPaneSpawn(tabId, leafId) - const send = callRuntimeRpc({ kind: 'local' }, 'agent.launchReplay', { + const send = callRuntimeRpc({ kind: 'local' }, 'agent.launch', { ...launchParams(args), - // A new click is a new operation; the pane is this click's too. - operationId: createAgentSessionOperationId(), + // The pane is this click's. paneKey: makePaneKey(tabId, leafId) }) store.createTab(args.worktreeId, args.groupId, undefined, { @@ -167,7 +162,6 @@ export function launchAgentThroughHost(args: HostAgentLaunchArgs): { ...(args.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}), ...(args.viewMode ? { viewMode: args.viewMode } : {}) }) - rememberAgentLaunchPanePrompt(tabId, args.prompt) seedNativeChatAppliedSessionOptions(tabId, args.agent, args.sessionOptions) // Why: without it an activated launch can stay hidden behind an editor. store.setActiveTabType('terminal', args.worktreeId) 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 d2ab492be0c8..89e3fd05f56f 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 @@ -145,7 +145,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: 'pane-says' }) + answer({ kind: 'not-started', unconfirmed: true }) 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 54213717101b..627a6d8321ba 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 @@ -83,15 +83,13 @@ describe('an AI button launched through the host', () => { expect(toast.error).toHaveBeenCalledOnce() }) - it('says nothing and pastes nothing when the user closed the tab, or its pane explains', async () => { - for (const outcome of [{ kind: 'closed-by-user' }, { kind: 'pane-says' }] as const) { - deferredOutcome()(outcome) - await expect(launch().promptDeliveryResult).resolves.toEqual({ - delivered: false, - failureNotified: true - }) - await expect(sendGate()).resolves.toBe(false) - } + 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() }) }) 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 47a7f2675e2f..1ddb83b6f92d 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 @@ -86,7 +86,7 @@ export function launchNewTabPromptThroughHost( if (launched.kind === 'started') { return pasted } - // The pane, or this notice for a tab that went, already says why: never a second notice. + // This notice says why, once; a tab the user closed says nothing. showLaunchNotStartedNotice(launched, args.prompt) return { delivered: false, failureNotified: true } })