fix(agent-launch): readiness findings: the phone's closed-tab wording, a closed pane's outcome, a new worktree's view

- The phone said "closed on the computer before it started" as an error
  even when the phone closed the tab or the agent had started. A new
  tab's launch now ends silently when its tab is closed; the PR AI button
  says "The agent's tab was closed, so the agent was stopped."
- A verdict for a launch pane the user already closed out of a split is
  no longer stored on the tab.
- A launch that creates a local worktree and falls back to a terminal
  now opens its agent in the view a local workspace allows: the worktree
  factory reports the new workspace's connection.
- A reveal-race kill of an already-retired PTY no longer reports an
  unhandled rejection.
This commit is contained in:
Brennan Benson
2026-10-06 07:42:04 -07:00
parent 9473e9e065
commit 8f62858e60
14 changed files with 86 additions and 16 deletions
@@ -250,10 +250,10 @@ describe('launchAgentInExistingWorkspace', () => {
})
})
it('says the launch was stopped when its tab was closed on the computer, in its own words', async () => {
it('reads a closed tab as the user stopping the launch, not as the launch failing', async () => {
const { client } = scriptedClient(refused('agent_launch_tab_closed'))
await expect(launch(client)).resolves.toEqual({
kind: 'failed',
kind: 'tab-closed',
message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE
})
})
@@ -44,8 +44,9 @@ export const AGENT_LAUNCH_UNCONFIRMED_MESSAGE =
// The host started nothing, and the next tap reserves new ids.
export const AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE = "Couldn't start the agent. Try again."
// Who closed it, and whether the agent had started, are unknown here: the phone or the computer.
export const AGENT_LAUNCH_TAB_CLOSED_MESSAGE =
"The agent's tab was closed on the computer before it started, so it was stopped."
"The agent's tab was closed, so the agent was stopped."
/**
* The tab a launch will create, named by this device before it asks, so it can land there as soon
@@ -82,6 +83,8 @@ export type MobileExistingAgentLaunch =
| { kind: 'failed'; message: string }
/** The agent may or may not be running; the caller must not launch again on its own. */
| { kind: 'unknown'; message: string }
/** A user closed the launch's tab, which stopped it; not a failure of the launch. */
| { kind: 'tab-closed'; message: string }
export function supportsMobileExistingAgentLaunch(
hostCapabilities: readonly string[] | null | undefined
@@ -169,7 +172,7 @@ function classifyLaunchRefusal(
return { kind: 'failed', message: AGENT_LAUNCH_RESERVATION_TAKEN_MESSAGE }
}
if (error.code === AGENT_LAUNCH_TAB_CLOSED_CODE) {
return { kind: 'failed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE }
return { kind: 'tab-closed', message: AGENT_LAUNCH_TAB_CLOSED_MESSAGE }
}
const message = error.message?.trim()
return { kind: 'failed', message: message || "Couldn't start the agent." }
@@ -99,6 +99,11 @@ export async function launchNewTabAgentThroughHost(args: {
args.reportCreateFailure(launched.message)
return true
}
if (launched.kind === 'tab-closed') {
// Why silent: the tab this "+" opened is gone, which is the answer; the close was a user's own.
pendingSelectionRef.current = withoutUnansweredLaunch(pendingSelectionRef.current, args.lock)
return true
}
if (launched.kind === 'unknown') {
// Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent.
if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) {
@@ -85,6 +85,7 @@ export async function launchAgentWithPrompt(args: {
case 'unsupported':
return { kind: 'not-started', message: AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE }
case 'failed':
case 'tab-closed':
return { kind: 'not-started', message: launched.message }
case 'unknown':
return { kind: 'unconfirmed', message: launched.message }
@@ -26,6 +26,7 @@ import { releaseTerminalCreateLock } from './terminal-create-lock'
import { useMobileSessionTerminalCreateActions } from './use-mobile-session-terminal-create-actions'
vi.mock('../platform/haptics', () => ({ triggerSuccess: vi.fn(), triggerError: vi.fn() }))
const { triggerError } = await import('../platform/haptics')
const LAUNCH_CAPABILITIES = [
'agent.launch.v2',
@@ -357,6 +358,19 @@ describe('the + menu', () => {
expect(methods(sendRequest)).toEqual(['agent.launchReplay', 'session.tabs.createTerminal'])
})
it('says nothing when a user closed the tab the launch opened: the tab going is the answer', async () => {
const { client } = scriptedClient(refusal('agent_launch_tab_closed'))
const state = scope(client)
vi.mocked(triggerError).mockClear()
await create_(state, 'claude')
expect(state.showToast).not.toHaveBeenCalled()
expect(state.setCreateError).not.toHaveBeenCalledWith(expect.stringMatching(/./))
expect(triggerError).not.toHaveBeenCalled()
expect(state.pendingSelectionRef.current).toBeNull()
})
it("shows the host's refusal even when the session already has tabs", async () => {
const { client, sendRequest } = scriptedClient({
id: 'x',
@@ -27,7 +27,7 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie
AGENT_LAUNCH_RUNTIME_CAPABILITY,
// Reads a listed launch tab with no terminal yet as not started, so the host may show it early.
AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY,
// Reads `agent_launch_tab_closed` (its tab was closed on the computer) as a definite answer.
// Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer.
AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY
])
@@ -43,6 +43,7 @@ function harness(options: {
calls.push(`createWorktree(startupAgent=${String(args.startupAgent)})`)
return {
worktreeId: 'wt-new',
connectionId: null,
startupTerminalHandle: args.startupAgent ? 'term_agent_first' : undefined,
...carried(args.startupPrompt)
}
@@ -691,3 +692,22 @@ describe('the surface is published as the launch stands, before its prompt is de
expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'journaled', messageId: 'msg-1' })
})
})
describe('a new local worktree whose startup terminal did not come up', () => {
it('opens its agent in the view a local workspace allows, as an existing one would', async () => {
const h = harness({
settings: { experimentalNativeChat: true, openAgentTabsInChatByDefault: true }
})
h.createWorktree.mockImplementationOnce(async () => ({
worktreeId: 'wt-new',
connectionId: null,
startupTerminalHandle: undefined
}))
await h.run({ ...CREATE_INTENT, agent: 'opencode' })
expect(h.createTerminalAgent).toHaveBeenCalledWith(
expect.objectContaining({ viewMode: 'chat' })
)
})
})
+11 -9
View File
@@ -167,7 +167,7 @@ export async function executeAgentLaunch(
execution.onStage?.('surface_create')
let created: CreatedSurface
try {
created = await createSurface(execution, placed.worktreeId, settled)
created = await createSurface(execution, placed, settled)
} catch (error) {
// The structured create path distinguishes a definitive pre-commit refusal from an unknown
// outcome. Only the former is safe to replace with a terminal in the same workspace; retrying
@@ -180,7 +180,7 @@ export async function executeAgentLaunch(
throw error
}
settled = downgradeAgentLaunchModeForStructuredRefusal(settled, vocabulary)
created = await createTerminalSurface(execution, placed.worktreeId)
created = await createTerminalSurface(execution, placed)
}
// Both CAN be set, so neither may be dropped. The create warns precisely when it produced no
// startup terminal — `didSpawnStartup` stays false when that spawn throws — and that is the same
@@ -231,6 +231,7 @@ async function resolveWorkspace(
preflight: AgentLaunchModeReceipt
): Promise<{
worktreeId: string
connectionId: string | null | undefined
startupTerminalHandle: string | undefined
startupTerminalPaneKey?: string
warning?: string
@@ -240,7 +241,8 @@ async function resolveWorkspace(
const { intent } = execution
if (intent.target.kind === 'existing') {
// Nothing was created, so there is no create warning to carry.
return { worktreeId: intent.target.worktree, startupTerminalHandle: undefined }
const { worktree: worktreeId, connectionId } = intent.target
return { worktreeId, connectionId, startupTerminalHandle: undefined }
}
const workspaces = execution.workspaces
if (!workspaces) {
@@ -277,7 +279,7 @@ export type CreatedSurface = {
async function createSurface(
execution: AgentLaunchExecution,
worktreeId: string,
workspace: { worktreeId: string; connectionId: string | null | undefined },
settled: AgentLaunchModeReceipt
): Promise<CreatedSurface> {
const { intent, surfaces } = execution
@@ -285,7 +287,7 @@ async function createSurface(
// One reservation serves either route: the tab half of the reserved pane is the chat's tab.
const reservedTabId = intent.paneKey ? parsePaneKey(intent.paneKey)?.tabId : undefined
const session = await surfaces.createStructuredSession({
worktreeId,
worktreeId: workspace.worktreeId,
agent: intent.agent,
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
@@ -302,7 +304,7 @@ async function createSurface(
...ignoredStructuredAgentArgsWarning(intent)
}
}
return createTerminalSurface(execution, worktreeId)
return createTerminalSurface(execution, workspace)
}
/**
@@ -347,12 +349,12 @@ function terminalLaunchInputs(intent: AgentLaunchIntent) {
*/
async function createTerminalSurface(
execution: AgentLaunchExecution,
worktreeId: string
workspace: { worktreeId: string; connectionId: string | null | undefined }
): Promise<CreatedSurface> {
const { intent, surfaces } = execution
const startupPrompt = argvLaunchPrompt(intent)
const terminal = await surfaces.createTerminalAgent({
worktreeId,
worktreeId: workspace.worktreeId,
agent: intent.agent,
...(startupPrompt ? { startupPrompt } : {}),
...terminalLaunchInputs(intent),
@@ -360,7 +362,7 @@ async function createTerminalSurface(
settings: readAgentLaunchModeSettings(execution.runtime),
agent: intent.agent,
...(intent.prompt ? { prompt: intent.prompt } : {}),
connectionId: intent.target.kind === 'existing' ? intent.target.connectionId : undefined
connectionId: workspace.connectionId
})
})
return {
@@ -13,6 +13,7 @@ function harness() {
const deliverPrompt = vi.fn(async () => true)
const createWorktree = vi.fn(async () => ({
worktreeId: 'wt_new',
connectionId: null,
startupTerminalHandle: 'term_new'
}))
return {
@@ -119,6 +119,8 @@ export type AgentLaunchWorkspaceFactory = {
options?: Readonly<Record<string, unknown>>
}): Promise<{
worktreeId: string
/** The new workspace's SSH connection; `null` is local. Decides what its agent tab can show. */
connectionId: string | null
startupTerminalHandle: string | undefined
/** The pane minted with the startup terminal, when the runtime reported one. */
startupTerminalPaneKey?: string
@@ -107,6 +107,7 @@ export function agentLaunchWorkspaceFactory(
finishAutomationWorkspaceProvenanceRequest(params.automationProvenanceRequest)
return {
worktreeId: result.worktree.id,
connectionId: repo.connectionId ?? null,
startupTerminalHandle: result.startupTerminal?.handle,
...(promptRodeLaunchCommand ? { promptRodeLaunchCommand } : {}),
...(result.startupTerminal?.paneKey
@@ -62,7 +62,8 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v
if (ptyId && tabId && leafId && wasAgentLaunchPaneClosedByUser(tabId, leafId)) {
// The user closed the launch's tab or pane while it waited. That close wins: it stays
// closed, and its agent stops, as closing any tab stops what runs in it.
void window.api.pty.kill(ptyId)
// The host stops the same agent; a second kill of a retired PTY may reject, harmlessly.
window.api.pty.kill(ptyId).catch(() => {})
throw new Error('agent_launch_tab_closed')
}
// Why: a split pane revealed from mobile is only bound in the persisted
@@ -77,6 +77,17 @@ describe("a launch pane's verdict in the window", () => {
expect(agentLaunchPanePrompt(tabId)).toBe('fix the build')
})
it('final, for a launch pane the user already closed out of a split: nothing is kept', () => {
store.getState().setTabLayout(tabId, {
root: { type: 'leaf', leafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' },
activeLeafId: '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d',
expandedLeafId: null
})
apply({ kind: 'not-started', code: 'agent_launch_tab_closed' })
expect(launchTab()?.agentLaunchPane).toBeUndefined()
expect(agentLaunchPanePrompt(tabId)).toBeNull()
})
it('withdrawn: a tab with only the launch pane goes', () => {
apply({ kind: 'withdrawn' })
expect(launchTab()).toBeUndefined()
@@ -5,6 +5,7 @@
import type { AgentLaunchPaneVerdictEvent } from '../../../shared/agent-launch-pane-verdict'
import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed-terminal-leaf-notice'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids'
import { useAppStore } from '@/store'
import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt'
@@ -23,10 +24,18 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent):
state.setTabAgentLaunchPane(tabId, undefined)
return
case 'not-started':
case 'unconfirmed':
case 'unconfirmed': {
const root = state.terminalLayoutsByTabId[tabId]?.root
if (root && !collectLeafIdsInOrder(root).includes(leafId)) {
// The user closed that pane: nothing is left for the outcome to describe.
forgetAgentLaunchPanePrompt(tabId)
state.setTabAgentLaunchPane(tabId, undefined)
return
}
// Final for this pane, for the tab's life: no later spawn needs the launch record.
state.setTabAgentLaunchPane(tabId, { ...tab.agentLaunchPane, outcome: verdict })
return
}
case 'withdrawn': {
forgetAgentLaunchPanePrompt(tabId)
// The pane alone when the user split the tab while it waited; their split stays.