From d60043787bd2f16ceceb50d99ac10d6b91e38dae Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:58:16 -0700 Subject: [PATCH] feat(agent-launch): carry the launch inputs the host cannot derive (#22037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../agent-launch-executor.test.ts | 92 +++++++++++++++ .../agent-launch/agent-launch-executor.ts | 53 ++++++++- src/main/agent-launch/agent-launch-mode.ts | 9 +- .../orca-runtime-activate-managed-worktree.ts | 8 +- .../orca-runtime-create-managed-worktree.ts | 6 +- ...runtime-resolve-worktree-removal-target.ts | 7 +- ...nal-creation-and-readiness-part-02.spec.ts | 60 ++++++++++ .../rpc/methods/agent-launch-surfaces.ts | 46 +++++++- .../methods/agent-launch-worktree-creation.ts | 12 +- .../rpc/methods/agent-launch.test-fixture.ts | 3 +- .../runtime/rpc/methods/agent-launch.test.ts | 105 ++++++++++++++++++ src/main/runtime/rpc/methods/agent-launch.ts | 6 +- .../runtime/runtime-folder-worktree-create.ts | 1 + ...runtime-local-worktree-terminal-startup.ts | 1 + .../runtime-managed-worktree-create-types.ts | 4 + .../runtime-remote-managed-worktree-create.ts | 1 + .../runtime/runtime-terminal-contracts.ts | 8 ++ .../runtime-worktree-agent-startup.test.ts | 19 ++++ .../runtime/runtime-worktree-agent-startup.ts | 29 ++++- src/shared/agent-launch-intent.ts | 28 +++++ src/shared/agent-launch-operation.test.ts | 100 +++++++++++++++++ src/shared/agent-launch-operation.ts | 19 +++- .../rpc-contract/agent-launch-params.test.ts | 40 +++++++ .../rpc-contract/agent-launch-params.ts | 17 ++- 24 files changed, 657 insertions(+), 17 deletions(-) create mode 100644 src/shared/agent-launch-operation.test.ts create mode 100644 src/shared/rpc-contract/agent-launch-params.test.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index b663ae64b13..58a41c3a49d 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -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() + }) +}) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 78e59d4a9f2..1dc58ee8d33 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -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 }, diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index 1aee0e65cc3..99711cc1293 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -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, 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) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index bbdfb9e6119..6b94e5d0ea2 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -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) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 35968150c8e..79c0b612c05 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -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 = diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index a6db2f1efab..96bd9f8e291 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -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), diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index 7b0ec496333..874a175249f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -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 () => { diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 72f89c888fb..3df28fd0717 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -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 { + 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) { diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 8bc31507680..af7d02714ef 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -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, diff --git a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts index 6b52635fc72..bcb14ec2784 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test-fixture.ts @@ -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) => ({ handle: 'term_1', ...(options.terminalWarning ? { warning: options.terminalWarning } : {}) })), diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 2175aab0234..f592c4fe1b7 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -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 { + 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 + } + + 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() + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index 787652fa293..9101b9ba96d 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -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 } : {}) } } diff --git a/src/main/runtime/runtime-folder-worktree-create.ts b/src/main/runtime/runtime-folder-worktree-create.ts index ef7798c91f9..2089e74c095 100644 --- a/src/main/runtime/runtime-folder-worktree-create.ts +++ b/src/main/runtime/runtime-folder-worktree-create.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-local-worktree-terminal-startup.ts b/src/main/runtime/runtime-local-worktree-terminal-startup.ts index 7babcd7da7d..fa3221fbe1e 100644 --- a/src/main/runtime/runtime-local-worktree-terminal-startup.ts +++ b/src/main/runtime/runtime-local-worktree-terminal-startup.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-managed-worktree-create-types.ts b/src/main/runtime/runtime-managed-worktree-create-types.ts index 17c8dc743db..4f46cd6d142 100644 --- a/src/main/runtime/runtime-managed-worktree-create-types.ts +++ b/src/main/runtime/runtime-managed-worktree-create-types.ts @@ -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 diff --git a/src/main/runtime/runtime-remote-managed-worktree-create.ts b/src/main/runtime/runtime-remote-managed-worktree-create.ts index 83de82b0594..53551ae3ce6 100644 --- a/src/main/runtime/runtime-remote-managed-worktree-create.ts +++ b/src/main/runtime/runtime-remote-managed-worktree-create.ts @@ -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 } : {}), diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 84571232b67..ae4d902c888 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 87fcfd9dd58..1575499ee68 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -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', () => { diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index 8663771e998..e37e331343c 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -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 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 diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index f68dc555815..056f32e436f 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -55,6 +55,34 @@ export type AgentLaunchIntent = { /** Seeded launch options, narrowed by the host to what a structured create accepts. */ sessionOptions?: Readonly> 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. */ diff --git a/src/shared/agent-launch-operation.test.ts b/src/shared/agent-launch-operation.test.ts new file mode 100644 index 00000000000..3ab501ec906 --- /dev/null +++ b/src/shared/agent-launch-operation.test.ts @@ -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) + }) +}) diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index 607808508f4..85127d4c9ec 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -34,6 +34,21 @@ export type AgentLaunchFingerprintInput = { prompt?: { text: string; delivery: string } sessionOptions?: Readonly> 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 }) } diff --git a/src/shared/rpc-contract/agent-launch-params.test.ts b/src/shared/rpc-contract/agent-launch-params.test.ts new file mode 100644 index 00000000000..87b9b84da96 --- /dev/null +++ b/src/shared/rpc-contract/agent-launch-params.test.ts @@ -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') + }) +}) diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index d719d0b0676..c2485fb5002 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -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