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