Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
30 changes: 24 additions & 6 deletions src/renderer/src/lib/agent-launch-funnel-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,42 @@ export function createLaunchFunnelStore(): {
seedNativeChatLaunchPrompt: ReturnType<typeof vi.fn>
seedNativeChatLaunchDraft: ReturnType<typeof vi.fn>
markNativeChatLaunchPromptFailed: ReturnType<typeof vi.fn>
setTabAgentLaunchPane: ReturnType<typeof vi.fn>
closeTab: ReturnType<typeof vi.fn>
} {
return {
const store = {
settings: launchFunnelSettings(),
repos: [],
allWorktrees: vi.fn(() => []),
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
openFiles: [],
browserTabsByWorktree: {},
tabBarOrderByWorktree: {},
createTab: vi.fn(() => ({ id: 'tab-1' })),
createTab: vi.fn(),
queueTabInitialCwd: vi.fn(),
queueTabStartupCommand: vi.fn(),
setActiveTabType: vi.fn(),
setTabBarOrder: vi.fn(),
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<string, { id: string }[]> }) {
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
}
}

Expand All @@ -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. */
Expand All @@ -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<typeof vi.fn>
): Record<string, unknown> | undefined {
return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launchReplay')?.[2]
return callRuntimeRpc.mock.calls.find(([, method]) => method === 'agent.launch')?.[2]
}
5 changes: 2 additions & 3 deletions src/renderer/src/lib/agent-launch-pane-spawn-hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>; release: () => void }
Expand Down
21 changes: 0 additions & 21 deletions src/renderer/src/lib/agent-launch-tab-publication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions src/renderer/src/lib/agent-launch-tab-publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
46 changes: 21 additions & 25 deletions src/renderer/src/lib/agent-launch-through-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createTestStore>
Expand Down Expand Up @@ -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<unknown>()
callRuntimeRpc.mockReturnValue(reply.promise)
const { tabId, outcome } = launch()
Expand All @@ -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<unknown>()
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<unknown>()
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<unknown>()
callRuntimeRpc.mockReturnValue(reply.promise)
Expand All @@ -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<unknown>()
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' })
})
})
68 changes: 31 additions & 37 deletions src/renderer/src/lib/agent-launch-through-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,24 @@
* 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
} from '@/lib/agent-launch-pane-spawn-hold'
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'
Expand Down Expand Up @@ -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<string, SessionOptionValue> | undefined): {
Expand Down Expand Up @@ -113,27 +107,29 @@ async function settleLaunch(
send: Promise<unknown>,
releaseHold: () => void
): Promise<HostAgentLaunchOutcome> {
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)
}
}
}

Expand All @@ -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<unknown>({ kind: 'local' }, 'agent.launchReplay', {
const send = callRuntimeRpc<unknown>({ 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, {
Expand All @@ -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)
Expand Down
Loading
Loading