From acd8425fe13d92c1ed89b20c7a5340c19d88773f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 9 Aug 2026 22:44:00 -0400 Subject: [PATCH] fix(workspaces): preserve remote task launch options --- config/reliability-gates.jsonc | 137 ++++++++++++++++++ src/main/runtime/orca-runtime.test.ts | 55 ++++++- src/main/runtime/orca-runtime.ts | 63 +++++++- .../methods/agent-launch-option-schemas.ts | 46 ++++++ src/main/runtime/rpc/methods/agent-session.ts | 20 +-- .../runtime/rpc/methods/worktree-schemas.ts | 9 ++ src/main/runtime/rpc/methods/worktree.test.ts | 10 +- src/main/runtime/rpc/methods/worktree.ts | 3 + src/main/runtime/rpc/schemas.test.ts | 28 ++++ ...poserState-host-context-boundaries.test.ts | 4 + src/renderer/src/hooks/useComposerState.ts | 26 +++- .../lib/worktree-creation-agent-seeds.test.ts | 132 ++++++++++++++++- .../src/lib/worktree-creation-agent-seeds.ts | 61 +++++++- .../worktree-creation-flow-startup.test.ts | 27 ++++ .../src/lib/worktree-creation-flow-startup.ts | 21 +++ .../src/lib/worktree-creation-flow.test.ts | 4 - .../src/lib/worktree-creation-flow.ts | 13 +- .../src/store/slices/worktree-helpers.ts | 3 + .../src/store/slices/worktrees.test.ts | 14 +- src/renderer/src/store/slices/worktrees.ts | 2 + src/shared/agent-session-option-launch.ts | 6 + .../tui-agent-startup-session-options.test.ts | 50 +++++++ src/shared/tui-agent-startup.ts | 5 +- src/shared/types.ts | 12 +- 24 files changed, 693 insertions(+), 58 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-option-schemas.ts create mode 100644 src/renderer/src/lib/worktree-creation-flow-startup.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3993a178f38..032df83ea75 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10254,6 +10254,143 @@ ], "demotionRule": "Demote if any exit duration can evade the spawn budget, concurrent children or timers appear, cleanup retains resources, or a transient failure cannot recover within the budget." }, + { + "id": "worktree.remote-task-agent-startup-authority", + "title": "Remote task workspaces start one host-owned agent with truthful launch state", + "maturity": "experimental", + "protection": "partial", + "owner": "worktree-runtime", + "layer": "renderer-runtime-worktree-create-contract", + "surfaces": [ + "Tasks issue and pull-request workspace creation", + "paired runtime Git and folder workspaces", + "nested SSH worktrees owned by a paired runtime", + "startup terminal mirroring and wake recovery", + "agent launch session-option state", + "mixed client and host versions" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos", "windows"], + "coveredProviders": ["local", "ssh", "paired-runtime"], + "coverageNotes": "Deterministic contracts join renderer request construction, RPC validation and forwarding, exact host argv for Git/SSH/folder workspaces, agent-scoped startup acknowledgement, renderer fallback suppression, and truthful session-option seeding. A physical paired Windows v1.4.177 host covers the reported Tasks journey and exact host PTY inventory; applying the new session-option field on a physical candidate host remains a gap.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/13412"], + "invariant": "A Tasks-created paired workspace launches exactly one agent PTY on the owning host, mirrors that terminal without a renderer wake or fallback shell, preserves every supported client-selected launch option through host-side quoting, and marks only agent-matched host-acknowledged values as applied. Missing optional fields, fallback agents, invalid option kinds, or receipts from older hosts degrade without RPC failure or false applied state.", + "oracle": "Send a linked-task draft and session options through the real worktree.create schema and handler. Require Git, nested SSH, and folder hosts to emit the selected model and effort in the exact startup command, return the actual applied subset with the startup-terminal receipt, and publish no client-authored command. Feed that receipt through Tasks completion and activation; require the host tab to own draft/session state, zero fallback or wake creation, and no applied cache entry when a legacy receipt omits acknowledgement. On a physical paired Windows host, compare authoritative PTY inventory before and after the fix and require Codex plus Setup only, a running Codex process, the unsent issue draft, completed Setup, and no ConPTY DA1 text.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/shared/tui-agent-startup-session-options.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/schemas.test.ts src/main/runtime/rpc/methods/worktree.test.ts src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/lib/worktree-creation-flow.test.ts src/renderer/src/lib/worktree-creation-flow-startup.test.ts src/renderer/src/lib/worktree-creation-agent-seeds.test.ts src/renderer/src/lib/worktree-activation.test.ts", + "Tasks -> GitHub issue -> Codex -> windows 2 paired host" + ], + "testFiles": [ + "src/shared/tui-agent-startup-session-options.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/schemas.test.ts", + "src/main/runtime/rpc/methods/worktree.test.ts", + "src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts", + "src/renderer/src/store/slices/worktrees.test.ts", + "src/renderer/src/lib/worktree-creation-flow.test.ts", + "src/renderer/src/lib/worktree-creation-flow-startup.test.ts", + "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts", + "src/renderer/src/lib/worktree-activation.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "Git host startup argv replaces host model and effort defaults with client session options", + "nested SSH startup uses remote-shell quoting and returns applied session options", + "folder workspace startup returns the same authoritative receipt", + "fallback agents neither receive nor acknowledge another agent's options" + ] + }, + { + "file": "src/shared/tui-agent-startup-session-options.test.ts", + "assertions": [ + "option values with the wrong catalog kind are neither encoded nor acknowledged" + ] + }, + { + "file": "src/main/runtime/rpc/methods/worktree.test.ts", + "assertions": [ + "worktree.create forwards the bounded optional startup draft and session options" + ] + }, + { + "file": "src/renderer/src/lib/worktree-creation-flow-startup.test.ts", + "assertions": [ + "host-built draft session options are scoped to the requested agent", + "client-built startup does not also request host-owned draft startup" + ] + }, + { + "file": "src/renderer/src/lib/worktree-creation-flow.test.ts", + "assertions": [ + "the backend startup receipt identifies the agent tab rather than a configured default tab" + ] + }, + { + "file": "src/renderer/src/lib/worktree-activation.test.ts", + "assertions": [ + "explicit backend spawn evidence suppresses fallback creation while the host tab mirrors" + ] + }, + { + "file": "src/renderer/src/lib/worktree-creation-agent-seeds.test.ts", + "assertions": [ + "legacy hosts without acknowledgement cannot stamp client options as applied", + "client-built commands retain their known applied state", + "fallback agents own draft state without inheriting requested-agent options" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-09", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/tui-agent-startup-session-options.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/schemas.test.ts src/main/runtime/rpc/methods/worktree.test.ts src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/lib/worktree-creation-flow.test.ts src/renderer/src/lib/worktree-creation-flow-startup.test.ts src/renderer/src/lib/worktree-creation-agent-seeds.test.ts src/renderer/src/lib/worktree-activation.test.ts", + "durationSeconds": 12, + "summary": "1,516 deterministic renderer, RPC, Git, SSH, folder, launch-command, compatibility, and activation tests passed; one unrelated test was skipped." + }, + { + "date": "2026-08-09", + "runner": "manual", + "platform": "windows", + "result": "passed", + "command": "Tasks -> GitHub issue -> Codex -> windows 2 paired host", + "durationSeconds": 60, + "summary": "Baseline had two blank shells plus Setup and no Codex. Candidate had Codex plus Setup only, Codex running with the issue URL drafted, Setup complete, and no DA1 text." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "deterministic renderer, RPC, full runtime-service, and shared launch contracts" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "New gate; controlled promises, exact call counts, and host inventories replace timing or eventual-state assertions." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "On baseline 94cc867e2a the physical Windows journey produced three host PTYs, no running Codex, and visible DA1 text; the candidate produced exactly Codex plus Setup. Independent review then proved the first candidate built from host defaults while stamping client options as applied. The exact argv and acknowledgement oracle fails on that candidate and passes after the optional session-options request and host-applied receipt; omitting the receipt reproduces the legacy no-claim result." + }, + "performanceBudget": { + "required": false, + "evidence": "Creation adds one bounded optional JSON object and one response property. It adds no polling, retries, scans, subprocesses, timers, listeners, or hot-path fanout." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the candidate host build on physical Windows and headless serve with non-default model and effort selections.", + "Add a versioned worktree.create harness spanning v1.4.19, v1.4.68, v1.4.138, and the current host." + ], + "knownGaps": [ + "The physical Windows host was v1.4.177, so it proves terminal topology but not application of the new session-options field.", + "Headless serve parity and a second simultaneous viewer have not been exercised for this Tasks creation path.", + "Historical receipt boundaries are reconstructed from release history and response-shape tests rather than four packaged hosts." + ], + "demotionRule": "Demote if a task create can produce a second shell, if any client-only value is marked applied without host acknowledgement, if an old peer rejects the optional fields, or if the gate depends on elapsed time instead of authoritative inventory." + }, { "id": "ssh-managed-hooks.node18-runtime-compatibility", "title": "SSH managed-hook companions load and install hooks on Node 18", diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 7967d422f46..f84ec48cd12 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -4057,6 +4057,14 @@ describe('OrcaRuntimeService', () => { } const runtimeStore = { ...store, + getSettings: () => ({ + ...store.getSettings(), + agentCmdOverrides: {}, + agentDefaultArgs: { + codex: + '-m folder-host-model -c model_reasoning_effort=low --dangerously-bypass-approvals-and-sandbox' + } + }), getRepos: () => [folderRepo], getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined), getAllWorktreeMeta: () => metaById, @@ -4097,14 +4105,25 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:folder-repo', name: 'folder-session', createdWithAgent: 'codex', - startup: { command: 'codex', viewMode: 'chat' } + startupDraft: 'https://github.com/stablyai/orca/issues/12', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high' } + } }) expect(addWorktreeMock).not.toHaveBeenCalled() expect(createTerminal).toHaveBeenCalledWith( `id:${result.worktree.id}`, - expect.objectContaining({ command: 'codex', viewMode: 'chat' }) + expect.objectContaining({ + command: + "codex '--dangerously-bypass-approvals-and-sandbox' '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=high'" + }) ) + expect(result.startupTerminal?.appliedSessionOptions).toEqual({ + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high' } + }) expect(result.worktree).toEqual( expect.objectContaining({ id: expect.stringMatching(/^folder-repo::\/workspace\/folder::workspace:[0-9a-f-]{36}$/), @@ -42539,7 +42558,11 @@ describe('OrcaRuntimeService', () => { getSettings: () => ({ ...store.getSettings(), defaultTuiAgent: 'codex' as const, - agentCmdOverrides: { codex: 'codex --profile work' } + agentCmdOverrides: { codex: 'codex --profile work' }, + agentDefaultArgs: { + codex: + '-m host-model -c model_reasoning_effort=low --dangerously-bypass-approvals-and-sandbox' + } }), getAllWorktreeMeta: () => metaById, getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], @@ -42590,6 +42613,10 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:repo-1', name: 'runtime-startup-draft', startupDraft: draftUrl, + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high' } + }, activate: true }) @@ -42598,10 +42625,15 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-draft', - command: "codex --profile work '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex --profile work '--dangerously-bypass-approvals-and-sandbox' '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=high'", worktreeId: result.worktree.id }) ) + expect(result.startupTerminal?.appliedSessionOptions).toEqual({ + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high' } + }) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'codex' }) runtime.onPtyData('pty-startup-draft', '\x1b[?2004h›', Date.now()) @@ -42906,6 +42938,10 @@ describe('OrcaRuntimeService', () => { repoSelector: TEST_REPO_ID, name: 'runtime-fallback-draft', startupDraft: 'https://github.com/stablyai/orca/issues/456', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high' } + }, createdWithAgent: 'codex', activate: true }) @@ -42919,6 +42955,8 @@ describe('OrcaRuntimeService', () => { }) ) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) + expect(spawn.mock.calls[0]?.[0].command).not.toContain('gpt-5.6-sol') + expect(result.startupTerminal?.appliedSessionOptions).toBeUndefined() }) it('honors split setup placement for opted-in local startup-draft worktrees', async () => { @@ -43375,7 +43413,8 @@ describe('OrcaRuntimeService', () => { const result = await runtime.createManagedWorktree({ repoSelector: TEST_REPO_ID, name: 'mobile-startup-draft', - startupDraft: draftUrl + startupDraft: draftUrl, + startupSessionOptions: { agent: 'claude', values: { model: 'opus', effort: 'high' } } }) expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) @@ -43383,11 +43422,15 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-startup-draft', - command: `claude '--dangerously-skip-permissions' --prefill '${draftUrl}'`, + command: `claude '--dangerously-skip-permissions' '--model' 'opus' '--effort' 'high' --prefill '${draftUrl}'`, connectionId: 'ssh-1', worktreeId: result.worktree.id }) ) + expect(result.startupTerminal?.appliedSessionOptions).toEqual({ + agent: 'claude', + values: { model: 'opus', effort: 'high' } + }) expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 526acea823b..2c9d716c0d7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -219,6 +219,7 @@ import type { TerminalPaneLayoutNode, TerminalTab, TuiAgent, + AgentLaunchSessionOptions, WorkspaceCreateTelemetrySource, WorkspaceSessionState, WorkspaceLinkedItem, @@ -21124,11 +21125,13 @@ export class OrcaRuntimeService { private async buildStartupForDraft( repo: Repo, draft: string, - requestedAgent?: TuiAgent + requestedAgent?: TuiAgent, + requestedSessionOptions?: AgentLaunchSessionOptions ): Promise<{ agent: TuiAgent startup: WorktreeStartupLaunch draftPaste?: WorktreeStartupDraftPaste + appliedSessionOptions?: AgentLaunchSessionOptions } | null> { if (!this.store) { return null @@ -21164,6 +21167,8 @@ export class OrcaRuntimeService { if (!agent) { return null } + const sessionOptions = + requestedSessionOptions?.agent === agent ? requestedSessionOptions.values : undefined // Why: a mobile client can run on Windows while the workspace shell is // Linux over SSH. Startup command quoting must target the shell that runs it. @@ -21180,6 +21185,8 @@ export class OrcaRuntimeService { cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform: agentLaunchPlatform, shell: queuedShell, isRemote @@ -21194,7 +21201,10 @@ export class OrcaRuntimeService { ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } + }, + ...(draftLaunchPlan.sessionOptions + ? { appliedSessionOptions: { agent, values: draftLaunchPlan.sessionOptions } } + : {}) } } @@ -21204,12 +21214,17 @@ export class OrcaRuntimeService { cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform: agentLaunchPlatform, shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) if (!startupPlan) { + if (sessionOptions) { + throw new Error(`Could not apply launch preferences for ${agent}.`) + } return null } return { @@ -21222,7 +21237,10 @@ export class OrcaRuntimeService { : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}) }, - draftPaste: { agent, content } + draftPaste: { agent, content }, + ...(startupPlan.sessionOptions + ? { appliedSessionOptions: { agent, values: startupPlan.sessionOptions } } + : {}) } } @@ -21231,7 +21249,12 @@ export class OrcaRuntimeService { agent: TuiAgent, prompt: string | undefined, launchPreferences?: AgentLaunchPreferences - ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { + ): { + agent: TuiAgent + startup: WorktreeStartupLaunch + followup?: WorktreeStartupFollowup + appliedSessionOptions?: AgentLaunchSessionOptions + } { if (!this.store) { throw new Error('runtime_unavailable') } @@ -21282,6 +21305,9 @@ export class OrcaRuntimeService { prompt: startupPlan.followupPrompt } } + : {}), + ...(startupPlan.sessionOptions + ? { appliedSessionOptions: { agent, values: startupPlan.sessionOptions } } : {}) } } @@ -21696,6 +21722,7 @@ export class OrcaRuntimeService { createdWithAgent?: TuiAgent startupAgent?: TuiAgent startupLaunchPreferences?: AgentLaunchPreferences + startupSessionOptions?: AgentLaunchSessionOptions startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance @@ -21737,10 +21764,17 @@ export class OrcaRuntimeService { : null const draftStartup = !args.startup && !agentStartup && args.startupDraft - ? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent) + ? await this.buildStartupForDraft( + repo, + args.startupDraft, + requestedAgent, + args.startupSessionOptions + ) : null const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup const effectiveStartupFollowup = agentStartup?.followup + const effectiveAppliedSessionOptions = + agentStartup?.appliedSessionOptions ?? draftStartup?.appliedSessionOptions const effectiveCreatedWithAgent = args.startup ? args.createdWithAgent : (agentStartup?.agent ?? @@ -21841,7 +21875,10 @@ export class OrcaRuntimeService { ...(terminal.tabId ? { tabId: terminal.tabId } : {}), ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}), ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), - surface: 'background' + surface: 'background', + ...(effectiveAppliedSessionOptions + ? { appliedSessionOptions: effectiveAppliedSessionOptions } + : {}) } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -21894,6 +21931,9 @@ export class OrcaRuntimeService { ...(effectiveStartup ? { startup: effectiveStartup } : {}), ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(effectiveAppliedSessionOptions + ? { startupAppliedSessionOptions: effectiveAppliedSessionOptions } + : {}), ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) @@ -22762,7 +22802,10 @@ export class OrcaRuntimeService { ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), - surface: 'background' as const + surface: 'background' as const, + ...(effectiveAppliedSessionOptions + ? { appliedSessionOptions: effectiveAppliedSessionOptions } + : {}) } } : {}) @@ -22806,6 +22849,7 @@ export class OrcaRuntimeService { startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup startupDraftPaste?: WorktreeStartupDraftPaste + startupAppliedSessionOptions?: AgentLaunchSessionOptions } ): Promise { if (!this.store) { @@ -23073,7 +23117,10 @@ export class OrcaRuntimeService { ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), - surface: 'background' as const + surface: 'background' as const, + ...(args.startupAppliedSessionOptions + ? { appliedSessionOptions: args.startupAppliedSessionOptions } + : {}) } } : resultForRenderer diff --git a/src/main/runtime/rpc/methods/agent-launch-option-schemas.ts b/src/main/runtime/rpc/methods/agent-launch-option-schemas.ts new file mode 100644 index 00000000000..f84edd43179 --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-option-schemas.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { AgentLaunchSessionOptions, TuiAgent } from '../../../../shared/types' + +const MAX_LAUNCH_OPTION_LENGTH = 512 +const MAX_SESSION_OPTION_COUNT = 32 + +const LaunchOption = (label: string) => + z + .string() + .min(1, `Invalid ${label}`) + .max(MAX_LAUNCH_OPTION_LENGTH, `Invalid ${label}`) + .refine( + (value) => value === value.trim(), + `Invalid ${label}; surrounding whitespace is invalid` + ) + +const SessionOptionKey = LaunchOption('session option key') + .regex(/^[A-Za-z][A-Za-z0-9_.-]*$/, 'Invalid session option key') + .refine((value) => !['__proto__', 'constructor', 'prototype'].includes(value), { + message: 'Invalid session option key' + }) + +export const AgentLaunchPreferencesSchema = z + .object({ + model: LaunchOption('model preference').optional(), + effort: LaunchOption('effort preference').optional(), + mode: LaunchOption('mode preference').optional() + }) + .strict() + +export const AgentSessionOptionsSchema = z + .record(SessionOptionKey, z.union([LaunchOption('session option value'), z.boolean()])) + .refine((value) => Object.keys(value).length > 0, { + message: 'Session options must not be empty' + }) + .refine((value) => Object.keys(value).length <= MAX_SESSION_OPTION_COUNT, { + message: 'Too many session options' + }) + +export const AgentLaunchSessionOptionsSchema: z.ZodType = z + .object({ + agent: z.custom((value) => isTuiAgent(value), 'Unknown TUI agent'), + values: AgentSessionOptionsSchema + }) + .strict() diff --git a/src/main/runtime/rpc/methods/agent-session.ts b/src/main/runtime/rpc/methods/agent-session.ts index 08aaa91038e..f0fff7f5525 100644 --- a/src/main/runtime/rpc/methods/agent-session.ts +++ b/src/main/runtime/rpc/methods/agent-session.ts @@ -19,12 +19,12 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcAnyMethod } from '../core' +import { AgentLaunchPreferencesSchema } from './agent-launch-option-schemas' const MAX_WORKTREE_SELECTOR_LENGTH = 32_768 const MAX_TRANSCRIPT_PATH_BYTES = 16 * 1024 const MAX_PROMPT_BYTES = 256 * 1024 const MAX_AGENT_ARGS_BYTES = 16 * 1024 -const MAX_LAUNCH_PREFERENCE_LENGTH = 512 const StrictNonEmptyString = (max: number, message: string) => z @@ -55,20 +55,6 @@ const Placement = z message: 'Placement must include a tab or leaf ID' }) -const LaunchPreferences = z - .object({ - model: StrictNonEmptyString( - MAX_LAUNCH_PREFERENCE_LENGTH, - 'Invalid model preference' - ).optional(), - effort: StrictNonEmptyString( - MAX_LAUNCH_PREFERENCE_LENGTH, - 'Invalid effort preference' - ).optional(), - mode: StrictNonEmptyString(MAX_LAUNCH_PREFERENCE_LENGTH, 'Invalid mode preference').optional() - }) - .strict() - const PromptDelivery = z.enum(['auto-submit', 'draft']) const AgentArgs = z @@ -131,7 +117,7 @@ const ExplicitEnsure = z providerSession: ProviderSession, ompResumeFilePath: OmpResumeFilePath.optional(), agentArgs: AgentArgs.optional(), - launchPreferences: LaunchPreferences.optional(), + launchPreferences: AgentLaunchPreferencesSchema.optional(), presentation: Presentation.optional(), placement: Placement.optional() }) @@ -175,7 +161,7 @@ export const CreateAgentSessionParams: z.ZodType (isTuiAgent(value) ? value : undefined)) @@ -210,6 +212,13 @@ export const WorktreeCreate = z message: 'startupPrompt requires startupAgent' }) } + if (params.startupSessionOptions !== undefined && params.startupDraft === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['startupSessionOptions'], + message: 'startupSessionOptions requires startupDraft' + }) + } }) export const WorktreePrefetchCreateBase = z.object({ diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 3fe4b5da15c..bb12f3081f0 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -588,6 +588,10 @@ describe('worktree RPC methods', () => { repo: 'repo-1', name: 'issue-123', startupDraft: 'https://github.com/stablyai/orca/issues/123', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high', fastMode: true } + }, createdWithAgent: 'codex', activate: true }) @@ -600,7 +604,11 @@ describe('worktree RPC methods', () => { activate: true, createdWithAgent: 'codex', startup: undefined, - startupDraft: 'https://github.com/stablyai/orca/issues/123' + startupDraft: 'https://github.com/stablyai/orca/issues/123', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high', fastMode: true } + } }) ) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 758e6b83891..bf67d1e82c2 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -158,6 +158,9 @@ export const WORKTREE_METHODS: RpcMethod[] = [ } : undefined, ...(params.startupAgent ? { startupAgent: params.startupAgent } : {}), + ...(params.startupSessionOptions + ? { startupSessionOptions: params.startupSessionOptions } + : {}), ...(params.startupPrompt !== undefined ? { startupPrompt: params.startupPrompt } : {}), startupDraft: params.startupDraft, lineage: { diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 8ba47496545..2ce98106cee 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -180,6 +180,10 @@ describe('RPC optional pipe schemas', () => { setupDecision: 'run', activate: true, startupDraft: 'https://github.com/acme/app/pull/123', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high', fastMode: true } + }, createdWithAgent: 'codex', linkedPR: 123, baseBranch: 'origin/main', @@ -187,6 +191,30 @@ describe('RPC optional pipe schemas', () => { branchNameOverride: 'feature/mobile-tasks', pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } }) + expectRejects(create, { + repo: 'id:repo-github', + startupDraft: 'https://github.com/acme/app/pull/123', + startupSessionOptions: { agent: 'codex', values: { model: ' gpt-5.6-sol' } } + }) + expectRejects(create, { + repo: 'id:repo-github', + startupDraft: 'https://github.com/acme/app/pull/123', + startupSessionOptions: { agent: 'codex', values: { constructor: 'polluted' } } + }) + expectRejects(create, { + repo: 'id:repo-github', + startupSessionOptions: { agent: 'codex', values: { model: 'gpt-5.6-sol' } } + }) + expectRejects(create, { + repo: 'id:repo-github', + startupDraft: 'https://github.com/acme/app/pull/123', + startupSessionOptions: { values: { model: 'gpt-5.6-sol' } } + }) + expectRejects(create, { + repo: 'id:repo-github', + startupDraft: 'https://github.com/acme/app/pull/123', + startupSessionOptions: { agent: 'not-an-agent', values: { model: 'gpt-5.6-sol' } } + }) expectParses(create, { repo: 'id:repo-gitlab', name: 'mr-7', diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index 635399b63d8..89d754d8a86 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -751,6 +751,10 @@ describe('useComposerState host-context boundaries', () => { ) expect(fullSubmit).toContain('platform: selectedRepoAgentLaunchPlatform') expect(fullSubmit).toContain('startupDraft: startupPlan.draftPrompt') + expect(fullSubmit).toContain( + 'startupSessionOptions: { agent: tuiAgent, values: startupPlan.sessionOptions }' + ) + expect(fullSubmit).toContain('resolveCreatedAgentAppliedSessionOptions') expect(fullSubmit).not.toContain('platform: CLIENT_PLATFORM') const quickSubmit = sourceBetween( diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 35f403c8e09..c4578b4ba84 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -15,6 +15,7 @@ import { } from '@/lib/github-links' import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation' import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow' +import { resolveCreatedAgentAppliedSessionOptions } from '@/lib/worktree-creation-agent-seeds' import { findPendingLinkedWorkItemCreationId, type WorktreeCreationRequest @@ -3851,7 +3852,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem), linkedTaskSourceContext: taskSourceContext, ...(!backendStartup && startupPlan?.draftPrompt - ? { startupDraft: startupPlan.draftPrompt } + ? { + startupDraft: startupPlan.draftPrompt, + ...(startupPlan.sessionOptions + ? { + startupSessionOptions: { agent: tuiAgent, values: startupPlan.sessionOptions } + } + : {}) + } : {}) } ) @@ -3907,10 +3915,24 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS : {}) }) if (startupPlan) { + const createdAgent = + backendSpawnedStartup && !backendStartup + ? (worktree.createdWithAgent ?? tuiAgent) + : tuiAgent const optionScopeKey = (activation !== false ? activation.primaryTabId : null) ?? result.startupTerminal?.tabId if (optionScopeKey) { - seedNativeChatAppliedSessionOptions(optionScopeKey, tuiAgent, startupPlan.sessionOptions) + seedNativeChatAppliedSessionOptions( + optionScopeKey, + createdAgent, + resolveCreatedAgentAppliedSessionOptions({ + agent: createdAgent, + backendSpawned: backendSpawnedStartup, + clientBuiltStartup: Boolean(backendStartup), + clientSessionOptions: startupPlan.sessionOptions, + hostAppliedSessionOptions: result.startupTerminal?.appliedSessionOptions + }) + ) } } if (startupPlan && !backendSpawnedStartup) { diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 9ac7e28ed25..9db9e7ebc99 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery' -import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent-seeds' +import { + resolveCreatedAgentAppliedSessionOptions, + seedAgentTabStateAfterWorktreeCreate +} from './worktree-creation-agent-seeds' const mocks = vi.hoisted(() => ({ seedNativeChatAppliedSessionOptions: vi.fn(), @@ -108,6 +111,46 @@ afterEach(() => { }) describe('seedAgentTabStateAfterWorktreeCreate', () => { + it('requires acknowledgement only when the host built the launch command', () => { + const clientSessionOptions = { model: 'client-model', effort: 'high' } + const hostAppliedSessionOptions = { model: 'host-confirmed-model' } + + expect( + resolveCreatedAgentAppliedSessionOptions({ + agent: 'claude', + backendSpawned: true, + clientBuiltStartup: false, + clientSessionOptions, + hostAppliedSessionOptions: { agent: 'claude', values: hostAppliedSessionOptions } + }) + ).toEqual(hostAppliedSessionOptions) + expect( + resolveCreatedAgentAppliedSessionOptions({ + agent: 'claude', + backendSpawned: true, + clientBuiltStartup: false, + clientSessionOptions + }) + ).toBeUndefined() + expect( + resolveCreatedAgentAppliedSessionOptions({ + agent: 'claude', + backendSpawned: true, + clientBuiltStartup: false, + clientSessionOptions, + hostAppliedSessionOptions: { agent: 'codex', values: clientSessionOptions } + }) + ).toBeUndefined() + expect( + resolveCreatedAgentAppliedSessionOptions({ + agent: 'claude', + backendSpawned: true, + clientBuiltStartup: true, + clientSessionOptions + }) + ).toEqual(clientSessionOptions) + }) + it('seeds the backend-spawned startup tab, not the first default terminal tab', () => { // Repo default tabs (dev server / logs / shell) run no agent; on the // backend-spawn path none of them carries launchAgent either. @@ -129,6 +172,93 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { ) }) + it('seeds only host-confirmed options for a host-built startup', () => { + setTabs([{ id: 'agent-tab' }]) + const requestWithOptions = { + ...request, + startupPlan: { + agent: 'claude', + launchCommand: 'claude', + sessionOptions: { model: 'client-model', effort: 'high' } + } as never + } + + seedAgentTabStateAfterWorktreeCreate({ + request: requestWithOptions, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true, + hostAppliedSessionOptions: { + agent: 'claude', + values: { model: 'host-confirmed-model' } + } + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith('agent-tab', 'claude', { + model: 'host-confirmed-model' + }) + }) + + it('does not claim client options when an older host returns no acknowledgement', () => { + setTabs([{ id: 'agent-tab' }]) + const requestWithOptions = { + ...request, + startupPlan: { + agent: 'claude', + launchCommand: 'claude', + sessionOptions: { model: 'client-model', effort: 'high' } + } as never + } + + seedAgentTabStateAfterWorktreeCreate({ + request: requestWithOptions, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith( + 'agent-tab', + 'claude', + undefined + ) + }) + + it('uses the host fallback agent without applying requested-agent options', () => { + setTabs([{ id: 'agent-tab' }]) + const codexRequest = { + ...request, + agent: 'codex' as const, + startupPlan: { + agent: 'codex', + launchCommand: 'codex', + sessionOptions: { model: 'gpt-5.6-sol', effort: 'high' } + } as never + } + + seedAgentTabStateAfterWorktreeCreate({ + request: codexRequest, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true, + backendAgent: 'claude' + }) + + expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith( + 'agent-tab', + 'claude', + undefined + ) + expect(mocks.seedNativeChatLaunchDraftForAgentTab).toHaveBeenCalledWith({ + tabId: 'agent-tab', + agent: 'claude', + text: DRAFT + }) + }) + it('moves a backend-spawned non-mirrorable draft out of an inherited chat view', () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }]) diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.ts index 295b5e5c06c..4eb58fa79d2 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.ts @@ -9,12 +9,28 @@ import { nativeChatRequiresLocalTranscript } from '@/lib/native-chat-supported-a import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { toWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id' import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import type { TuiAgent } from '../../../shared/types' +import type { AgentLaunchSessionOptions, TuiAgent } from '../../../shared/types' +import type { SessionOptionValue } from '../../../shared/native-chat-session-options' type AppStoreSnapshot = ReturnType type SeedRequest = Pick +export function resolveCreatedAgentAppliedSessionOptions(args: { + agent: TuiAgent + backendSpawned: boolean + clientBuiltStartup: boolean + clientSessionOptions?: Record + hostAppliedSessionOptions?: AgentLaunchSessionOptions +}): Record | undefined { + if (!args.backendSpawned || args.clientBuiltStartup) { + return args.clientSessionOptions + } + return args.hostAppliedSessionOptions?.agent === args.agent + ? args.hostAppliedSessionOptions.values + : undefined +} + /** * Resolve the tab the launch agent actually runs in. * @@ -92,10 +108,15 @@ function applyAgentTabSeeds(args: { tabId: string worktreeId: string backendSpawned: boolean + backendAppliedSessionOptions?: Record }): void { - const { request, agent, tabId } = args + const { request, agent, tabId, backendSpawned, backendAppliedSessionOptions } = args applyBackendSpawnedDraftViewMode(args) - seedNativeChatAppliedSessionOptions(tabId, agent, request.startupPlan?.sessionOptions) + seedNativeChatAppliedSessionOptions( + tabId, + agent, + backendSpawned ? backendAppliedSessionOptions : request.startupPlan?.sessionOptions + ) // Why: draft launch context reaches only the TUI input; seed the // chat-composer copy so it isn't invisible in the chat view. if (request.launchDraftPrompt) { @@ -113,16 +134,38 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { primaryTabId: string | null startupTerminalTabId: string | null | undefined backendSpawned: boolean + backendAgent?: TuiAgent + clientBuiltStartup?: boolean + hostAppliedSessionOptions?: AgentLaunchSessionOptions }): void { const { request, worktreeId, backendSpawned } = args - const agent = request.agent - if (!request.startupPlan || !agent) { + const requestedAgent = request.agent + if (!request.startupPlan || !requestedAgent) { return } + const agent = + backendSpawned && !args.clientBuiltStartup + ? (args.backendAgent ?? requestedAgent) + : requestedAgent + const backendAppliedSessionOptions = resolveCreatedAgentAppliedSessionOptions({ + agent, + backendSpawned, + clientBuiltStartup: Boolean(args.clientBuiltStartup), + clientSessionOptions: request.startupPlan.sessionOptions, + hostAppliedSessionOptions: args.hostAppliedSessionOptions + }) const state = useAppStore.getState() const tabId = resolveLaunchAgentTabId(state, { ...args, agent }) if (tabId) { - applyAgentTabSeeds({ state, request, agent, tabId, worktreeId, backendSpawned }) + applyAgentTabSeeds({ + state, + request, + agent, + tabId, + worktreeId, + backendSpawned, + backendAppliedSessionOptions + }) return } if ((state.tabsByWorktree[worktreeId] ?? []).length > 0) { @@ -144,7 +187,8 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { agent, tabId: mirroredTabId, worktreeId, - backendSpawned + backendSpawned, + backendAppliedSessionOptions }) return } @@ -159,7 +203,8 @@ export function seedAgentTabStateAfterWorktreeCreate(args: { agent, tabId: firstTerminalTabId, worktreeId, - backendSpawned + backendSpawned, + backendAppliedSessionOptions }) } } diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.test.ts b/src/renderer/src/lib/worktree-creation-flow-startup.test.ts new file mode 100644 index 00000000000..d0ca74dfdbb --- /dev/null +++ b/src/renderer/src/lib/worktree-creation-flow-startup.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeCreationRequest } from './pending-worktree-creation' +import { buildHostDraftStartupOptions } from './worktree-creation-flow-startup' + +const request = { + agent: 'codex', + launchDraftPrompt: 'https://github.com/stablyai/orca/issues/12', + startupPlan: { + sessionOptions: { model: 'gpt-5.6-sol', effort: 'high' } + } +} as unknown as WorktreeCreationRequest + +describe('buildHostDraftStartupOptions', () => { + it('scopes launch options to the requested agent', () => { + expect(buildHostDraftStartupOptions(request, false)).toEqual({ + startupDraft: request.launchDraftPrompt, + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high' } + } + }) + }) + + it('omits host-owned startup when the client already built it', () => { + expect(buildHostDraftStartupOptions(request, true)).toEqual({}) + }) +}) diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 4c946e0cf63..5ceb353eb89 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -5,6 +5,27 @@ import type { WorktreeCreationPhase, WorktreeCreationRequest } from '@/lib/pending-worktree-creation' +import type { AgentLaunchSessionOptions } from '../../../shared/types' + +export function buildHostDraftStartupOptions( + request: WorktreeCreationRequest, + clientBuiltStartup: boolean +): { startupDraft?: string; startupSessionOptions?: AgentLaunchSessionOptions } { + if (clientBuiltStartup || !request.agent || !request.launchDraftPrompt) { + return {} + } + return { + startupDraft: request.launchDraftPrompt, + ...(request.startupPlan?.sessionOptions + ? { + startupSessionOptions: { + agent: request.agent, + values: request.startupPlan.sessionOptions + } + } + : {}) + } +} // Why: mirrors the startup-opt the composer used to build inline. The renderer // only seeds the first terminal when the backend did not already spawn it. diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index f48591a8b2f..0850aa406e1 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -739,10 +739,6 @@ describe('staged background worktree creation', () => { expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'agent-tab' }) ) - const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined - expect(createCall?.[25]).toEqual({ - startupDraft: 'https://github.com/o/r/issues/12' - }) }) it.each([ diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 132f4825dc8..08ff36b4340 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -27,6 +27,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { seedAgentTabStateAfterWorktreeCreate } from '@/lib/worktree-creation-agent-seeds' import { resolveBackendDraftStartup } from '@/lib/worktree-draft-startup-view-mode' import { + buildHostDraftStartupOptions, buildWorktreeCreationStartupOpt, getInitialWorktreeCreationPhase, getWorktreeCreationIndeterminate @@ -103,9 +104,9 @@ async function executeWorktreeCreation( return } + const backendStartup = resolveBackendDraftStartup(preparedRequest) let result: CreateWorktreeResult try { - const backendStartup = resolveBackendDraftStartup(preparedRequest) result = await useAppStore .getState() .createWorktree( @@ -141,10 +142,7 @@ async function executeWorktreeCreation( ...(preparedRequest.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: preparedRequest.linkedTaskSourceContext } : {}), - // Why: the remote host must own task-draft startup so its initial terminal is the agent, not an idle fallback shell. - ...(!backendStartup && preparedRequest.agent && preparedRequest.launchDraftPrompt - ? { startupDraft: preparedRequest.launchDraftPrompt } - : {}) + ...buildHostDraftStartupOptions(preparedRequest, Boolean(backendStartup)) } ) } catch (error) { @@ -243,7 +241,10 @@ async function executeWorktreeCreation( worktreeId: worktree.id, primaryTabId, startupTerminalTabId: result.startupTerminal?.tabId, - backendSpawned + backendSpawned, + backendAgent: result.worktree.createdWithAgent, + clientBuiltStartup: Boolean(backendStartup), + hostAppliedSessionOptions: result.startupTerminal?.appliedSessionOptions }) if (preparedRequest.startupPlan && !backendSpawned) { void ensureAgentStartupInTerminal({ diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 499a4865cfe..7688602578a 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -1,6 +1,7 @@ import type { CreateWorktreeResult, CreateWorktreeArgs, + AgentLaunchSessionOptions, CreateSparseCheckoutRequest, DetectedWorktree, DetectedWorktreeListResult, @@ -200,6 +201,8 @@ export type WorktreeSlice = { linkedTaskSourceContext?: TaskSourceContext | null /** Lets the owning runtime launch and prefill a task agent without first creating an idle shell. */ startupDraft?: string + /** Picker values the owning runtime should apply while building that launch. */ + startupSessionOptions?: AgentLaunchSessionOptions } ) => Promise /** Register an in-flight background creation and make it the active surface. */ diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6709169daca..33dc8358b36 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -5933,7 +5933,13 @@ describe('worktree remote runtime mutations', () => { const createWorktree = store.getState().createWorktree const args: Parameters = ['repo1', 'task-draft', undefined, 'inherit'] args[10] = 'codex' - args[25] = { startupDraft: 'https://github.com/stablyai/orca/issues/12' } + args[25] = { + startupDraft: 'https://github.com/stablyai/orca/issues/12', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high', fastMode: true } + } + } await createWorktree(...args) @@ -5942,7 +5948,11 @@ describe('worktree remote runtime mutations', () => { method: 'worktree.create', params: expect.objectContaining({ createdWithAgent: 'codex', - startupDraft: 'https://github.com/stablyai/orca/issues/12' + startupDraft: 'https://github.com/stablyai/orca/issues/12', + startupSessionOptions: { + agent: 'codex', + values: { model: 'gpt-5.6-sol', effort: 'high', fastMode: true } + } }) }) ) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 51613ebdb8a..24a9e6800ee 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3990,6 +3990,7 @@ export const createWorktreeSlice: StateCreator const linkedWorkItem = options?.linkedWorkItem const linkedTaskSourceContext = options?.linkedTaskSourceContext const startupDraft = options?.startupDraft + const startupSessionOptions = options?.startupSessionOptions try { for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { const candidateName = getClientWorktreeCreateCandidate(name, attempt) @@ -4097,6 +4098,7 @@ export const createWorktreeSlice: StateCreator ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), ...(startupDraft ? { startupDraft } : {}), + ...(startupSessionOptions ? { startupSessionOptions } : {}), ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), ...(startup ? { diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 53df967a838..614f5434e02 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -48,6 +48,12 @@ export function resolveAgentSessionOptionLaunch( modelOptions.flatMap((option) => { const explicitValue = values[option.id] if (explicitValue !== undefined) { + if ( + (option.kind.type === 'boolean' && typeof explicitValue !== 'boolean') || + (option.kind.type === 'select' && typeof explicitValue !== 'string') + ) { + return [] + } if ( !model && option.kind.type === 'select' && diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index cd47d81b971..afcf42f7a1e 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -54,6 +54,24 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' }) }) + it('quotes overridden task preferences for a Windows host shell', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'win32', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: + '-m host-model -c model_reasoning_effort=low --dangerously-bypass-approvals-and-sandbox' + }) + expect(plan?.launchCommand).toBe( + "codex '--dangerously-bypass-approvals-and-sandbox' '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=high'" + ) + expect(plan?.sessionOptions).toEqual({ model: 'gpt-5.6-sol', effort: 'high' }) + }) + it('inserts worker preferences before an argument terminator', () => { const plan = buildAgentStartupPlan({ agent: 'codex', @@ -140,6 +158,21 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' }) }) + it('lets host-built drafts override host defaults with client preferences', () => { + const plan = buildAgentDraftLaunchPlan({ + agent: 'claude', + draft: 'review this', + cmdOverrides: {}, + platform: 'linux', + sessionOptions: { model: 'opus', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '--model haiku --effort low' + }) + expect(plan?.launchCommand).toContain("claude '--model' 'opus' '--effort' 'high'") + expect(plan?.launchCommand).not.toContain('haiku') + expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' }) + }) + it('never injects session options into resume commands', () => { const plan = buildAgentResumeStartupPlan({ agent: 'codex', @@ -151,4 +184,21 @@ describe('tui agent startup session options', () => { expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'") expect(plan?.sessionOptions).toBeUndefined() }) + + it('never acknowledges an option whose value has the wrong catalog type', () => { + const resolved = resolveAgentLaunchCommand({ + agent: 'cursor', + cmdOverrides: {}, + platform: 'linux', + shell: 'posix', + sessionOptions: { model: 'gpt-5.3-codex', fastMode: 'false' }, + sessionOptionsOverrideAgentArgs: true + }) + + expect(resolved.ok).toBe(true) + if (resolved.ok) { + expect(resolved.command).not.toContain('-fast') + expect(resolved.appliedSessionOptions).toEqual({ model: 'gpt-5.3-codex' }) + } + }) }) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 27d4b40ac8d..ea86af1d09e 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,4 +1,4 @@ -import { isShellProcess } from './agent-detection' +export { isShellProcess } from './agent-detection' import { getAgentResumeArgv, type AgentProviderSessionMetadata, @@ -256,6 +256,7 @@ export function buildAgentDraftLaunchPlan(args: { agentArgs?: string | null agentEnv?: Record | null sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ isRemote?: boolean }): AgentDraftLaunchPlan | null { @@ -273,6 +274,7 @@ export function buildAgentDraftLaunchPlan(args: { shell, agentArgs: args.agentArgs, sessionOptions: args.sessionOptions, + sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs, isRemote: args.isRemote }) if (!baseCommand.ok) { @@ -316,7 +318,6 @@ export function buildAgentDraftLaunchPlan(args: { return plan } -export { isShellProcess } export { buildShellCommandFromArgv, planAgentCliArgsSuffix, diff --git a/src/shared/types.ts b/src/shared/types.ts index c6dfff01d3c..9bc4751d6b3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -48,7 +48,10 @@ import type { } from './project-execution-runtime' import type { UsagePercentageDisplay } from './usage-percentage-display' import type { StatusBarUsageMode } from './status-bar-usage-mode' -import type { PersistedNativeChatSessionOptions } from './native-chat-session-options' +import type { + PersistedNativeChatSessionOptions, + SessionOptionValue +} from './native-chat-session-options' import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger' import type { TaskSourceContext } from './task-source-context' import type { SetupRunnerShell } from './setup-runner-command' @@ -2326,6 +2329,8 @@ export type CreateWorktreeResult = { paneKey?: string | null ptyId?: string | null surface?: 'visible' | 'background' + /** Host-confirmed agent and picker values emitted into the startup command. */ + appliedSessionOptions?: AgentLaunchSessionOptions } timing?: WorktreeCreateTiming } @@ -2627,6 +2632,11 @@ export type TuiAgent = | 'trae' // Trae CLI | 'prime-agent' // Prime Agent (Prime Intellect) +export type AgentLaunchSessionOptions = { + agent: TuiAgent + values: Record +} + export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs' /** Where the repo setup script runs when a worktree is created.