feat(agent-launch): carry the launch inputs the host cannot derive (#22037)

* feat(agent-launch): carry the launch inputs the host cannot derive

Desktop's launch call sites cannot move onto `agent.launch` while the wire
drops inputs they depend on. This adds the three the host genuinely cannot
work out for itself, and deliberately adds nothing the host can.

- `agentArgs` — the host read only `settings.agentDefaultArgs`, so a saved
  launch recipe's arguments had no way across. Tri-state is preserved: `null`
  is "no arguments", absent is "use the settings default".
- `cwd` — `TerminalCreateOptions.cwd` already reached the spawn, but nothing
  on the wire filled it. It also decides the route: only a terminal can start
  somewhere other than its workspace, so the host now feeds it to
  `requiresTuiLaunchCommand` and downgrades with `tui_launch_command` rather
  than running a structured session in the wrong directory.
- `launchSource` — telemetry, and the only member of the `agent_started`
  triple the host cannot derive; `agent_kind` and `request_kind` are computed
  host-side. Typed `z.string()`, not the closed enum: params are validated by
  the HOST, so a closed arm set would let an older host refuse a newer
  client's launch over a label. Attribution must not gate a user action.

Not added, because the host already derives them: `launchPlatform`
(`getAgentLaunchPlatformForWorkspace`, from the same connectionId/path/
projectRuntime the renderer uses) and `startupCommandDelivery` (a pure
function of the agent inside `buildAgentStartupPlan`).

Fingerprint: `agentArgs` and `cwd` are in — they change what the call does, so
a retry carrying different ones must conflict rather than replay.
`launchSource` is out — two buttons producing the same launch are one
operation, and folding it in would refuse an honest re-attributed retry. A
caller sending none of the new fields digests exactly as before, because the
canonicalizer drops undefined keys, so launches admitted by an older build
still replay across the upgrade.

Arguments reaching a structured route are ignored by an existing deliberate
decision (the Agent SDK and app-server version their option sets separately
from the interactive CLI), so the host reports it in `warning` instead of
overriding the user's preference on the strength of a field that is not
evidence about the surface.

* fix(agent-launch): forward create-target launch inputs
This commit is contained in:
Brennan Benson
2026-09-21 13:58:16 -07:00
committed by GitHub
parent d40aac0a58
commit d60043787b
24 changed files with 657 additions and 17 deletions
@@ -439,3 +439,95 @@ describe('a launch into an existing workspace, by workspace kind', () => {
expect(result.receipt).toMatchObject({ mode: 'structured' })
})
})
/**
* The launch inputs the host cannot derive for itself.
*
* The pair is deliberately asymmetric and the asymmetry is the contract: a requested `cwd` is
* something only a terminal can apply, so it decides the route; launch arguments are a TUI concern
* the structured providers version independently, so they do NOT decide the route and a structured
* launch that received some has to admit it ignored them.
*/
describe('caller-supplied launch inputs', () => {
const EXISTING = { kind: 'existing' as const, worktree: 'wt-7' }
it('downgrades a structured preference to a terminal when the launch names a cwd', async () => {
const h = harness({})
const result = await h.run({ agent: 'claude', target: EXISTING, cwd: '/repo/packages/api' })
// A structured session runs in its workspace, so honouring the cwd and honouring the
// preference are mutually exclusive; the receipt has to say which one lost.
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
expect(result.receipt).toMatchObject({
mode: 'terminal',
preferred: 'structured',
reason: 'tui_launch_command'
})
expect(h.createStructuredSession).not.toHaveBeenCalled()
})
it('still opens a structured session when the cwd is only whitespace', async () => {
const h = harness({})
const result = await h.run({ agent: 'claude', target: EXISTING, cwd: ' ' })
expect(result.outcome.kind).toBe('structured')
})
it('hands cwd, agentArgs and launchSource to the terminal it creates', async () => {
const h = harness({ settings: null })
await h.run({
agent: 'claude',
target: EXISTING,
cwd: '/repo/packages/api',
agentArgs: '--model opus',
launchSource: 'source_control_recovery'
})
expect(h.createTerminalAgent.mock.calls[0]?.[0]).toMatchObject({
cwd: '/repo/packages/api',
agentArgs: '--model opus',
launchSource: 'source_control_recovery'
})
})
it('forwards an explicit "no arguments" rather than dropping it as falsy', async () => {
const h = harness({ settings: null })
await h.run({ agent: 'claude', target: EXISTING, agentArgs: null })
// `null` means the caller wants none; dropping it here would silently restore the user's
// configured default, which is the opposite of what was asked.
expect(h.createTerminalAgent.mock.calls[0]?.[0]).toHaveProperty('agentArgs', null)
})
it('omits agentArgs entirely when the caller sent none, so the settings default still applies', async () => {
const h = harness({ settings: null })
await h.run({ agent: 'claude', target: EXISTING })
expect(h.createTerminalAgent.mock.calls[0]?.[0]).not.toHaveProperty('agentArgs')
})
it('warns that a structured session ignored the launch arguments, without changing the route', async () => {
const h = harness({})
const result = await h.run({ agent: 'claude', target: EXISTING, agentArgs: '--model opus' })
expect(result.outcome.kind).toBe('structured')
expect(result.warning).toContain('does not apply launch arguments')
})
it('warns when a structured session ignored an explicit "no arguments" too', async () => {
const h = harness({})
const result = await h.run({ agent: 'claude', target: EXISTING, agentArgs: null })
// The structured path reads the bypass-permissions bit from the user's SETTINGS default, so a
// caller that asked for no arguments can still get a session with more permission than it asked
// for. Staying silent about that is the failure mode worth a test.
expect(result.warning).toContain('does not apply launch arguments')
})
it('leaves a structured launch unwarned when it carried no arguments at all', async () => {
const h = harness({})
const result = await h.run({ agent: 'claude', target: EXISTING })
expect(result.warning).toBeUndefined()
})
})
+49 -4
View File
@@ -72,6 +72,11 @@ export type AgentLaunchSurfaceFactory = {
/** Set only for an agent whose CLI takes the prompt on argv, so the text is in the process's
* arguments at exec time rather than raced into its composer afterwards. */
startupPrompt?: string
/** Replaces the settings default for this launch only; `null` means no arguments at all. */
agentArgs?: string | null
cwd?: string
/** The one member of the `agent_started` triple the host cannot derive for itself. */
launchSource?: string
}): Promise<{ handle: string; warning?: string }>
/**
* Commits the launch text as the session's first turn, answering with the transcript row's id.
@@ -128,6 +133,10 @@ export type AgentLaunchWorkspaceFactory = {
/** Set only alongside a `startupAgent` whose CLI takes the prompt on argv: agent-first creation
* builds the startup command, so that is where an argv prompt belongs. */
startupPrompt?: string
/** Inputs needed when this terminal is created as the worktree's startup surface. */
agentArgs?: string | null
cwd?: string
launchSource?: string
}): Promise<{
worktreeId: string
startupTerminalHandle: string | undefined
@@ -156,7 +165,8 @@ export async function executeAgentLaunch(
placement: {
agent: intent.agent,
workspaceKind: launchWorkspaceKind(intent.target),
...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {})
...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {})
},
settings,
vocabulary
@@ -279,7 +289,14 @@ async function resolveWorkspace(
// owns the prompt for the same reason, so it re-supplies its own rather than honouring theirs.
create: withoutReservedAgentCreateFields(intent.target.create),
startupAgent: preflight.mode === 'structured' ? undefined : intent.agent,
...(startupPrompt ? { startupPrompt } : {})
...(startupPrompt ? { startupPrompt } : {}),
...(preflight.mode === 'structured'
? {}
: {
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.launchSource ? { launchSource: intent.launchSource } : {})
})
})
// Only when a startup terminal actually came back: a create that produced none ran no command,
// so nothing carried the prompt and the launch still owes it to whatever surface it builds next.
@@ -312,12 +329,36 @@ async function createSurface(
})
return {
outcome: { kind: 'structured', sessionId: session.sessionId, handle: session.handle },
structured: session
structured: session,
...ignoredStructuredAgentArgsWarning(intent)
}
}
return createTerminalSurface(execution, worktreeId)
}
/**
* A structured session cannot apply launch arguments, so a launch that carried some and got one
* anyway has to say so.
*
* Reported rather than routed around: the arguments field is a TUI concern by an explicit decision
* (`hasExplicitTuiLaunchCommand` reads the launch command and pointedly not the args, because the
* Agent SDK and app-server version their option sets independently of the interactive CLI), so
* downgrading here would override a stated user preference on the strength of a field that is not
* evidence about the surface. `null` warns too: "no arguments" is also unapplied, and the structured
* path still reads the bypass-permissions bit out of the user's *settings* default, so a caller that
* asked for none can get a session running with more permission than it requested.
*/
function ignoredStructuredAgentArgsWarning(
intent: AgentLaunchIntent
): { warning: string } | undefined {
return intent.agentArgs === undefined
? undefined
: {
warning:
'Started a structured chat session, which does not apply launch arguments; the requested arguments were ignored.'
}
}
/**
* The one place a terminal agent is created, so the structured-refusal downgrade builds the same
* surface — carrying the same argv prompt — as a launch that chose a terminal outright.
@@ -332,7 +373,11 @@ async function createTerminalSurface(
worktreeId,
agent: intent.agent,
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
...(startupPrompt ? { startupPrompt } : {})
...(startupPrompt ? { startupPrompt } : {}),
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.launchSource ? { launchSource: intent.launchSource } : {})
})
return {
outcome: { kind: 'terminal', handle: terminal.handle },
+8 -1
View File
@@ -72,6 +72,10 @@ export type AgentLaunchModePlacement = {
* resolved — never accepted from a caller, which would let one route around this decision.
* Absent means the kind was never established, and is not read as any particular kind. */
workspaceKind?: WorkspaceLaunchKind
/** A start directory other than the workspace root. It belongs here, unlike `model` or `effort`,
* because a structured session has no way to apply one — it runs in its workspace — so honouring
* it and honouring the chat preference are mutually exclusive rather than merely awkward. */
cwd?: string
}
const DOWNGRADE_DETAIL: Record<Exclude<AgentLaunchModeReason, 'user_default'>, string> = {
@@ -141,7 +145,10 @@ export function decideAgentLaunchMode(args: {
// the create-support probe reads the resolved workspace rather than guessing from a
// client-side project runtime.
...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}),
requiresTuiLaunchCommand: hasExplicitTuiLaunchCommand(settings, agent)
// Mirrors the renderer's own route input (`agent-launch-route-input.ts`), which has always
// treated a requested cwd as terminal-only; the host simply had no way to be told about one.
requiresTuiLaunchCommand:
Boolean(placement.cwd?.trim()) || hasExplicitTuiLaunchCommand(settings, agent)
})
if (!support.supported) {
return downgraded(BLOCKER_REASON[support.blocker], vocabulary)
@@ -139,7 +139,11 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
repo: Repo,
agent: TuiAgent,
prompt: string | undefined,
launchPreferences?: AgentLaunchPreferences
launchPreferences?: AgentLaunchPreferences,
launchInputs?: {
agentArgs?: string | null
launchSource?: string
}
): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } {
if (!this.store) {
throw new Error('runtime_unavailable')
@@ -149,6 +153,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
agent,
...(prompt !== undefined ? { prompt } : {}),
...(launchPreferences ? { launchPreferences } : {}),
...(launchInputs?.agentArgs !== undefined ? { agentArgs: launchInputs.agentArgs } : {}),
...(launchInputs?.launchSource ? { launchSource: launchInputs.launchSource } : {}),
settings: this.store.getSettings(),
getLaunchPlatform: () => this.getAgentLaunchPlatformForRepo(repo),
toSessionOptions: (preferences) => this.toAgentSessionOptions(preferences)
@@ -59,7 +59,11 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
repo,
args.startupAgent,
args.startupPrompt,
args.startupLaunchPreferences
args.startupLaunchPreferences,
{
...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}),
...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {})
}
)
: null
const draftStartup =
@@ -229,7 +229,12 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
agent,
prompt: opts.startupPrompt ?? '',
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
// A per-launch override wins over the Settings default; `null` is "no arguments", so this
// tests for absence rather than falsiness.
agentArgs:
opts.agentArgs !== undefined
? opts.agentArgs
: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
sessionOptions,
sessionOptionsOverrideAgentArgs: Boolean(sessionOptions),
@@ -521,6 +521,66 @@ describe('OrcaRuntimeService', () => {
expect(spawnCall?.command).toBe("cursor-agent --beta '--force'")
})
// Why: a saved launch recipe carries its own arguments, and the host previously read only the
// Settings default, so there was no way to express one over the wire.
it('prefers a per-call agentArgs over the agentDefaultArgs setting', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
disabledTuiAgents: [],
agentCmdOverrides: { cursor: 'cursor-agent --beta' },
agentDefaultArgs: { cursor: '--force' },
agentDefaultEnv: {}
})
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
startupAgent: 'cursor',
agentArgs: '--headless'
})
const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined
expect(spawnCall?.command).toBe("cursor-agent --beta '--headless'")
})
// Why: `null` is "no arguments"; treating it as absent would restore the Settings default and
// launch the agent with arguments the caller explicitly cleared.
it('launches with no arguments when a per-call agentArgs is null', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => ({
...store.getSettings(),
disabledTuiAgents: [],
agentCmdOverrides: { cursor: 'cursor-agent --beta' },
agentDefaultArgs: { cursor: '--force' },
agentDefaultEnv: {}
})
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
startupAgent: 'cursor',
agentArgs: null
})
const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined
expect(spawnCall?.command).toBe('cursor-agent --beta')
})
// Why: with no selector the launch is never resolved, so a dropped startupAgent
// would reach the renderer as a bare shell — the failure this option prevents.
it('rejects a startupAgent create with no workspace selector', async () => {
@@ -14,6 +14,10 @@
*/
import { randomUUID } from 'node:crypto'
import { tuiAgentToAgentKind } from '../../../../shared/agent-kind'
import { launchSourceSchema } from '../../../../shared/telemetry-property-schemas'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { TerminalCreateOptions } from '../../runtime-terminal-contracts'
import { narrowStructuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults'
import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation'
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
@@ -86,14 +90,24 @@ export function agentLaunchSurfaceFactory(
fence,
text: prompt.text
}),
createTerminalAgent: async ({ worktreeId, agent, startupPrompt }) => {
createTerminalAgent: async ({
worktreeId,
agent,
startupPrompt,
agentArgs,
cwd,
launchSource
}) => {
const terminal = await context.runtime.createTerminal(`id:${worktreeId}`, {
// The agent id is not a shell command — `cursor` is the desktop app, its CLI is
// `cursor-agent` — so the runtime builds the configured launcher.
startupAgent: agent,
// Folded into that launcher by the same startup plan a new agent tab is built from, so an
// argv agent's prompt is in its argv at exec time rather than typed in afterwards.
...(startupPrompt ? { startupPrompt } : {})
...(startupPrompt ? { startupPrompt } : {}),
...(agentArgs !== undefined ? { agentArgs } : {}),
...(cwd ? { cwd } : {}),
...agentLaunchTelemetry(agent, launchSource)
})
return {
handle: terminal.handle,
@@ -109,6 +123,34 @@ export function agentLaunchSurfaceFactory(
}
}
/**
* The `agent_started` triple, of which only `launch_source` came from the caller.
*
* `agent_kind` and `request_kind` are derived rather than accepted — the host already knows both,
* and a value it derives is a value a caller cannot misreport. `request_kind` is always `new`
* because a launch reusing a terminal returns before any surface is created.
*
* An unrecognized `launch_source` drops the telemetry and starts the agent anyway. The wire keeps
* the arm set open so an older host cannot refuse a newer client's launch over a label, which is
* only honoured if the refusal does not reappear here: attribution is bookkeeping, and bookkeeping
* must not gate the user's launch.
*/
function agentLaunchTelemetry(
agent: TuiAgent,
launchSource: string | undefined
): Pick<TerminalCreateOptions, 'telemetry'> {
const parsed = launchSourceSchema.safeParse(launchSource)
return parsed.success
? {
telemetry: {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: parsed.data,
request_kind: 'new'
}
}
: {}
}
function requireInstalledHost(): StructuredAgentSessionHost {
const host = getStructuredAgentSessionHost()
if (!host) {
@@ -35,7 +35,14 @@ export function agentLaunchWorkspaceFactory(
agent: TuiAgent
): AgentLaunchWorkspaceFactory {
return {
createWorktree: async ({ create, startupAgent, startupPrompt }) => {
createWorktree: async ({
create,
startupAgent,
startupPrompt,
agentArgs,
cwd,
launchSource
}) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`; the executor only removed the reserved agent fields, so the rest of the payload is the parsed shape.
const params = create as WorktreeCreateParams
const { runtime } = context
@@ -68,6 +75,9 @@ export function agentLaunchWorkspaceFactory(
},
context.clientKind ? { clientKind: context.clientKind } : {}
),
...(agentArgs !== undefined ? { startupAgentArgs: agentArgs } : {}),
...(cwd ? { startupCwd: cwd } : {}),
...(launchSource ? { startupLaunchSource: launchSource } : {}),
// The launch owns the agent whichever surface it settles on, so the workspace records
// it even when no startup terminal was created for it.
createdWithAgent: agent,
@@ -64,7 +64,8 @@ export function runtimeStub(options: AgentLaunchRuntimeStubOptions = {}) {
...(options.setupReceipt ? { setupReceipt: options.setupReceipt } : {}),
...(options.createWarning ? { warning: options.createWarning } : {})
})),
createTerminal: vi.fn(async () => ({
// 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.terminalWarning ? { warning: options.terminalWarning } : {})
})),
@@ -311,6 +311,33 @@ describe('the worktree factory', () => {
expect(runtime.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled()
})
it('carries terminal launch inputs into an agent-first worktree create', async () => {
const runtime = runtimeStub({ settings: {} })
await launch(
{
...CREATE_LAUNCH,
agentArgs: '--model opus',
cwd: '/repo/packages/api',
launchSource: 'source_control_recovery'
},
runtime
)
expect(createArgs(runtime)).toMatchObject({
startupAgent: 'claude',
startupAgentArgs: '--model opus',
startupCwd: '/repo/packages/api',
startupLaunchSource: 'source_control_recovery'
})
})
it('preserves an explicit no-arguments value for an agent-first worktree create', async () => {
const runtime = runtimeStub({ settings: {} })
await launch({ ...CREATE_LAUNCH, agentArgs: null }, runtime)
expect(createArgs(runtime)).toHaveProperty('startupAgentArgs', null)
})
it('drops a stale startupAgent a caller carried over from worktree.create', async () => {
const runtime = runtimeStub()
await launch(
@@ -483,3 +510,81 @@ describe('worktree.create is untouched by any of this', () => {
expect(createStructuredSession).not.toHaveBeenCalled()
})
})
/**
* The wire half of the launch inputs a host cannot derive: params in, `createTerminal` options out.
*
* Asserted here rather than only at the executor because the executor takes an intent that someone
* has to build. The interesting case is the telemetry triple — two thirds of it is derived by the
* host on purpose, and the third is parsed leniently so an unfamiliar label costs an analytics row
* rather than the user's agent.
*/
describe('launch inputs that cross the wire', () => {
const EXISTING_LAUNCH = {
agent: 'claude',
target: { kind: 'existing', worktree: 'wt-7' }
}
function terminalOptions(runtime: RuntimeStub): Record<string, unknown> {
const [, options] = runtime.createTerminal.mock.calls[0] ?? []
if (!options) {
throw new Error('createTerminal was not called')
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub records whatever options the method passed; each assertion below checks a field before reading it.
return options as Record<string, unknown>
}
it('carries agentArgs and cwd through to the terminal create', async () => {
const runtime = runtimeStub({ settings: {} })
await launch(
{ ...EXISTING_LAUNCH, agentArgs: '--model opus', cwd: '/repo/packages/api' },
runtime
)
expect(terminalOptions(runtime)).toMatchObject({
startupAgent: 'claude',
agentArgs: '--model opus',
cwd: '/repo/packages/api'
})
})
it('derives agent_kind and request_kind, taking only launch_source from the caller', async () => {
const runtime = runtimeStub({ settings: {} })
await launch({ ...EXISTING_LAUNCH, launchSource: 'source_control_recovery' }, runtime)
expect(terminalOptions(runtime).telemetry).toEqual({
agent_kind: 'claude-code',
launch_source: 'source_control_recovery',
request_kind: 'new'
})
})
it('starts the agent anyway when launch_source is one this build has never heard of', async () => {
const runtime = runtimeStub({ settings: {} })
const result = await launch(
{ ...EXISTING_LAUNCH, launchSource: 'a_surface_added_later' },
runtime
)
// The whole point of the open arm set: attribution is bookkeeping, and bookkeeping must never
// gate a user action. The row is dropped; the launch is not.
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
expect(terminalOptions(runtime)).not.toHaveProperty('telemetry')
})
it('sends no telemetry at all when the caller named no launch source', async () => {
const runtime = runtimeStub({ settings: {} })
await launch(EXISTING_LAUNCH, runtime)
expect(terminalOptions(runtime)).not.toHaveProperty('telemetry')
})
it('routes a structured preference to a terminal when the launch names a cwd', async () => {
const runtime = runtimeStub({})
const result = await launch({ ...EXISTING_LAUNCH, cwd: '/repo/packages/api' }, runtime)
expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' })
expect(result.receipt).toMatchObject({ preferred: 'structured', reason: 'tui_launch_command' })
expect(createStructuredSession).not.toHaveBeenCalled()
})
})
+5 -1
View File
@@ -87,7 +87,11 @@ async function agentLaunchIntent(
target: await agentLaunchTarget(params, runtime),
...(params.prompt ? { prompt: params.prompt } : {}),
...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}),
...(params.reuseTerminal ? { reuseTerminal: params.reuseTerminal } : {})
...(params.reuseTerminal ? { reuseTerminal: params.reuseTerminal } : {}),
// `null` means "no arguments" and must survive; only absence falls back to the settings default.
...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {}),
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.launchSource ? { launchSource: params.launchSource } : {})
}
}
@@ -136,6 +136,7 @@ export async function createRuntimeFolderWorktree(args: {
}
const terminal = await deps.createTerminal(`id:${worktree.id}`, {
command: args.startup.command,
...(request.startupCwd ? { cwd: request.startupCwd } : {}),
env: args.startup.env,
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
@@ -99,6 +99,7 @@ export async function startRuntimeLocalWorktreeTerminals(args: {
}
const terminal = await ports.createTerminal(`id:${worktree.id}`, {
command: sequencedStartup.command,
...(request.startupCwd ? { cwd: request.startupCwd } : {}),
...(setup && startup ? { claudeAgentTeamsSourceCommand: startup.command } : {}),
env: sequencedStartup.env,
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
@@ -51,6 +51,10 @@ export type RuntimeManagedWorktreeCreateArgs = {
startupAgent?: TuiAgent
startupLaunchPreferences?: AgentLaunchPreferences
startupPrompt?: string
/** Per-launch inputs used when `startupAgent` is the created terminal surface. */
startupAgentArgs?: string | null
startupCwd?: string
startupLaunchSource?: string
pendingFirstAgentMessageRename?: boolean
automationProvenance?: AutomationWorkspaceProvenance
cliProvenance?: CliWorkspaceProvenance
@@ -110,6 +110,7 @@ export async function createRuntimeRemoteManagedWorktree(
}
const terminal = await deps.createTerminal(`path:${result.worktree.path}`, {
command: sequencedStartup.command,
...(args.startupCwd ? { cwd: args.startupCwd } : {}),
...(result.setup && args.startup
? { claudeAgentTeamsSourceCommand: args.startup.command }
: {}),
@@ -47,6 +47,14 @@ export type TerminalCreateOptions = {
* the prompt silently dropped. Post-start delivery belongs to whoever owns the live PTY.
*/
startupPrompt?: string
/**
* Replaces the Settings launch arguments for this `startupAgent` only; `null` means none at all.
*
* Not part of `callerSuppliedLaunch`: that guard refuses a caller that brought its own *command*,
* which would contradict the agent the runtime is resolving. Arguments are an input to the plan
* the runtime still builds, so overriding them does not take the launch away from it.
*/
agentArgs?: string | null
launchPreferences?: AgentLaunchPreferences
terminalKittyKeyboardProtocol?: boolean
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
@@ -80,6 +80,25 @@ describe('buildWorktreeStartupForAgent host resolution', () => {
it('keeps the rename for a runtime host with no nested SSH target', () => {
expect(launchCliNameFor(makeRepo({ executionHostId: 'runtime:vm-1' }))).toBe('orca-ide')
})
it('uses per-launch arguments and preserves launch telemetry', () => {
const result = buildWorktreeStartupForAgent({
repo: makeRepo({}),
settings,
agent: 'claude',
agentArgs: '--model opus',
launchSource: 'source_control_recovery',
getLaunchPlatform: () => 'linux',
toSessionOptions: () => undefined
})
expect(result.startup.command).toContain("'--model'")
expect(result.startup.telemetry).toEqual({
agent_kind: 'claude-code',
launch_source: 'source_control_recovery',
request_kind: 'new'
})
})
})
describe('buildWorktreeStartupForDraft agent detection', () => {
@@ -1,7 +1,9 @@
import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority'
import { tuiAgentToAgentKind } from '../../shared/agent-kind'
import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import { launchSourceSchema } from '../../shared/telemetry-property-schemas'
import { repoIsRemote } from '../../shared/agent-launch-remote'
import { getRepoSshConnectionId } from '../../shared/execution-host'
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
@@ -31,6 +33,10 @@ type StartupEnvironment = {
repo: Repo
settings: ReturnType<RuntimeStore['getSettings']>
getLaunchPlatform: () => NodeJS.Platform
/** Replaces the configured arguments for this launch; `null` means none. */
agentArgs?: string | null
/** Caller-supplied telemetry attribution, validated leniently at the host boundary. */
launchSource?: string
}
export async function buildWorktreeStartupForDraft(
@@ -146,7 +152,10 @@ export function buildWorktreeStartupForAgent(
agent,
prompt: environment.prompt ?? '',
cmdOverrides: settings.agentCmdOverrides ?? {},
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
agentArgs:
environment.agentArgs !== undefined
? environment.agentArgs
: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
sessionOptions,
sessionOptionsOverrideAgentArgs: Boolean(sessionOptions),
@@ -162,6 +171,7 @@ export function buildWorktreeStartupForAgent(
if (!startupPlan) {
throw new Error(`Could not build launch command for ${agent}.`)
}
const telemetry = agentLaunchTelemetry(agent, environment.launchSource)
return {
agent,
startup: {
@@ -170,7 +180,8 @@ export function buildWorktreeStartupForAgent(
...(startupPlan.startupCommandDelivery
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
: {}),
...(startupPlan.env ? { env: startupPlan.env } : {})
...(startupPlan.env ? { env: startupPlan.env } : {}),
...(telemetry ? { telemetry } : {})
},
...(startupPlan.followupPrompt
? {
@@ -183,6 +194,20 @@ export function buildWorktreeStartupForAgent(
}
}
function agentLaunchTelemetry(
agent: TuiAgent,
launchSource: string | undefined
): WorktreeStartupLaunch['telemetry'] | undefined {
const parsed = launchSourceSchema.safeParse(launchSource)
return parsed.success
? {
agent_kind: tuiAgentToAgentKind(agent),
launch_source: parsed.data,
request_kind: 'new'
}
: undefined
}
export async function markLocalWorktreeTrusted(
agent: TuiAgent,
workspacePath: string
+28
View File
@@ -55,6 +55,34 @@ export type AgentLaunchIntent = {
/** Seeded launch options, narrowed by the host to what a structured create accepts. */
sessionOptions?: Readonly<Record<string, unknown>>
reuseTerminal?: AgentLaunchReusedTerminal
/**
* Per-call replacement for the user's configured launch arguments, as a saved launch recipe
* carries. Tri-state and must stay so: absent means "use the settings default", `null` means the
* caller explicitly wants none, and collapsing the two would make a recipe that clears its args
* silently inherit whatever the settings happen to hold.
*
* Deliberately NOT a route input. `hasExplicitTuiLaunchCommand` reads the launch *command* and
* pointedly not the arguments, because structured chat drives Claude through the Agent SDK and
* Codex through app-server, whose option sets are versioned independently of the interactive
* CLI's. So args reaching a structured launch are ignored rather than forcing a terminal — the
* host says so in `warning` instead of quietly honouring neither the args nor the preference.
*/
agentArgs?: string | null
/**
* Where the agent starts, when that is not the workspace root — a resumed session's recorded
* subdirectory is the case that needs it.
*
* Unlike `agentArgs` this one DOES decide the route: only a terminal can be started somewhere
* other than its workspace, so a launch carrying one downgrades with `tui_launch_command` rather
* than running a structured session in the wrong directory.
*/
cwd?: string
/**
* Which surface the user acted on, for the `agent_started` telemetry triple. Never read as
* behaviour — the host derives the other two members of that triple and this one is the only part
* it cannot know.
*/
launchSource?: string
}
/** The surface the host actually created. */
+100
View File
@@ -0,0 +1,100 @@
/**
* Which launch inputs the replay digest covers, and — just as deliberately — which it does not.
*
* The digest decides whether a retry replays the first answer or is refused as a different call
* wearing the same id, so every field added to the launch wire is a decision in one of two
* directions. Both directions are pinned here: a behavioural field that fell OUT would let a retry
* carrying different arguments silently inherit the original's outcome, and a telemetry field that
* crept IN would refuse an honest retry that merely got re-attributed.
*/
import { describe, expect, it } from 'vitest'
import {
computeAgentLaunchFingerprint,
type AgentLaunchFingerprintInput
} from './agent-launch-operation'
import { canonicalAgentSessionDigest } from './agent-session-mutation-envelope'
const BASE = {
agent: 'claude',
target: { kind: 'existing' as const, worktree: 'wt-1' }
}
/**
* The handler digests the whole params object, `launchSource` included — excess properties are only
* rejected on an object literal, never on the params value it actually passes. Going through this
* signature keeps the exclusion tests honest: they prove the digest IGNORES the field at runtime,
* not merely that the input type has no name for it.
*/
function fingerprintOfWirePayload(
params: AgentLaunchFingerprintInput & { launchSource?: string }
): string {
return computeAgentLaunchFingerprint(params)
}
describe('fields the launch fingerprint covers', () => {
it('separates two launches that differ only in agentArgs', () => {
expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus' })).not.toBe(
computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model sonnet' })
)
})
it('treats an explicit "no arguments" as different from falling back to the settings default', () => {
// `null` and absent are the tri-state the wire keeps; collapsing them here would let a retry
// that cleared its arguments replay an answer produced with the user's configured ones.
expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: null })).not.toBe(
computeAgentLaunchFingerprint(BASE)
)
})
it('separates two launches that differ only in cwd', () => {
expect(computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/a' })).not.toBe(
computeAgentLaunchFingerprint({ ...BASE, cwd: '/repo/packages/b' })
)
})
})
describe('fields the launch fingerprint deliberately ignores', () => {
it('does not separate two launches that differ only in launchSource', () => {
// Telemetry. Two buttons producing the same launch are one operation, and a retry that got
// re-attributed must replay rather than be refused as a conflict.
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'shortcut' })).toBe(
fingerprintOfWirePayload({ ...BASE, launchSource: 'tab_bar_quick_launch' })
)
})
it('ignores launchSource entirely, so sending one matches sending none', () => {
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'sidebar' })).toBe(
computeAgentLaunchFingerprint(BASE)
)
})
})
describe('compatibility with rows written before these fields existed', () => {
/**
* A client that sends none of the new fields must still digest to what the previous build
* produced, or an upgraded host would refuse the in-flight retries of every launch admitted
* before it restarted. This is the old expression verbatim rather than a captured constant, so it
* keeps checking the property rather than a value someone can re-record.
*/
it('matches the pre-existing digest when no new field is sent', () => {
const previousBuild = canonicalAgentSessionDigest({
method: 'agent.launch',
agent: BASE.agent,
target: BASE.target,
prompt: undefined,
sessionOptions: undefined,
reuseTerminal: undefined
})
expect(computeAgentLaunchFingerprint(BASE)).toBe(previousBuild)
})
it('still matches when the caller sends only telemetry the digest excludes', () => {
const previousBuild = canonicalAgentSessionDigest({
method: 'agent.launch',
agent: BASE.agent,
target: BASE.target
})
expect(fingerprintOfWirePayload({ ...BASE, launchSource: 'quick_command' })).toBe(previousBuild)
})
})
+18 -1
View File
@@ -34,6 +34,21 @@ export type AgentLaunchFingerprintInput = {
prompt?: { text: string; delivery: string }
sessionOptions?: Readonly<Record<string, string>>
reuseTerminal?: { handle: string }
/** In: a launch carrying `--model opus` is a different operation from one without, so a retry
* that changed them must conflict rather than replay the first answer. `null` is a value here,
* not an absence — "explicitly no arguments" differs from "use the settings default". */
agentArgs?: string | null
/** In: it decides both where the agent runs and, through `tui_launch_command`, which surface it
* gets. Two launches differing only in `cwd` are genuinely two operations. */
cwd?: string
/**
* `launchSource` is deliberately absent, and this is the reasoned exclusion rather than an
* oversight: it is telemetry, so two launches differing only in which button produced them do the
* same thing, and folding it in would make an honest retry that got re-attributed conflict with
* its own original. That is the rule the mutable host settings above are excluded under — the
* digest covers what the call DOES — and the cost of leaving it out is only that a replay reports
* the first attempt's attribution, which is the truthful answer: one launch happened.
*/
}
/** Host-computed, never accepted from the caller: a digest a client supplies is a digest a buggy
@@ -45,7 +60,9 @@ export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput
target: input.target,
prompt: input.prompt,
sessionOptions: input.sessionOptions,
reuseTerminal: input.reuseTerminal
reuseTerminal: input.reuseTerminal,
agentArgs: input.agentArgs,
cwd: input.cwd
})
}
@@ -0,0 +1,40 @@
/**
* The wire shape of the launch inputs a host cannot derive.
*
* Params are validated by the HOST, which makes every closed arm set here a refusal a future client
* walks into. `launchSource` is the case that matters: it is a telemetry label, and a host that
* rejected an unfamiliar one would fail the user's launch over bookkeeping.
*/
import { describe, expect, it } from 'vitest'
import { AgentLaunch } from './agent-launch-params'
const BASE = { agent: 'claude', target: { kind: 'existing', worktree: 'wt-1' } }
describe('agent.launch params', () => {
it('keeps agentArgs tri-state: a string, an explicit null, and absent are three answers', () => {
expect(AgentLaunch.parse({ ...BASE, agentArgs: '--model opus' }).agentArgs).toBe('--model opus')
expect(AgentLaunch.parse({ ...BASE, agentArgs: null }).agentArgs).toBeNull()
expect(AgentLaunch.parse(BASE)).not.toHaveProperty('agentArgs')
})
it('accepts a cwd and rejects an empty one', () => {
expect(AgentLaunch.parse({ ...BASE, cwd: '/repo/packages/api' }).cwd).toBe('/repo/packages/api')
expect(AgentLaunch.safeParse({ ...BASE, cwd: '' }).success).toBe(false)
})
it('accepts a launchSource this build has never heard of', () => {
// The arm set is open ON PURPOSE. A newer client naming a surface this host predates must still
// get its agent started; the label is re-checked where it is used and dropped if unknown.
const parsed = AgentLaunch.safeParse({ ...BASE, launchSource: 'a_surface_added_later' })
expect(parsed.success).toBe(true)
expect(parsed.data?.launchSource).toBe('a_surface_added_later')
})
it('still parses a payload from a client that sends none of these fields', () => {
// Rule 1: the fields are optional, so a shipped client that predates them is unaffected.
const parsed = AgentLaunch.parse(BASE)
expect(parsed).not.toHaveProperty('cwd')
expect(parsed).not.toHaveProperty('launchSource')
})
})
+16 -1
View File
@@ -65,7 +65,22 @@ export const AgentLaunch = z.object({
.optional(),
/** Only the seedable string options a structured create accepts; a terminal launch ignores them. */
sessionOptions: z.record(z.string(), z.string()).optional(),
reuseTerminal: z.object({ handle: z.string().min(1, 'Missing terminal handle') }).optional()
reuseTerminal: z.object({ handle: z.string().min(1, 'Missing terminal handle') }).optional(),
/** Nullable on purpose: `null` is "no arguments", absent is "use the settings default". */
agentArgs: z.string().nullable().optional(),
/** A start directory other than the workspace root. Terminal-only, and the host downgrades a
* structured launch that carries one rather than ignoring it. */
cwd: z.string().min(1, 'Empty launch cwd').optional(),
/**
* Telemetry attribution, deliberately `z.string()` rather than the closed `launchSourceSchema`.
*
* Params are validated by the HOST, so a closed enum here is a version claim pointing the wrong
* way: a newer client naming a launch surface an older host has never heard of would have its
* whole launch refused over a label nothing reads as behaviour. Bookkeeping must not gate a user
* action, so the arm set stays open here and the host parses it leniently at the point it is
* actually used — the same `safeParse`-and-skip the PTY spawn already does.
*/
launchSource: z.string().optional()
})
export type AgentLaunchParams = z.infer<typeof AgentLaunch>