AI buttons stop writing a launch record (#28209)

* fix(agent-launch): an AI button's launch writes no launch record, so it starts even when the record store cannot open

* fix(agent-launch): a failed AI-button tab the window closes is not a user close, and E's leftover prompt store write, hold release and stale comment go

* test(agent-launch): the launch funnel's store double keeps a tab made with its own id, as the real store does, so an AI-button launch is not read as closed by the user

* docs(agent-launch): a failed AI-button tab's cleanup close still records the tab, so an agent that starts late is stopped, not shown
This commit is contained in:
Brennan Benson
2026-10-13 21:34:57 -07:00
committed by GitHub
parent e3258a484b
commit de9caf1366
10 changed files with 103 additions and 109 deletions
@@ -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 })
@@ -51,8 +51,10 @@ 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(() => []),
@@ -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<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
}
}
@@ -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<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]
}
@@ -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 }
@@ -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
@@ -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({
@@ -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>
@@ -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()
@@ -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 () => {
const reply = deferred<unknown>()
callRuntimeRpc.mockReturnValue(reply.promise)
const { tabId, outcome } = launch()
reply.reject(rpcError('agent_session_operation_conflict'))
await expect(outcome).resolves.toEqual({
kind: 'not-started',
unconfirmed: false,
code: 'agent_session_operation_conflict'
})
expect(launchTab(tabId)).toBeUndefined()
})
it('leaves a launch the host took to its pane, which says how it ended', async () => {
// 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_unknown'))
reply.reject(rpcError('terminal_reveal_failed'))
await expect(outcome).resolves.toEqual({ kind: 'pane-says' })
await expect(outcome).resolves.toEqual({
kind: 'not-started',
unconfirmed: true,
code: 'terminal_reveal_failed'
})
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)
@@ -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' })
})
})
@@ -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<string, SessionOptionValue> | undefined): {
@@ -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)
}
}
}
@@ -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, {
@@ -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)
@@ -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 })
@@ -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()
})
})
@@ -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 }
})