feat(agent-launch): report the pane a terminal launch created

A `term_*` handle is a main-side mapping the renderer cannot resolve
(terminal-handle-links.ts:309), so a client that draws its own tabs had no
way to name the tab it had just asked `agent.launch` to build. The runtime
already mints that pane, bakes it into the PTY's environment and hands it
to its own reveal; the surface factory then dropped it on the floor.

Carry it through as `paneKey` on the terminal outcome. Identity, not
placement: where the pane goes — which group, what order, whether it takes
focus — stays with whichever client is drawing, and nothing here rides the
wire for it. One field rather than a tabId/leafId pair, because the key
already holds both and two copies of one fact can disagree.

Absent when this launch minted no pane: a reused terminal was already
running, and a worktree-create startup terminal is built by the create,
which reports only a handle. Naming the wrong surface is worse than naming
none.

Optional on the wire and optional on the read side. Mobile parses the
receipt with a loose object and is deliberately mode-blind, so it ignores
the field; the persisted-row guard checks it when present and accepts a row
written before it existed, because a read rule stricter than the write side
turns one odd row into a refused replay.
This commit is contained in:
Brennan Benson
2026-09-21 16:35:33 -07:00
parent 0bbaadafa4
commit e6989dbe73
5 changed files with 199 additions and 5 deletions
@@ -77,7 +77,9 @@ export type AgentLaunchSurfaceFactory = {
cwd?: string
/** The one member of the `agent_started` triple the host cannot derive for itself. */
launchSource?: string
}): Promise<{ handle: string; warning?: string }>
/** `paneKey` names the pane this create minted, for a caller that presents its own tabs; a
* factory whose runtime does not report one omits it rather than inventing a key. */
}): Promise<{ handle: string; paneKey?: string; warning?: string }>
/**
* Commits the launch text as the session's first turn, answering with the transcript row's id.
*
@@ -380,7 +382,11 @@ async function createTerminalSurface(
...(intent.launchSource ? { launchSource: intent.launchSource } : {})
})
return {
outcome: { kind: 'terminal', handle: terminal.handle },
outcome: {
kind: 'terminal',
handle: terminal.handle,
...(terminal.paneKey ? { paneKey: terminal.paneKey } : {})
},
...(terminal.warning ? { warning: terminal.warning } : {}),
...(startupPrompt ? { promptRodeLaunchCommand: true } : {})
}
@@ -111,6 +111,9 @@ export function agentLaunchSurfaceFactory(
})
return {
handle: terminal.handle,
// The runtime already minted this pane and baked it into the PTY's env and its own reveal;
// dropping it here was what left a client with no way to name the tab it just asked for.
...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}),
...(terminal.warning ? { warning: terminal.warning } : {})
}
},
@@ -0,0 +1,154 @@
/**
* Whether a terminal launch names the pane it created.
*
* A `term_*` handle is a main-side mapping — terminal-handle-links.ts:309 says the renderer cannot
* resolve one — so a client that draws its own tabs could not tell which tab `agent.launch` had
* just asked the host to build. The runtime already mints that pane and reports it as `paneKey`;
* the surface factory used to discard it, which is the one identity channel this method declined.
*
* Identity travels; placement does not. Nothing here asks for a group, an order or a focus — those
* stay with whichever client is drawing, and mobile, which has no tabs, reads none of this.
*/
import { describe, expect, it, vi } from 'vitest'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { isAgentLaunchResult } from '../../../../shared/agent-launch-intent'
import type { RpcContext } from '../core'
import {
CAPABLE_CLIENT,
methodNamed,
rpcContext,
runtimeStub,
type AgentLaunchRuntimeStub as RuntimeStub
} from './agent-launch.test-fixture'
vi.mock('./structured-agent-session-create', () => ({
createStructuredAgentSessionForWorktree: async () => ({
ok: true,
value: { sessionId: 'sess-1' }
})
}))
const { AGENT_LAUNCH_METHODS } = await import('./agent-launch')
const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch')
/** Shaped like a pane the runtime really mints: `randomUUID()` for the tab and for the leaf. */
const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
/** Settings with no structured preference, so every launch here settles as a terminal. */
const TERMINAL_ONLY = {}
async function launch(params: unknown, runtime: RuntimeStub, context: Partial<RpcContext> = {}) {
const parsed = AGENT_LAUNCH.params.safeParse(params)
if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? 'invalid')
}
return AGENT_LAUNCH.handler(parsed.data, rpcContext(runtime, { ...CAPABLE_CLIENT, ...context }))
}
const EXISTING_LAUNCH = {
agent: 'claude',
target: { kind: 'existing', worktree: 'id:wt-7' }
}
describe('the pane a terminal launch created', () => {
it('reports the pane key the runtime minted', async () => {
const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneKey: PANE_KEY })
const result = await launch(EXISTING_LAUNCH, runtime)
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1', paneKey: PANE_KEY })
})
it('reports a pane key a client can resolve to a tab and a leaf', async () => {
const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneKey: PANE_KEY })
const result = await launch(EXISTING_LAUNCH, runtime)
// The point of carrying identity at all: the client gets the two ids `store.createTab` needs.
const pane =
result.outcome.kind === 'terminal' ? parsePaneKey(result.outcome.paneKey ?? '') : null
expect(pane).toMatchObject({ tabId: TAB_ID, leafId: LEAF_ID })
})
it('carries the pane key through a downgrade to a terminal', async () => {
// The downgrade builds its terminal through the same factory, so it must not lose identity
// the outright-terminal path keeps.
const runtime = runtimeStub({
createSupport: { supported: false, reason: 'wsl' },
terminalPaneKey: PANE_KEY
})
const result = await launch(EXISTING_LAUNCH, runtime)
expect(result.receipt).toMatchObject({ mode: 'terminal', reason: 'wsl_execution_runtime' })
expect(result.outcome).toMatchObject({ kind: 'terminal', paneKey: PANE_KEY })
})
it('omits the pane key when the runtime reported none', async () => {
const runtime = runtimeStub({ settings: TERMINAL_ONLY })
const result = await launch(EXISTING_LAUNCH, runtime)
// Absent, not empty: a client must be able to tell "no pane to adopt" from "a pane called ''".
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
})
it('omits the pane key for a reused terminal, which this launch did not create', async () => {
const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneKey: PANE_KEY })
const result = await launch(
{ ...EXISTING_LAUNCH, reuseTerminal: { handle: 'term_live' } },
runtime
)
expect(runtime.createTerminal).not.toHaveBeenCalled()
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_live' })
})
it('omits the pane key for a worktree-create startup terminal', async () => {
// Agent-first creation builds the agent inside the create, which reports only a handle.
// Reporting the pane of a terminal this launch never created would name the wrong surface.
const runtime = runtimeStub({ settings: TERMINAL_ONLY, terminalPaneKey: PANE_KEY })
const result = await launch(
{
agent: 'claude',
target: { kind: 'create-worktree', create: { repo: 'id:repo-1', name: 'task' } }
},
runtime
)
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_agent_first' })
})
})
describe('reading a recorded launch back', () => {
// The replay resolver narrows a stored row with this guard, so a field it does not check is a
// field a replay hands back unvalidated — and one it over-checks is a refused replay.
const BASE = {
worktreeId: 'wt-7',
receipt: { mode: 'terminal', preferred: 'terminal', reason: 'user_default', detail: 'ok' }
}
it('accepts a row carrying a pane key', () => {
expect(
isAgentLaunchResult({
...BASE,
outcome: { kind: 'terminal', handle: 't', paneKey: PANE_KEY }
})
).toBe(true)
})
it('accepts a row written before the field existed', () => {
expect(isAgentLaunchResult({ ...BASE, outcome: { kind: 'terminal', handle: 't' } })).toBe(true)
})
it('refuses a row whose pane key is not a string', () => {
expect(
isAgentLaunchResult({ ...BASE, outcome: { kind: 'terminal', handle: 't', paneKey: 7 } })
).toBe(false)
})
})
@@ -27,6 +27,9 @@ export type AgentLaunchRuntimeStubOptions = {
createWarning?: string
/** What `createTerminal` reports when the surface itself came up degraded. */
terminalWarning?: string
/** The pane `createTerminal` minted. Off by default so the existing outcome assertions keep
* modelling a runtime that reports none — the arm `RuntimeTerminalCreate.paneKey?` allows. */
terminalPaneKey?: string
}
export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) {
@@ -67,6 +70,7 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) {
// Args are declared so a test can assert what the launch asked for, not merely that it asked.
createTerminal: vi.fn(async (_selector: string, _options?: Record<string, unknown>) => ({
handle: 'term_1',
...(options.terminalPaneKey ? { paneKey: options.terminalPaneKey } : {}),
...(options.terminalWarning ? { warning: options.terminalWarning } : {})
})),
showTerminal: vi.fn(async (handle: string) => ({ handle, worktreeId: 'wt-7' })),
+30 -3
View File
@@ -88,7 +88,26 @@ export type AgentLaunchIntent = {
/** The surface the host actually created. */
export type AgentLaunchOutcome =
| { kind: 'structured'; sessionId: string; handle: string }
| { kind: 'terminal'; handle: string }
| {
kind: 'terminal'
handle: string
/**
* The pane the host minted for this agent, as `tabId:leafId` — read it with `parsePaneKey`.
*
* Identity, not placement. The host already mints this pair, bakes it into the PTY's
* environment and hands it to its own reveal; a client that draws its own tabs previously had
* no way to learn it, because a `term_*` handle is a main-side mapping the renderer cannot
* resolve. Where that pane goes — which group, what order, whether it takes focus — stays
* with the client and never rides this wire.
*
* One field rather than a `tabId`/`leafId` pair, because the key already carries both and two
* copies of one fact can disagree.
*
* Absent when this launch minted no pane: a reused terminal was already running, and a
* worktree-create startup terminal is built by the create, which reports only a handle.
*/
paneKey?: string
}
/**
* What became of the launch text.
*
@@ -210,12 +229,20 @@ function isAgentLaunchOutcome(value: unknown): value is AgentLaunchOutcome {
return false
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion claims only that the keys may be present and unknown, which is true of any object.
const outcome = value as { kind?: unknown; handle?: unknown; sessionId?: unknown }
const outcome = value as {
kind?: unknown
handle?: unknown
sessionId?: unknown
paneKey?: unknown
}
if (typeof outcome.handle !== 'string' || outcome.handle.length === 0) {
return false
}
return outcome.kind === 'terminal'
? true
? // Checked when present, ignored when absent: a row written before this field existed, or by a
// runtime that minted no pane, still reads. Deliberately not parsed — a read-side shape rule
// stricter than the write side turns one odd row into a refused replay.
outcome.paneKey === undefined || typeof outcome.paneKey === 'string'
: outcome.kind === 'structured' &&
typeof outcome.sessionId === 'string' &&
outcome.sessionId.length > 0