diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs
index b77b103f2c3..5a03d8192b9 100644
--- a/config/scripts/pr-unit-support-routing.test.mjs
+++ b/config/scripts/pr-unit-support-routing.test.mjs
@@ -6,7 +6,7 @@ import {
collectUnitDependencyGraph,
unitConsumers
} from './ci-unit-dependency-graph.mjs'
-import { discoverUnitFiles } from './ci-unit-files.mjs'
+import { CROSS_VERSION_WIRE_DIR, discoverUnitFiles } from './ci-unit-files.mjs'
import { planUnitSelection } from './ci-unit-selection.mjs'
const harness = 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts'
@@ -34,7 +34,11 @@ const supportBridges = new Set([
function productConsumers(file, graph, unitFiles) {
return [...unitConsumers([file], graph.reverse)].filter(
- (consumer) => consumer !== file && !unitFiles.has(consumer) && !supportBridges.has(consumer)
+ (consumer) =>
+ consumer !== file &&
+ !unitFiles.has(consumer) &&
+ !(consumer.startsWith(CROSS_VERSION_WIRE_DIR) && consumer.endsWith('.unit.test.ts')) &&
+ !supportBridges.has(consumer)
)
}
diff --git a/mobile/src/session/mobile-existing-agent-launch.test.ts b/mobile/src/session/mobile-existing-agent-launch.test.ts
index a26c8d97f42..0b058046afe 100644
--- a/mobile/src/session/mobile-existing-agent-launch.test.ts
+++ b/mobile/src/session/mobile-existing-agent-launch.test.ts
@@ -209,8 +209,11 @@ describe('launchAgentInExistingWorkspace', () => {
})
it('treats a refusal of the first send as an unsupported host', async () => {
- const { client } = scriptedClient(refused('agent_launch_replay_unsupported'))
- await expect(launch(client)).resolves.toEqual({ kind: 'unsupported' })
+ const { client, sendRequest } = scriptedClient(refused('agent_launch_replay_unsupported'))
+ await expect(
+ launch(client, { chatOptions: { model: 'opus', fastMode: 'false' } })
+ ).resolves.toEqual({ kind: 'unsupported' })
+ expect(sendRequest.mock.calls[0]?.[1]).not.toHaveProperty('chatOptions')
})
it('never reads a refusal after a replay as unsupported: the first attempt may have run', async () => {
@@ -218,10 +221,23 @@ describe('launchAgentInExistingWorkspace', () => {
{ throws: markRpcDeliveryUnknown(new Error('Connection interrupted')) },
refused('agent_launch_replay_unsupported')
)
- await expect(launch(client)).resolves.toEqual({
+ const chatOptions = { model: 'opus', fastMode: 'false' }
+ const capabilities = [...LAUNCH_CAPABILITIES, 'agent.launch.chat-options.v1']
+ const pending = launch(client, { hostCapabilities: capabilities, chatOptions })
+ chatOptions.model = 'changed-after-send'
+ capabilities.length = 0
+ await expect(pending).resolves.toEqual({
kind: 'unknown',
message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE
})
+ expect(sendRequest.mock.calls.map(([, params]) => requestParam(params, 'chatOptions'))).toEqual(
+ [
+ { model: 'opus', fastMode: 'false' },
+ { model: 'opus', fastMode: 'false' }
+ ]
+ )
+ expect(sendRequest.mock.calls[0]?.[1]).toBe(sendRequest.mock.calls[1]?.[1])
+ // The replay reuses the same operation id.
expect(sendRequest.mock.calls.map(([, params]) => requestParam(params, 'operationId'))).toEqual(
['op-1', 'op-1']
)
diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts
index fe4d102df3c..61e5dd45b23 100644
--- a/mobile/src/session/mobile-existing-agent-launch.ts
+++ b/mobile/src/session/mobile-existing-agent-launch.ts
@@ -1,5 +1,6 @@
// Send launch intent to the host, which selects terminal versus structured execution and prompt delivery.
+import { AGENT_LAUNCH_CHAT_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability'
import type {
AgentLaunchPlacement,
AgentLaunchPrompt,
@@ -76,6 +77,9 @@ export async function launchAgentInExistingWorkspace(args: {
worktreeId: string
agent: TuiAgent
prompt?: AgentLaunchPrompt
+ /** Model and effort the host applies on whichever route it picks. */
+ sessionOptions?: Readonly>
+ chatOptions?: Readonly>
launchSource?: string
reservation?: MobileAgentLaunchReservation
placement?: AgentLaunchPlacement
@@ -89,6 +93,11 @@ export async function launchAgentInExistingWorkspace(args: {
worktreeId: args.worktreeId,
operationId: (args.mintOperationId ?? structuredSessionOperationId)(),
...(args.prompt ? { prompt: args.prompt } : {}),
+ ...(args.sessionOptions ? { sessionOptions: args.sessionOptions } : {}),
+ ...(args.chatOptions &&
+ args.hostCapabilities?.includes(AGENT_LAUNCH_CHAT_OPTIONS_RUNTIME_CAPABILITY)
+ ? { chatOptions: args.chatOptions }
+ : {}),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.placement ? { placement: args.placement } : {}),
...(args.reservation
diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts
index a49c8498e29..5869de97c06 100644
--- a/mobile/src/session/pr-ai-triage-launch.ts
+++ b/mobile/src/session/pr-ai-triage-launch.ts
@@ -11,6 +11,7 @@ import {
loadMobileAgentLaunchContext
} from './mobile-new-tab-agent-loader'
import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-launch-agent'
+import { resolveSourceControlActionSessionOptions } from '../../../src/shared/source-control-action-launch-preferences'
// Have the host launch and deliver AI prompts; mobile must never type them into a shell.
@@ -68,11 +69,15 @@ export async function launchAgentWithPrompt(args: {
"This action's saved prompt is empty. Update Source Control AI settings on your computer."
}
}
+ const sessionOptions = recipe
+ ? resolveSourceControlActionSessionOptions(recipe, resolved.agent)
+ : undefined
const launched = await launchAgentInExistingWorkspace({
client: args.client,
hostCapabilities: args.hostCapabilities,
worktreeId: args.worktreeId,
agent: resolved.agent,
+ ...(sessionOptions ? { chatOptions: sessionOptions } : {}),
// Leave agent arguments to host defaults because applicability depends on the host-selected route and shell.
prompt: { text, delivery: 'submit' },
launchSource: args.launchSource
diff --git a/mobile/src/tasks/agent-launch-request.test.ts b/mobile/src/tasks/agent-launch-request.test.ts
index 33cc7f53082..3acc981b292 100644
--- a/mobile/src/tasks/agent-launch-request.test.ts
+++ b/mobile/src/tasks/agent-launch-request.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
agentLaunchCreateParams,
+ agentLaunchExistingParams,
isAgentLaunchUnsupportedRefusal,
readAgentLaunchCreateOutcome
} from './agent-launch-request'
@@ -36,6 +37,23 @@ describe('agentLaunchCreateParams', () => {
})
})
+describe('agentLaunchExistingParams', () => {
+ const launch = { agent: 'codex', worktreeId: 'wt-1', operationId: 'op-1' } as const
+
+ it("sends an action's model and effort as session options", () => {
+ expect(
+ agentLaunchExistingParams({
+ ...launch,
+ sessionOptions: { model: 'gpt-5.6-terra', effort: 'high' }
+ }).sessionOptions
+ ).toEqual({ model: 'gpt-5.6-terra', effort: 'high' })
+ })
+
+ it('omits session options when the action saved no model', () => {
+ expect(agentLaunchExistingParams(launch)).not.toHaveProperty('sessionOptions')
+ })
+})
+
describe('readAgentLaunchCreateOutcome', () => {
it.each([
{
diff --git a/mobile/src/tasks/agent-launch-request.ts b/mobile/src/tasks/agent-launch-request.ts
index 0b3ad2e25c5..0de11aa3ae7 100644
--- a/mobile/src/tasks/agent-launch-request.ts
+++ b/mobile/src/tasks/agent-launch-request.ts
@@ -61,12 +61,16 @@ export function agentLaunchExistingParams(args: {
paneKey?: string
sessionId?: string
placement?: AgentLaunchPlacement
+ sessionOptions?: Readonly>
+ chatOptions?: Readonly>
}): RpcSendParams<'agent.launchReplay'> {
return {
agent: args.agent,
operationId: args.operationId,
target: { kind: 'existing', worktree: `id:${args.worktreeId}` },
...(args.prompt ? { prompt: args.prompt } : {}),
+ ...(args.sessionOptions ? { sessionOptions: { ...args.sessionOptions } } : {}),
+ ...(args.chatOptions ? { chatOptions: { ...args.chatOptions } } : {}),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.paneKey ? { paneKey: args.paneKey } : {}),
...(args.sessionId ? { sessionId: args.sessionId } : {}),
diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts
index c506e58ae44..a98cf0dba37 100644
--- a/src/main/agent-launch/agent-launch-executor.test.ts
+++ b/src/main/agent-launch/agent-launch-executor.test.ts
@@ -375,10 +375,17 @@ describe("a caller's chat default in place of the host's", () => {
describe('a launch into a workspace that already exists', () => {
it('opens a session without creating anything', async () => {
const h = harness({})
- const result = await h.run({ agent: 'codex', target: { kind: 'existing', worktree: 'wt-7' } })
+ const result = await h.run({
+ agent: 'codex',
+ target: { kind: 'existing', worktree: 'wt-7' },
+ chatOptions: { model: 'action-model', fastMode: 'false' }
+ })
expect(h.calls).toEqual(['createSupport', 'createStructuredSession'])
expect(h.createWorktree).not.toHaveBeenCalled()
expect(result.worktreeId).toBe('wt-7')
+ expect(h.createStructuredSession).toHaveBeenCalledWith(
+ expect.objectContaining({ chatOptions: { model: 'action-model', fastMode: 'false' } })
+ )
})
it.each(['claude', 'grok'] as const)(
@@ -400,7 +407,16 @@ describe('a launch into a workspace that already exists', () => {
describe('an agent with no structured session', () => {
it('stays a terminal without asking the host', async () => {
const h = harness({})
- const result = await h.run({ agent: 'gemini', target: { kind: 'existing', worktree: 'wt-7' } })
+ const result = await h.run({
+ agent: 'gemini',
+ target: { kind: 'existing', worktree: 'wt-7' },
+ chatOptions: { model: 'action-model', fastMode: 'false' }
+ })
+ expect(h.createTerminalAgent.mock.calls[0]?.[0]).not.toHaveProperty('chatOptions')
+ // The saved choice reaches the terminal too; its converter keeps only what has a flag.
+ expect(h.createTerminalAgent.mock.calls[0]?.[0]).toMatchObject({
+ options: { model: 'action-model', fastMode: 'false' }
+ })
expect(h.calls).toEqual(['createTerminalAgent'])
expect(result.receipt).toMatchObject({ reason: 'agent_without_structured_session' })
})
diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts
index c98f4176967..7daa0d01edd 100644
--- a/src/main/agent-launch/agent-launch-executor.ts
+++ b/src/main/agent-launch/agent-launch-executor.ts
@@ -1,6 +1,7 @@
-/** Ask after workspace creation: the host needs resolved metadata to decide chat support. */
+import { decideAgentLaunchIntentMode } from './agent-launch-intent-mode'
+/** Structured launches create the workspace without an agent, then ask its host for chat support.
+ * The host needs the created workspace to answer; a definitive refusal starts a terminal agent. */
-import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
import { assertAgentPromptRequestActive } from '../runtime/orca-runtime-core'
import { parsePaneKey } from '../../shared/stable-pane-id'
import type { AgentLaunchResult } from '../../shared/agent-launch-intent'
@@ -18,11 +19,8 @@ import {
settledAtCreation,
settleLaunchPromptDisposal
} from './agent-launch-prompt-delivery'
-import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind'
import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal'
import {
- decideAgentLaunchMode,
- readAgentLaunchModeSettings,
resolveAgentLaunchModeOnHost,
type AgentLaunchModeReceipt,
type AgentLaunchModeSettlement,
@@ -37,6 +35,7 @@ import {
} from './agent-launch-legacy-host'
import {
AgentLaunchStructuredSessionRefusedError,
+ assertTerminalLaunchAdmissible,
terminalLaunchInputs,
type AgentLaunchStructuredSurface,
type AgentLaunchWorkspaceFactory
@@ -53,31 +52,10 @@ export async function executeAgentLaunch(
execution: AgentLaunchExecution
): Promise {
const { intent, runtime } = execution
- if (intent.reuseTerminal || intent.target.kind === 'create-worktree') {
- assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, intent.sessionOptions)
- }
const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
- const preflight =
- execution.decidedMode ??
- decideAgentLaunchMode({
- placement: {
- agent: intent.agent,
- workspaceKind: workspaceKindForLaunchTarget(intent.target),
- ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}),
- ...(intent.cwd ? { cwd: intent.cwd } : {}),
- ...(intent.target.kind === 'existing' && intent.target.workspacePath
- ? { workspacePath: intent.target.workspacePath }
- : {}),
- ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
- },
- settings: execution.terminalOnly
- ? undefined
- : readAgentLaunchModeSettings(runtime, intent.nativeChatDefault),
- ...(execution.terminalOnly ? { terminalOnly: true } : {}),
- ...(intent.surface ? { surface: intent.surface } : {}),
- vocabulary
- })
- // Intent preparation may have outlived the request.
+ const preflight = execution.decidedMode ?? decideAgentLaunchIntentMode(execution)
+ assertTerminalLaunchAdmissible(intent, preflight.mode)
+ // A caller's own preparation (intent resolution) may have outlived its request.
assertAgentPromptRequestActive(execution.signal)
if (execution.promptPolicy === 'legacy-host') {
assertLegacyHostTarget(execution)
@@ -271,6 +249,7 @@ async function createSurface(
worktreeId: workspace.worktreeId,
agent: intent.agent,
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
+ ...(intent.chatOptions ? { chatOptions: intent.chatOptions } : {}),
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
...(reservedTabId ? { tabId: reservedTabId } : {}),
...launchPromptCreateInputs(intent, execution.signal),
diff --git a/src/main/agent-launch/agent-launch-intent-mode.ts b/src/main/agent-launch/agent-launch-intent-mode.ts
new file mode 100644
index 00000000000..5102b9f1bae
--- /dev/null
+++ b/src/main/agent-launch/agent-launch-intent-mode.ts
@@ -0,0 +1,38 @@
+import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
+import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind'
+import {
+ decideAgentLaunchMode,
+ readAgentLaunchModeSettings,
+ DEFAULT_LAUNCH_VOCABULARY,
+ type AgentLaunchModeReceipt,
+ type AgentLaunchModeVocabulary
+} from './agent-launch-mode'
+
+/** The same preflight is used for admission and execution, before workspace creation. */
+export function decideAgentLaunchIntentMode(execution: {
+ intent: AgentLaunchIntent
+ runtime: Parameters[0]
+ terminalOnly?: boolean
+ callerRendersStructured?: boolean
+ vocabulary?: AgentLaunchModeVocabulary
+}): AgentLaunchModeReceipt {
+ const { intent, runtime } = execution
+ return decideAgentLaunchMode({
+ placement: {
+ agent: intent.agent,
+ workspaceKind: workspaceKindForLaunchTarget(intent.target),
+ ...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}),
+ ...(intent.cwd ? { cwd: intent.cwd } : {}),
+ ...(intent.target.kind === 'existing' && intent.target.workspacePath
+ ? { workspacePath: intent.target.workspacePath }
+ : {}),
+ ...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
+ },
+ settings: execution.terminalOnly
+ ? undefined
+ : readAgentLaunchModeSettings(runtime, intent.nativeChatDefault),
+ ...(execution.terminalOnly ? { terminalOnly: true } : {}),
+ ...(intent.surface ? { surface: intent.surface } : {}),
+ vocabulary: execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
+ })
+}
diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts
index 38885d6c263..a6a7ae977e8 100644
--- a/src/main/agent-launch/agent-launch-opencode-model.test.ts
+++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts
@@ -1,10 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
+import recordings from '../../shared/fixtures/source-control-launch-recordings.json'
import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
import { executeAgentLaunch } from './agent-launch-executor'
+import { toAgentLaunchPreferences } from '../../shared/agent-launch-preferences'
+import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
+import { agentPermissionChoices } from '../../shared/tui-agent-permission-choice'
function harness() {
const readSettings = vi.fn(() => {
- throw new Error('settings_not_needed_for_refusal')
+ throw new Error('settings_unavailable')
})
const createSupport = vi.fn(async () => {
throw new Error('support_not_needed_for_refusal')
@@ -59,12 +63,39 @@ const creating: AgentLaunchIntent = {
}
describe('OpenCode model preferences on unsupported launch placements', () => {
+ it('keeps own selectable without projecting a meaningless permission into either terminal guard', async () => {
+ const sessionOptions = { permissionMode: 'own' }
+ const projected = toAgentLaunchPreferences(sessionOptions, 'opencode')
+ expect(agentPermissionChoices('opencode')).toContain('own')
+ expect(projected).toBeUndefined()
+ // Workspace creation consumes this projection through the same unchanged broad guard.
+ expect(() => assertOpenCodeModelLaunchPreferencesAbsent('opencode', projected)).not.toThrow()
+ for (const intent of [reused, creating]) {
+ const h = harness()
+ await expect(h.run({ ...intent, sessionOptions })).resolves.toBeDefined()
+ }
+ })
+
+ it('refuses the real OpenCode model readback on a reused terminal before prompt delivery', async () => {
+ const model = recordings.opencode.update.message.params.update.configOptions.find(
+ (option) => option.category === 'model'
+ )?.currentValue
+ expect(model).toBe('opencode/big-pickle')
+ const h = harness()
+ await expect(h.run({ ...reused, sessionOptions: { model } })).rejects.toMatchObject({
+ code: 'capability_unsupported'
+ })
+ expect(h.createTerminal).not.toHaveBeenCalled()
+ expect(h.createWorktree).not.toHaveBeenCalled()
+ expect(h.deliverPrompt).not.toHaveBeenCalled()
+ })
+
it.each([reused, creating])(
- 'refuses before reading settings or creating or delivering',
+ 'preserves definitive refusal without creating or delivering when the route settings read throws',
async (intent) => {
const h = harness()
await expect(h.run(intent)).rejects.toMatchObject({ code: 'capability_unsupported' })
- expect(h.readSettings).not.toHaveBeenCalled()
+ expect(h.readSettings).toHaveBeenCalledOnce()
expect(h.createTerminal).not.toHaveBeenCalled()
expect(h.createWorktree).not.toHaveBeenCalled()
expect(h.deliverPrompt).not.toHaveBeenCalled()
diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts
index f623bcafda3..0bafb03838f 100644
--- a/src/main/agent-launch/agent-launch-surface-factories.ts
+++ b/src/main/agent-launch/agent-launch-surface-factories.ts
@@ -1,13 +1,27 @@
-import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
+/**
+ * The factories `executeAgentLaunch` is handed: how a surface and a workspace are BUILT once the
+ * executor has decided which. Separate from the executor because they are the contract each caller
+ * implements, not part of the sequencing it runs.
+ */
+
+import type {
+ AgentLaunchIntent,
+ AgentLaunchPrompt,
+ AgentLaunchMode
+} from '../../shared/agent-launch-intent'
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create'
import type { TuiAgent } from '../../shared/tui-agent'
+import type { AgentLaunchParams } from '../../shared/rpc-contract/agent-launch-params'
+import { toAgentLaunchPreferences } from '../../shared/agent-launch-preferences'
+import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
/** Inject creation so orchestration can add subscriptions without duplicating launch sequencing. */
export type AgentLaunchSurfaceFactory = {
createStructuredSession(args: {
worktreeId: string
agent: StructuredAgentId
+ chatOptions?: Readonly>
options?: Readonly>
/** Caller-minted ID; reject with AgentLaunchSessionAlreadyExistsError if taken. */
sessionId?: string
@@ -111,10 +125,31 @@ export type AgentLaunchWorkspaceFactory = {
}): Promise<{ worktreeId: string; workspacePath?: string; connectionId: string | null }>
}
+/** The options a terminal leg starts with: a caller's saved chat choice applies to it too. */
+function terminalLaunchOptions(intent: Pick) {
+ return intent.sessionOptions ?? intent.chatOptions
+}
+
+/** Refuses, before anything exists, an OpenCode terminal its host cannot verify, read as that
+ * terminal will receive it, after the shared preflight determines the actual route. */
+export function assertTerminalLaunchAdmissible(
+ intent: AgentLaunchIntent | AgentLaunchParams,
+ mode: AgentLaunchMode
+): void {
+ if (intent.reuseTerminal || intent.target.kind === 'create-worktree') {
+ const options = mode === 'terminal' ? terminalLaunchOptions(intent) : intent.sessionOptions
+ const preferences = toAgentLaunchPreferences(options, intent.agent)
+ assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, preferences)
+ }
+}
+
+/** What every route that builds a terminal agent passes on, so the startup terminal of a new
+ * workspace and the terminal of an existing one start the same agent. */
export function terminalLaunchInputs(intent: AgentLaunchIntent) {
return {
- ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
- // Null explicitly suppresses default arguments.
+ // Projected for this agent by each terminal builder, which keeps only what has a flag.
+ options: terminalLaunchOptions(intent),
+ // `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 } : {}),
diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts
index 62ad8138f8b..fcc3954cfcf 100644
--- a/src/main/codex/codex-structured-launch-resolution.ts
+++ b/src/main/codex/codex-structured-launch-resolution.ts
@@ -4,7 +4,7 @@ import { resolveCodexCommand } from '../codex-cli/command'
import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import {
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV,
diff --git a/src/main/codex/codex-structured-real-cli-command-env.test.ts b/src/main/codex/codex-structured-real-cli-command-env.test.ts
index 73e07255523..a7ca627b5fd 100644
--- a/src/main/codex/codex-structured-real-cli-command-env.test.ts
+++ b/src/main/codex/codex-structured-real-cli-command-env.test.ts
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
import { resolveCodexCommand } from '../codex-cli/command'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { openCodexThread } from './codex-structured-thread-open'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
// The probe is a POSIX shell command.
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' && process.platform !== 'win32'
diff --git a/src/main/codex/codex-structured-real-cli-permissions.test.ts b/src/main/codex/codex-structured-real-cli-permissions.test.ts
index 43516a3c03b..a8a15dec4e9 100644
--- a/src/main/codex/codex-structured-real-cli-permissions.test.ts
+++ b/src/main/codex/codex-structured-real-cli-permissions.test.ts
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import { resolveCodexCommand } from '../codex-cli/command'
import { openCodexAppServerConnection } from './codex-app-server-connection'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { openCodexThread } from './codex-structured-thread-open'
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1'
diff --git a/src/main/codex/codex-structured-real-cli-visuals.test.ts b/src/main/codex/codex-structured-real-cli-visuals.test.ts
index 7bbc3f18390..e16f7d35701 100644
--- a/src/main/codex/codex-structured-real-cli-visuals.test.ts
+++ b/src/main/codex/codex-structured-real-cli-visuals.test.ts
@@ -6,7 +6,7 @@ import { resolveCodexCommand } from '../codex-cli/command'
import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { withCodexVisualsThreadConfig } from './codex-structured-visuals'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1'
const WORKSPACE_POLICY = codexStructuredPermissionPolicy('auto')
diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts
index 8bc6ae172c9..b17e19727c1 100644
--- a/src/main/codex/codex-structured-session-state.ts
+++ b/src/main/codex/codex-structured-session-state.ts
@@ -23,7 +23,7 @@ import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker
import type { CodexSkillReport } from './codex-structured-skill-report'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
-import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import type { CodexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition'
import type {
AgentModelCatalogSessionAccess,
diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts
index 9b1610ddfa7..5c9f91ddce2 100644
--- a/src/main/codex/codex-structured-thread-open.test.ts
+++ b/src/main/codex/codex-structured-thread-open.test.ts
@@ -4,7 +4,7 @@ import { CodexAppServerFrameSizeError } from './app-server/codex-app-server-fram
import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { openCodexThread } from './codex-structured-thread-open'
function connectionFor(
diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts
index 6bb6d5abc68..542ba54cbab 100644
--- a/src/main/codex/codex-structured-thread-open.ts
+++ b/src/main/codex/codex-structured-thread-open.ts
@@ -3,7 +3,7 @@ import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-s
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
-import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import type { CodexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { readCodexThreadId } from './codex-structured-thread-facts'
import { readCodexSandboxPolicy, type CodexSandboxPolicy } from './codex-structured-sandbox-policy'
diff --git a/src/main/codex/codex-structured-turn-permission.ts b/src/main/codex/codex-structured-turn-permission.ts
index bd919e15d69..7332c1b609f 100644
--- a/src/main/codex/codex-structured-turn-permission.ts
+++ b/src/main/codex/codex-structured-turn-permission.ts
@@ -8,7 +8,7 @@ import {
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
import type { CodexSession } from './codex-structured-session-state'
import { readCodexAutoSandbox } from './codex-structured-auto-sandbox'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import type { CodexSandboxPolicy } from './codex-structured-sandbox-policy'
type PermissionHost = {
diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts
index d4d78e0bf25..7c745ce4525 100644
--- a/src/main/codex/codex-structured-visuals.test.ts
+++ b/src/main/codex/codex-structured-visuals.test.ts
@@ -19,7 +19,7 @@ import {
CODEX_VISUALS_SETUP_BUDGET_MS,
prepareCodexThreadForVisuals
} from './codex-structured-visuals'
-import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
const VISUALS: NativeChatVisualsLaunch = {
folder: '/state/chat-scratch/abc',
diff --git a/src/main/codex/codex-structured-visuals.ts b/src/main/codex/codex-structured-visuals.ts
index e66e39bfccf..5b123a75428 100644
--- a/src/main/codex/codex-structured-visuals.ts
+++ b/src/main/codex/codex-structured-visuals.ts
@@ -3,7 +3,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { isCodexMethodNotFoundError } from './codex-app-server-session'
-import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
+import type { CodexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
/** Bound local setup requests so optional visuals do not delay the first prompt indefinitely. */
export const CODEX_VISUALS_SETUP_BUDGET_MS = 2_000
diff --git a/src/main/external-conversations/external-conversation-start.test.ts b/src/main/external-conversations/external-conversation-start.test.ts
new file mode 100644
index 00000000000..e31f4d048c4
--- /dev/null
+++ b/src/main/external-conversations/external-conversation-start.test.ts
@@ -0,0 +1,50 @@
+import { expect, it, vi } from 'vitest'
+import type { OrcaRuntimeService } from '../runtime/orca-runtime'
+import { runtimeConversationLauncher } from './external-conversation-start'
+
+it('starts the requested connector agent in a background terminal even with chat enabled', async () => {
+ const createTerminal = vi.fn(
+ async (_workspace: string, options: { onStartupPromptCarry?: (carried: boolean) => void }) => {
+ options.onStartupPromptCarry?.(true)
+ return { handle: 'connector-terminal' }
+ }
+ )
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real launcher reaches only these runtime I/O boundaries; no agent process is started.
+ const runtime = {
+ showTerminalWorkspaceLaunchScope: vi.fn(async () => ({
+ id: 'workspace-1',
+ path: '/repo/task',
+ connectionId: null
+ })),
+ requireStore: () => ({ getSettings: () => ({ experimentalNativeChat: true }) }),
+ getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
+ createTerminal
+ } as unknown as OrcaRuntimeService
+ const published = vi.fn()
+
+ const result = await runtimeConversationLauncher(runtime).launch(
+ {
+ agent: 'codex',
+ target: { kind: 'existing', worktree: 'id:workspace-1' },
+ prompt: 'fix the test'
+ },
+ published,
+ new AbortController().signal
+ )
+
+ expect(createTerminal).toHaveBeenCalledExactlyOnceWith(
+ 'id:workspace-1',
+ expect.objectContaining({
+ startupAgent: 'codex',
+ startupPrompt: 'fix the test',
+ presentation: 'background'
+ })
+ )
+ expect(result).toMatchObject({
+ worktreeId: 'workspace-1',
+ outcome: { kind: 'terminal', handle: 'connector-terminal' },
+ receipt: { mode: 'terminal', preferred: 'terminal' },
+ prompt: { outcome: 'handed-to-terminal' }
+ })
+ expect(published).toHaveBeenCalledExactlyOnceWith(result)
+})
diff --git a/src/main/external-conversations/external-conversation-start.ts b/src/main/external-conversations/external-conversation-start.ts
index bc4c7b192c0..2780178a019 100644
--- a/src/main/external-conversations/external-conversation-start.ts
+++ b/src/main/external-conversations/external-conversation-start.ts
@@ -34,15 +34,16 @@ export function runtimeConversationLauncher(runtime: OrcaRuntimeService): Conver
const context = { runtime }
return {
launch: async ({ agent, target, prompt }, onSurfacePublished, signal) => {
- const intent = await resolveUnlaunchedIntent(
+ const { intent, decidedMode } = await resolveUnlaunchedIntent(
{ agent, target, prompt: { text: prompt, delivery: 'submit' } },
runtime,
- null
+ null,
+ { terminalOnly: true }
)
return executeAgentLaunch({
runtime,
intent,
- terminalOnly: true,
+ decidedMode,
surfaces: agentLaunchSurfaceFactory(context, undefined, undefined, false, undefined, {
early: null,
presentation: 'background'
diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts
index f12a2195e5a..b77fc002f47 100644
--- a/src/main/ipc/pty/ipc/spawn-agent-launch.ts
+++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts
@@ -109,11 +109,13 @@ export async function planPaneAgentLaunch(ctx: PtyIpcSpawnState): Promise
}
}
}
- const intent = await resolveUnlaunchedIntent(params.data, runtime, null)
+ const { intent } = await resolveUnlaunchedIntent(params.data, runtime, null, {
+ terminalOnly: true
+ })
if (intent.target.kind !== 'existing') {
throw new Error('agent_launch_request_invalid')
}
- const launchPreferences = toAgentLaunchPreferences(intent.sessionOptions)
+ const launchPreferences = toAgentLaunchPreferences(intent.sessionOptions, intent.agent)
const planned = await runtime.planAgentPaneLaunch(intent.target.worktree, {
startupAgent: intent.agent,
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts
index 3be7b4aba81..c2843976bab 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts
@@ -11,7 +11,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error'
-import { codexStructuredPermissionPolicy } from '../../codex/codex-structured-permission-policy'
+import { codexStructuredPermissionPolicy } from '../../../shared/codex-permission-policy'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
import { agentSessionSendSubmission as submission } from '../../../shared/agent-session-wire'
diff --git a/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts b/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts
index 3a4d138b486..a9636894b59 100644
--- a/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts
+++ b/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts
@@ -107,6 +107,47 @@ describe('Store', () => {
expect(store.getRepos()).toHaveLength(1)
})
+ it('keeps saved action choices and sparse repo args through old-profile migration and re-read', async () => {
+ const recipe = {
+ agentId: 'codex',
+ commandInputTemplate: '{basePrompt}',
+ agentArgs: '',
+ launchPreferences: {
+ agentId: 'codex',
+ model: 'gpt-6-sol',
+ permissionMode: 'ask',
+ options: { thinkingEffort: 'high' }
+ }
+ }
+ const sparse = { agentArgs: '--model gpt-6-sol' }
+ const repo = makeRepo()
+ writeDataFile({
+ schemaVersion: 1,
+ repos: [{ ...repo, sourceControlAi: { actionOverrides: { resolveConflicts: sparse } } }],
+ worktreeMeta: {},
+ settings: {
+ defaultTuiAgent: 'claude',
+ sourceControlAi: { actions: { fixCommitFailure: recipe } }
+ },
+ ui: {},
+ githubCache: { pr: {}, issue: {} },
+ workspaceSession: {}
+ })
+
+ const store = await createStore()
+ expect(store.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe)
+ expect(store.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual(sparse)
+ store.updateSettings({ theme: 'light' })
+ store.flush()
+ await closeTestStores()
+
+ const reopened = await createStore()
+ expect(reopened.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe)
+ expect(reopened.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual(
+ sparse
+ )
+ })
+
it('migrates legacy commit-message AI settings to source-control AI on load', async () => {
writeDataFile({
schemaVersion: 1,
diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts
index 23dfd78f3f0..03065fdd6ab 100644
--- a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts
+++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts
@@ -1,3 +1,4 @@
+import { migrateActionPermissionArgs } from '../../../shared/source-control-action-permission-lift'
import { homedir } from 'node:os'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type {
@@ -89,12 +90,25 @@ export function prepareLoadedTerminalSettings(
markNeedsSave()
}
const legacyCommitMessageAi = parsed.settings?.commitMessageAi
- const migratedSourceControlAi = rawSourceControlAiMissing
+ let migratedSourceControlAi = rawSourceControlAiMissing
? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi)
: mergeLegacyCommitMessageAiIntoSourceControlAi(
parsed.settings?.sourceControlAi,
legacyCommitMessageAi
)
+ const actions = migrateActionPermissionArgs(migratedSourceControlAi.actions)
+ if (actions !== migratedSourceControlAi.actions) {
+ migratedSourceControlAi = { ...migratedSourceControlAi, actions }
+ markNeedsSave()
+ }
+ migratedExternalVisibility.repos = migratedExternalVisibility.repos.map((repo) => {
+ const actionOverrides = migrateActionPermissionArgs(repo.sourceControlAi?.actionOverrides)
+ if (actionOverrides === repo.sourceControlAi?.actionOverrides) {
+ return repo
+ }
+ markNeedsSave()
+ return { ...repo, sourceControlAi: { ...repo.sourceControlAi, actionOverrides } }
+ })
// Legacy Option-as-Alt true broke non-US characters; let the layout probe choose once.
const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt
const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true
diff --git a/src/main/persistence/persistence-load-repair-durability.test.ts b/src/main/persistence/persistence-load-repair-durability.test.ts
index 3638e0011a5..f29931bf256 100644
--- a/src/main/persistence/persistence-load-repair-durability.test.ts
+++ b/src/main/persistence/persistence-load-repair-durability.test.ts
@@ -1,3 +1,9 @@
+import * as permissionLift from '../../shared/tui-agent-permission-lift'
+import * as terminalPreparation from './loading-store/prepare-loaded-terminal-settings'
+import * as domainWrites from './profile-state/profile-state-domain-writes'
+import * as permissionArgs from '../../shared/agent-permission-args'
+import * as shell from '../../shared/tui-agent-startup-shell'
+import { agentPermissionChoices } from '../../shared/tui-agent-permission-choice'
import {
closeTestStores,
testState,
@@ -80,9 +86,158 @@ describe('load-time normalization durability', () => {
afterEach(async () => {
await closeTestStores()
+ vi.restoreAllMocks()
rmSync(testState.dir, { recursive: true, force: true })
})
+ const loadWork: number[][] = []
+ it.each([32, 320])(
+ 'migrates %i owned rows through actual typed-profile load, flush and no-op reopen',
+ async (n) => {
+ const settled = await settledProfile()
+ await closeTestStores()
+ const literal = ' --yolo --search '
+ const conflict = {
+ agentId: 'claude',
+ agentArgs: ' --dangerously-skip-permissions ',
+ launchPreferences: { agentId: 'claude', permissionMode: 'ask' }
+ } as const
+ settled.settings.agentPermissionDefault = 'own'
+ const source = settled.settings.sourceControlAi
+ if (!source) {
+ throw new Error('Missing settled Source Control settings')
+ }
+ settled.settings.sourceControlAi = {
+ ...source,
+ actions: {
+ fixChecks: {
+ agentId: 'claude',
+ agentArgs: '--dangerously-skip-permissions --verbose',
+ launchPreferences: { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } }
+ },
+ resolveComments: { agentId: 'codex', agentArgs: literal },
+ resolveConflicts: { agentId: 'gemini', agentArgs: '--yolo' },
+ fixPushFailure: { agentArgs: ' --dangerously-skip-permissions ' },
+ fixCommitFailure: conflict,
+ commitMessage: { agentId: 'claude', agentArgs: '--dangerously-skip-permissions' }
+ }
+ }
+ settled.repos = Array.from({ length: n }, (_, i) => ({
+ id: `recipe-${i}`,
+ displayName: 'recipe',
+ badgeColor: '',
+ addedAt: 0,
+ path: '/tmp/recipe',
+ sourceControlAi: {
+ actionOverrides: {
+ fixChecks: {
+ agentId: i % 2 ? 'claude' : 'codex',
+ agentArgs: (i % 2
+ ? '--dangerously-skip-permissions'
+ : '--dangerously-bypass-approvals-and-sandbox'
+ ).padEnd(64)
+ },
+ resolveComments: { agentArgs: ' --dangerously-bypass-approvals-and-sandbox ' },
+ fixPushFailure: { agentId: null, agentArgs: literal }
+ }
+ }
+ }))
+ const lift = vi.spyOn(permissionLift, 'liftAgentPermissionArgs')
+ const cut = vi.spyOn(permissionArgs, 'permissionRecipeCut')
+ const tokenize = vi.spyOn(shell, 'tokenizeStartupCommand')
+ const prepare = terminalPreparation.prepareLoadedTerminalSettings
+ const marks = vi.fn()
+ vi.spyOn(terminalPreparation, 'prepareLoadedTerminalSettings').mockImplementation(
+ (parsed, mark) =>
+ prepare(parsed, () => {
+ marks()
+ mark()
+ })
+ )
+ writeDataFile(settled)
+ const store = createStore()
+ expect(lift).toHaveBeenCalledTimes(n + 4)
+ const work = [
+ cut.mock.calls.length,
+ tokenize.mock.calls.length,
+ tokenize.mock.calls.reduce((sum, [text]) => sum + text.length, 0)
+ ]
+ const choices = Math.max(
+ ...(['claude', 'codex'] as const).map((agent) => agentPermissionChoices(agent).length - 1)
+ )
+ expect(work[0]).toBeGreaterThanOrEqual(n)
+ // Per choice: recipe + choice inventory + three original/cut shell grammars.
+ expect(work[1]).toBeLessThanOrEqual((n + 4) * choices * (1 + choices + 6))
+ if (n === 320) {
+ work.forEach((count, index) => expect(count).toBeLessThanOrEqual(10 * loadWork[0][index]))
+ }
+ loadWork.push(work)
+ process.stdout.write(
+ `${JSON.stringify({
+ metric: 'permission-load-work',
+ rows: n,
+ lifts: lift.mock.calls.length,
+ cuts: work[0],
+ tokens: work[1],
+ chars: work[2]
+ })}\n`
+ )
+
+ expect(store.getSettings().sourceControlAi?.actions?.fixChecks).toMatchObject({
+ agentArgs: '--verbose',
+ launchPreferences: {
+ agentId: 'claude',
+ model: 'sonnet',
+ options: { effort: 'high' },
+ permissionMode: 'full-access'
+ }
+ })
+ store.flush()
+ await closeTestStores()
+ marks.mockClear()
+ const reopened = createStore()
+ expect(marks).not.toHaveBeenCalled()
+ for (const row of reopened.getRepos()) {
+ expect(row.sourceControlAi?.actionOverrides?.fixChecks).toMatchObject({
+ agentArgs: '',
+ launchPreferences: {
+ agentId: row.sourceControlAi?.actionOverrides?.fixChecks?.agentId,
+ permissionMode: 'full-access'
+ }
+ })
+ }
+ const write = vi.spyOn(domainWrites, 'writeProfileStateDomains')
+ const actions = reopened.getSettings().sourceControlAi?.actions
+ expect(actions?.fixChecks?.launchPreferences?.permissionMode).toBe('full-access')
+ expect(actions?.resolveConflicts).toMatchObject({
+ agentArgs: '',
+ launchPreferences: { agentId: 'gemini', permissionMode: 'full-access' }
+ })
+ expect(actions?.resolveComments?.agentArgs).toBe(literal)
+ expect(actions?.fixPushFailure?.agentArgs).toBe(' --dangerously-skip-permissions ')
+ expect(actions?.fixCommitFailure).toEqual(conflict)
+ expect(actions?.commitMessage?.agentArgs).toBe('--dangerously-skip-permissions')
+ const rows = reopened.getRepos()[0]?.sourceControlAi?.actionOverrides
+ expect(rows?.resolveComments).toEqual(
+ settled.repos[0]?.sourceControlAi?.actionOverrides?.resolveComments
+ )
+ expect(rows?.fixPushFailure).toEqual(
+ settled.repos[0]?.sourceControlAi?.actionOverrides?.fixPushFailure
+ )
+ const stable = readDataFile()
+ reopened.flush()
+ expect(readDataFile()).toEqual(stable)
+ expect(write).toHaveReturnedWith({
+ changed: false,
+ revision: expect.any(Number),
+ changedDomains: []
+ })
+ write.mockClear()
+ reopened.flush()
+ expect(write).not.toHaveBeenCalled()
+ }
+ )
+
it('persists the legacy search-tab explorer view repair', async () => {
const settled = await settledProfile()
delete (settled.ui as Record).rightSidebarExplorerView
diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts
index 7fba9b8ea3d..1834dc64c27 100644
--- a/src/main/preflight/workspace-agent-detection.test.ts
+++ b/src/main/preflight/workspace-agent-detection.test.ts
@@ -17,6 +17,7 @@ import {
} from '../../shared/protocol-version'
import {
resolveWorkspaceAgentDetectionHost,
+ detectProjectLaunchContext,
type WorkspaceAgentDetectionStore
} from './workspace-agent-detection'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
@@ -142,6 +143,22 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => {
_resetWslCachesForTests()
})
+ it('prospective detection owns the project runtime before a workspace exists', async () => {
+ detectInstalledMock.mockResolvedValueOnce(['codex'])
+ const target = repo({ id: 'prospective', path: WSL_REPO_PATH })
+ const result = await detectProjectLaunchContext(store({ repos: [target] }), target)
+ expect(result).toMatchObject({
+ workspaceKind: 'git-worktree',
+ executionContext: {
+ kind: 'host',
+ projectRuntime: { status: 'resolved', runtime: { kind: 'wsl' } }
+ }
+ })
+ expect(detectInstalledMock).toHaveBeenCalledWith(
+ wslContext('Ubuntu-24.04', 'prospective', 'project-override')
+ )
+ })
+
it('keeps the host default for a workspace no repo row owns', () => {
const s = store({ repos: [repo({ id: 'r1', path: 'C:\\r1' })] })
expect(resolveFor(s, 'missing::C:\\x')).toEqual({ kind: 'local' })
diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts
index 38fac66ce10..e9c8355262c 100644
--- a/src/main/preflight/workspace-agent-detection.ts
+++ b/src/main/preflight/workspace-agent-detection.ts
@@ -1,3 +1,6 @@
+import { isFolderRepo } from '../../shared/repo-kind'
+import type { ProjectLaunchContext } from '../../shared/rpc-contract/preflight-params'
+import { isTuiAgent } from '../../shared/tui-agent-config'
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version'
import { refreshAtRestCommandContext } from '../native-chat/agent-session-wire/at-rest-command-context'
import { captureHostShellEnvironment } from '../runtime/structured-agent-shell-environment'
@@ -157,3 +160,27 @@ export async function refreshAgentsOnHost(host: AgentDetectionHost): Promise[ {
+ const host = getRepoExecutionHostId(repo)
+ const ssh = getSshTargetIdForExecutionHost(host)
+ if (parseExecutionHostId(host)?.kind === 'runtime') {
+ throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
+ }
+ const projectRuntime = ssh ? undefined : resolveLocalWorkspaceRuntime(store, repo, repo.path)
+ if (!ssh && !projectRuntime) {
+ throw new Error('worktree_execution_host_unresolved')
+ }
+ const agents = await detectAgentsOnHost(
+ ssh ? { kind: 'ssh', connectionId: ssh } : { kind: 'local', context: { projectRuntime } }
+ )
+ return {
+ agents: agents.filter(isTuiAgent),
+ workspaceKind: isFolderRepo(repo) ? 'folder' : 'git-worktree',
+ executionContext: projectRuntime ? { kind: 'host', projectRuntime } : { kind: 'ssh' }
+ }
+}
diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts
index 56bec0d8ddd..331306bf1b3 100644
--- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts
+++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts
@@ -8,6 +8,8 @@ import type {
RuntimeCreateAgentSessionResult
} from '../../shared/agent-session-host-authority'
import { OrcaRuntimeService } from './orca-runtime'
+import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
+import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
import type { IPtyProvider } from '../providers/pty-provider-contract'
@@ -140,6 +142,69 @@ describe('agent-session execution namespace', () => {
)
})
+ it('retains compiled policy through args-only host reconstruction and repeated recorded resume', async () => {
+ const launch = buildAgentStartupPlan({
+ agent: 'codex',
+ prompt: '',
+ allowEmptyPromptLaunch: true,
+ cmdOverrides: {},
+ platform: 'linux',
+ agentArgs: '-m old-model -c model_reasoning_effort=low -c model_verbosity=low',
+ sessionOptions: { model: 'gpt-5.5', effort: 'high', permissionMode: 'ask' },
+ sessionOptionsOverrideAgentArgs: true
+ })
+ expect(launch?.sessionOptions).toEqual({
+ model: 'gpt-5.5',
+ effort: 'high',
+ permissionMode: 'ask'
+ })
+ expect(launch?.launchCommand).not.toContain('old-model')
+ const policyTokens = [
+ 'codex',
+ '-c',
+ 'model_verbosity=low',
+ '-a',
+ 'on-request',
+ '-s',
+ 'read-only',
+ '-c',
+ 'approvals_reviewer=user'
+ ]
+ expect(tokenizeStartupCommand(launch?.launchConfig.agentCommand ?? '', 'posix')).toMatchObject({
+ ok: true,
+ tokens: policyTokens
+ })
+ const runtime = createRuntime()
+ const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
+ const providerSession = { key: 'session_id' as const, id: 'provider-session-1' }
+ await runtime.ensureAgentSession({
+ kind: 'explicit',
+ worktree: 'id:worktree-1',
+ agent: 'codex',
+ providerSession,
+ agentArgs: launch?.launchConfig.agentArgs
+ })
+ const host = createTerminal.mock.calls[0]?.[1]
+ expect(tokenizeStartupCommand(host?.command ?? '', 'posix')).toMatchObject({
+ ok: true,
+ tokens: [...policyTokens, 'resume', providerSession.id]
+ })
+ expect(host?.launchConfig).toEqual(launch?.launchConfig)
+ const resumeInputs = {
+ agent: 'codex' as const,
+ providerSession,
+ platform: 'linux' as const,
+ cmdOverrides: {}
+ }
+ const resumed = buildAgentResumeStartupPlan({ ...resumeInputs, ...host?.launchConfig })
+ const resumedAgain = buildAgentResumeStartupPlan({ ...resumeInputs, ...resumed?.launchConfig })
+ expect(resumedAgain?.launchConfig).toEqual(launch?.launchConfig)
+ expect(tokenizeStartupCommand(resumedAgain?.launchCommand ?? '', 'posix')).toMatchObject({
+ ok: true,
+ tokens: [...policyTokens, 'resume', providerSession.id]
+ })
+ })
+
it('propagates probe getter failure before entering the probe failure fallback', async () => {
const failure = new Error('probe getter failed')
const runtime = createRuntime({
diff --git a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts
index ba4a841e798..aab9759e110 100644
--- a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts
+++ b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts
@@ -160,9 +160,16 @@ it('a whitespace-only submit creates an eager empty chat without sending a turn'
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
})
-it('the opening prompt owns retry for a legacy-capability CLI caller', async () => {
+it('the opening prompt owns retry with explicit model and permission for a legacy-capability CLI caller', async () => {
holdStarts()
- const result = await launch(CLI, 'fix the build')
+ const params = AGENT_LAUNCH.params.parse({
+ agent: 'codex',
+ target: { kind: 'existing', worktree: 'id:wt-7' },
+ sessionId: SESSION,
+ chatOptions: { model: 'explicit-model', permissionMode: 'full-access' },
+ prompt: { text: 'fix the build', delivery: 'submit' }
+ })
+ const result = await AGENT_LAUNCH.handler(params, rpcContext(launchRuntime(), CLI))
expect(result.prompt).toEqual({
delivery: 'submit',
outcome: 'journaled',
@@ -185,6 +192,11 @@ it('the opening prompt owns retry for a legacy-capability CLI caller', async ()
const snapshot = await rig.host.journalSnapshot(SESSION)
expect(snapshot.submissions).toMatchObject([{ dispatchState: 'accepted' }])
expect(snapshot.items.filter((item) => item.body.kind === 'status')).toEqual([])
+ expect(rig.store.getRecord(SESSION)).toMatchObject({
+ options: { model: 'explicit-model' },
+ permissionMode: 'full-access'
+ })
+ expect(rig.store.getRecord(SESSION)?.options).not.toHaveProperty('effort')
})
it('rejects one requester with one failure row when all five children exit', async () => {
diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts
index 0fe3460914e..d595922cd77 100644
--- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts
+++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts
@@ -7,6 +7,7 @@ import type { AgentLaunchPublishedSurface } from '../../../agent-launch/agent-la
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcContext } from '../core'
+import { clientRendersStructuredAgent } from './structured-agent-session-policy'
/** The paired client whose view this launch should move, or null when it moves the host's. */
export function agentLaunchCallerNavigationId(
@@ -49,3 +50,20 @@ export function selectAgentLaunchTabForCaller(
console.warn('[agent-launch] the launch ran; selecting its tab for the caller did not', error)
}
}
+
+/**
+ * `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those
+ * two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart
+ * offers use; a client that does not gets that agent as a terminal.
+ */
+export function callerRendersLaunchedChat(
+ context: Pick,
+ agent: string
+): boolean {
+ return (
+ context.clientKind === undefined ||
+ agent === 'claude' ||
+ agent === 'codex' ||
+ clientRendersStructuredAgent(context.clientCapabilities, agent)
+ )
+}
diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts
index 5048068ac6d..73a391ed44d 100644
--- a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts
+++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts
@@ -1,9 +1,14 @@
+import { decideAgentLaunchIntentMode } from '../../../agent-launch/agent-launch-intent-mode'
/** Resolve selectors and validate terminal reuse without creating a surface. */
import type { AgentLaunchIntent, AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
import type { OrcaRuntimeService } from '../../orca-runtime'
-import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan'
+import { assertTerminalLaunchAdmissible } from '../../../agent-launch/agent-launch-surface-factories'
import type { AgentLaunchParams } from '../../../../shared/rpc-contract/agent-launch-params'
+import {
+ resolveAgentLaunchModeOnHost,
+ type AgentLaunchModeReceipt
+} from '../../../agent-launch/agent-launch-mode'
import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication'
/** Resolve the selector once to avoid id:id: prefixes below the executor.
@@ -36,6 +41,7 @@ async function agentLaunchIntent(
target: await agentLaunchTarget(params, runtime),
...(params.prompt ? { prompt: params.prompt } : {}),
...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}),
+ ...(params.chatOptions ? { chatOptions: params.chatOptions } : {}),
...(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 } : {}),
@@ -74,12 +80,28 @@ async function validateReusedTerminal(
export async function resolveUnlaunchedIntent(
params: AgentLaunchParams,
runtime: OrcaRuntimeService,
- early: EarlyAgentLaunchTab | null
-): Promise {
- if (params.reuseTerminal || params.target.kind === 'create-worktree') {
- assertOpenCodeModelLaunchPreferencesAbsent(params.agent, params.sessionOptions)
- }
+ early: EarlyAgentLaunchTab | null,
+ modeOptions: { terminalOnly?: boolean; callerRendersStructured?: boolean } = {}
+): Promise<{ intent: AgentLaunchIntent; decidedMode: AgentLaunchModeReceipt }> {
const intent = await agentLaunchIntent(params, runtime, early?.paneKey)
await validateReusedTerminal(intent, runtime)
- return intent
+ const preflight = decideAgentLaunchIntentMode({ intent, runtime, ...modeOptions })
+ const decidedMode =
+ intent.target.kind === 'existing'
+ ? await resolveAgentLaunchModeOnHost(
+ runtime,
+ preflight,
+ intent.target.worktree,
+ intent.agent,
+ undefined,
+ {
+ phase: 'before-effects',
+ agent: intent.agent,
+ workspacePath: intent.target.workspacePath,
+ cwd: intent.cwd
+ }
+ )
+ : preflight
+ assertTerminalLaunchAdmissible(intent, decidedMode.mode)
+ return { intent, decidedMode }
}
diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts
index 4e70833eef7..62eefadcbb6 100644
--- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts
+++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts
@@ -7,11 +7,14 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation'
import { AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE } from '../../../../shared/agent-launch-surface'
+import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan'
import type { AgentSessionRecordStore } from '../../agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { RpcDispatcher } from '../dispatcher'
+import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options'
import {
+ CAPABLE_CLIENT,
methodNamed,
runtimeStub,
setAgentLaunchRecordStore,
@@ -38,9 +41,11 @@ type Launch = {
agent: string
target: { kind: string; worktree?: string; create?: Readonly> }
sessionOptions?: Readonly>
+ chatOptions?: Readonly>
reuseTerminal?: { handle: string }
cwd?: string
surface?: string
+ nativeChatDefault?: boolean
}
/** The create throws; `afterDispatch` says whether the spawn request had already left. */
@@ -81,19 +86,23 @@ describe('a launch whose terminal fails', () => {
async function replay(
runtime: AgentLaunchRuntimeStub,
launch: Launch,
- operationId: string = OPERATION_ID
+ operationId: string = OPERATION_ID,
+ options?: RpcDispatchStreamingOptions
) {
const dispatcher = new RpcDispatcher({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method reached by agent.launch and dispatcher metadata.
runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
methods: AGENT_LAUNCH_METHODS
})
- return dispatcher.dispatch({
- id: 'request-1',
- authToken: 'token',
- method: 'agent.launchReplay',
- params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId })
- })
+ return dispatcher.dispatch(
+ {
+ id: 'request-1',
+ authToken: 'token',
+ method: 'agent.launchReplay',
+ params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId })
+ },
+ options
+ )
}
it('reports a failure before the spawn request with its real cause and records it', async () => {
@@ -232,6 +241,13 @@ describe('a launch whose terminal fails', () => {
it.each([
{ ...CREATE_LAUNCH, agent: 'opencode', sessionOptions: { model: 'private-proof/model-b' } },
+ // A saved chat choice is what an explicit terminal starts with, so it is refused the same way.
+ {
+ ...CREATE_LAUNCH,
+ agent: 'opencode',
+ surface: 'terminal',
+ chatOptions: { model: 'private-proof/model-b' }
+ },
{
...EXISTING_LAUNCH,
agent: 'opencode',
@@ -260,6 +276,66 @@ describe('a launch whose terminal fails', () => {
expect(retry.createManagedWorktree).not.toHaveBeenCalled()
})
+ it.each([false, true])(
+ 'settles an automatic terminal model refusal (caller downgrade %s)',
+ async (callerDowngrade) => {
+ const launch = {
+ ...CREATE_LAUNCH,
+ agent: 'opencode',
+ chatOptions: { model: 'anthropic/claude-sonnet-4-6' },
+ ...(callerDowngrade ? {} : { nativeChatDefault: false }),
+ prompt: { text: 'Reply with OK', delivery: 'submit' }
+ }
+ const first = runtimeStub({ settings: { experimentalNativeChat: true } })
+ first.createManagedWorktree.mockImplementation(async (args) => {
+ expect(args).toMatchObject({
+ startupAgent: 'opencode',
+ startupLaunchPreferences: { model: launch.chatOptions.model }
+ })
+ assertOpenCodeModelLaunchPreferencesAbsent('opencode', launch.chatOptions)
+ return { worktree: { id: 'wt-new' }, startupTerminal: undefined }
+ })
+
+ const caller = callerDowngrade ? CAPABLE_CLIENT : undefined
+ expect(await replay(first, launch, OPERATION_ID, caller)).toMatchObject({
+ ok: false,
+ error: { code: 'capability_unsupported' }
+ })
+ expect(outcomeOf(OPERATION_ID)).toMatchObject({ status: 'failed' })
+ expect(first.createManagedWorktree).not.toHaveBeenCalled()
+ expect(first.createTerminal).not.toHaveBeenCalled()
+ const retry = runtimeStub({ settings: {} })
+ expect(await replay(retry, launch, OPERATION_ID, caller)).toMatchObject({
+ ok: false,
+ error: { code: 'capability_unsupported' }
+ })
+ expect(retry.createManagedWorktree).not.toHaveBeenCalled()
+ }
+ )
+
+ it.each([undefined, 'chat'] as const)(
+ 'keeps an OpenCode model create on its chat route %s',
+ async (surface) => {
+ const runtime = runtimeStub({ settings: { experimentalNativeChat: true } })
+ const launch = {
+ ...CREATE_LAUNCH,
+ agent: 'opencode',
+ chatOptions: { model: 'anthropic/claude-sonnet-4-6' },
+ ...(surface ? { surface } : {})
+ }
+ expect(await replay(runtime, launch)).toMatchObject({
+ ok: true,
+ result: { outcome: { kind: 'structured' } }
+ })
+ expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
+ expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).not.toHaveProperty('startupAgent')
+ expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).not.toHaveProperty(
+ 'startupLaunchPreferences'
+ )
+ expect(runtime.createTerminal).not.toHaveBeenCalled()
+ }
+ )
+
it('preserves a successful recorded model launch even when its placement is now refused', async () => {
const launch = AGENT_LAUNCH_REPLAY.params.parse({
...CREATE_LAUNCH,
diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts
index 486b9aab330..61e88b5da71 100644
--- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts
+++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts
@@ -52,6 +52,7 @@ export function agentLaunchSurfaceFactory(
worktreeId,
agent,
options,
+ chatOptions,
sessionId: requested,
tabId,
withoutPrompt,
@@ -65,7 +66,7 @@ export function agentLaunchSurfaceFactory(
definition?.restingOptions.acceptsKey(key)
)
)
- const seeded = Object.keys(supported).length > 0 ? supported : undefined
+ const seeded = chatOptions ?? (Object.keys(supported).length > 0 ? supported : undefined)
// A client that creates chats without starting them starts this one on its first message.
const atRest =
firstMessage !== undefined ||
@@ -91,6 +92,7 @@ export function agentLaunchSurfaceFactory(
},
worktree: `id:${worktreeId}`,
agent,
+ ...(chatOptions?.model ? { optionsMode: 'replace-model-selection' as const } : {}),
...(seeded ? { options: seeded } : {}),
...(tabId ? { tabId } : {}),
...(atRest ? { atRest: true as const } : {}),
@@ -130,7 +132,7 @@ export function agentLaunchSurfaceFactory(
viewMode,
accountEnv
}) => {
- const launchPreferences = toAgentLaunchPreferences(options)
+ const launchPreferences = toAgentLaunchPreferences(options, agent)
let promptRodeLaunchCommand = false
const hasEarlyTab = earlyTab !== null
if (earlyTab?.closedByUser()) {
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 9eda85c2d14..88317333bd2 100644
--- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts
+++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts
@@ -48,7 +48,8 @@ export function agentLaunchWorkspaceFactory(
options,
accountEnv
}) => {
- const startupLaunchPreferences = toAgentLaunchPreferences(options)
+ const startupLaunchPreferences =
+ startupAgent && toAgentLaunchPreferences(options, startupAgent)
let promptRodeLaunchCommand = false
// 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
diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts
index adf28591178..ca7e0340499 100644
--- a/src/main/runtime/rpc/methods/agent-launch.ts
+++ b/src/main/runtime/rpc/methods/agent-launch.ts
@@ -5,7 +5,7 @@
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability'
import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed'
import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation'
-import type { AgentLaunchIntent, AgentLaunchResult } from '../../../../shared/agent-launch-intent'
+import type { AgentLaunchResult } from '../../../../shared/agent-launch-intent'
import { agentSessionOperationKey } from '../../../../shared/agent-session-operation-ledger'
import {
WorktreeCreateCollisionError,
@@ -39,11 +39,11 @@ import {
} from './agent-launch-failure-code'
import {
agentLaunchCallerNavigationId,
+ callerRendersLaunchedChat,
selectAgentLaunchTabForCaller
} from './agent-launch-caller-selection'
import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation'
import { assertAgentLaunchTargetAuthorized } from './agent-launch-target-authorization'
-import { clientRendersStructuredAgent } from './structured-agent-session-policy'
import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution'
import {
publishEarlyTab,
@@ -61,19 +61,6 @@ export function supportsAgentLaunch(
)
}
-/** v2 covers Claude/Codex chats; other agents require the same explicit rendering capability used by tabs and restart offers, else use terminal. */
-function callerRendersLaunchedChat(
- context: Pick,
- agent: string
-): boolean {
- return (
- context.clientKind === undefined ||
- agent === 'claude' ||
- agent === 'codex' ||
- clientRendersStructuredAgent(context.clientCapabilities, agent)
- )
-}
-
/** What a launch admitted under an operation id carries into its execution. */
type ReplaySafeLaunch = {
attachOperationId: string
@@ -84,7 +71,7 @@ type ReplaySafeLaunch = {
}
async function runAgentLaunch(
- intent: AgentLaunchIntent,
+ { intent, decidedMode }: Awaited>,
context: RpcContext,
view: AgentLaunchView,
replaySafe?: ReplaySafeLaunch
@@ -93,6 +80,7 @@ async function runAgentLaunch(
const result = await executeAgentLaunch({
runtime: context.runtime,
intent,
+ decidedMode,
surfaces: agentLaunchSurfaceFactory(
context,
replaySafe?.attachOperationId,
@@ -102,7 +90,6 @@ async function runAgentLaunch(
view
),
workspaces: agentLaunchWorkspaceFactory(context, intent.agent),
- ...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }),
// The tab is shown as it is published, not after a prompt that can take a minute to land.
onSurfacePublished: (surface) => {
view.early?.surfacePublished(surface)
@@ -123,10 +110,16 @@ function runLegacyAgentLaunch(
): Promise {
// No early tab, as before: only a replay-safe launch shows its tab before its agent exists.
const execute = async () =>
- runAgentLaunch(await resolveUnlaunchedIntent(params, context.runtime, null), context, {
- early: null,
- presentation: params.presentation
- })
+ runAgentLaunch(
+ await resolveUnlaunchedIntent(params, context.runtime, null, {
+ callerRendersStructured: callerRendersLaunchedChat(context, params.agent)
+ }),
+ context,
+ {
+ early: null,
+ presentation: params.presentation
+ }
+ )
if (params.target.kind === 'create-worktree' && params.target.create.clientMutationId) {
const { repo, repoHostId } = params.target.create
return context.runtime.dedupeWorktreeCreate(
@@ -202,17 +195,19 @@ async function executeAdmittedAgentLaunch(
>,
view: AgentLaunchView
): Promise {
- let intent: AgentLaunchIntent
+ let resolved: Awaited>
try {
- intent = await resolveUnlaunchedIntent(params, context.runtime, view.early)
+ resolved = await resolveUnlaunchedIntent(params, context.runtime, view.early, {
+ callerRendersStructured: callerRendersLaunchedChat(context, params.agent)
+ })
} catch (error) {
- await settleQuietly(admission.fail(agentLaunchFailureCode(error)))
+ await settleQuietly(admission.fail(agentLaunchFailureCode(error), error))
throw error
}
const terminalSpawn = trackTerminalSpawnDispatch()
let result: AgentLaunchResult
try {
- result = await runAgentLaunch(intent, context, view, {
+ result = await runAgentLaunch(resolved, context, view, {
attachOperationId: admission.attachOperationId,
callerKey: admission.callerKey,
terminalSpawn,
@@ -224,7 +219,7 @@ async function executeAdmittedAgentLaunch(
}
const failedWithoutEffects = launchFailureWithoutEffectsCode(
error,
- intent.target.kind,
+ resolved.intent.target.kind,
terminalSpawn
)
if (failedWithoutEffects) {
diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts
index a18e0374f56..f5cd16dcd68 100644
--- a/src/main/runtime/rpc/methods/preflight.test.ts
+++ b/src/main/runtime/rpc/methods/preflight.test.ts
@@ -145,6 +145,32 @@ describe('preflight RPC methods', () => {
expect(resolveAgentDetectionHost).not.toHaveBeenCalled()
})
+ it('routes a project through its launch context and rejects mixed selectors before detection', async () => {
+ const context = { agents: ['codex'], workspaceKind: 'git', executionContext: { kind: 'host' } }
+ const detectProjectLaunchContext = vi.fn().mockResolvedValue(context)
+ const resolveAgentDetectionHost = vi.fn()
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these runtime members.
+ const runtime = {
+ getRuntimeId: () => 'runtime',
+ detectProjectLaunchContext,
+ resolveAgentDetectionHost
+ } as unknown as OrcaRuntimeService
+ const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
+ const project = { repo: 'r1' }
+ const response = await dispatcher.dispatch(makeRequest('preflight.detectAgents', { project }))
+ expect(response).toMatchObject({ ok: true, result: context })
+ expect(detectProjectLaunchContext).toHaveBeenCalledExactlyOnceWith('r1')
+ detectProjectLaunchContext.mockClear()
+ for (const selector of [{ repoId: 'r1' }, { worktreeId: 'r1::/work' }]) {
+ const mixed = await dispatcher.dispatch(
+ makeRequest('preflight.detectAgents', { project, ...selector })
+ )
+ expect(mixed).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
+ }
+ expect(detectProjectLaunchContext).not.toHaveBeenCalled()
+ expect(resolveAgentDetectionHost).not.toHaveBeenCalled()
+ })
+
it.each(['preflight.detectAgents', 'preflight.refreshAgents'])(
'%s never probes locally after repository ownership refuses',
async (method) => {
diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts
index 9e395c6eb00..5d4b8ced7ff 100644
--- a/src/main/runtime/rpc/methods/preflight.ts
+++ b/src/main/runtime/rpc/methods/preflight.ts
@@ -9,6 +9,7 @@ import {
} from '../../../preflight/workspace-agent-detection'
import {
PreflightAgentDetection,
+ PreflightProjectAgentDetection,
PreflightCheck,
PreflightDetectRemoteAgents,
PreflightDetectRemoteWindowsTerminalCapabilities
@@ -24,10 +25,14 @@ export const PREFLIGHT_METHODS = [
defineMethod({
name: 'preflight.detectAgents',
permission: 'workspace',
- params: PreflightAgentDetection,
+ params: PreflightProjectAgentDetection,
// Only the host knows the workspace's actual project runtime, including WSL.
handler: async (params, { runtime }) =>
- detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId))
+ params.project
+ ? runtime.detectProjectLaunchContext(params.project.repo)
+ : detectAgentsOnHost(
+ await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId)
+ )
}),
defineMethod({
name: 'preflight.detectRemoteAgents',
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts
index 74280deb013..2651a13a7fe 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts
@@ -87,6 +87,7 @@ export async function handleStructuredAgentSessionCreate(
structuredAgentCliSourceEvidence: ctx.structuredAgentCliSourceEvidence,
...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}),
...(params.tabId ? { tabId: params.tabId } : {}),
+ ...(params.optionsMode ? { optionsMode: params.optionsMode } : {}),
...(params.options ? { options: params.options } : {}),
...(params.firstMessage ? { firstMessage: params.firstMessage } : {}),
atRest: Boolean(
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts
index afa22af7a1a..eaa828ee603 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts
@@ -42,7 +42,11 @@ afterEach(async () => {
vi.restoreAllMocks()
})
-function params(options?: Readonly>, withMessage = false) {
+function params(
+ options?: Readonly>,
+ withMessage = false,
+ optionsMode?: 'replace-model-selection'
+) {
return structuredAgentSessionCreateParams({
sessionId: SESSION,
worktree: 'id:workspace-1',
@@ -50,6 +54,7 @@ function params(options?: Readonly>, withMessage = false)
randomUuid: () => '00000000-0000-0000-0000-0000000000fa',
now: NOW,
...(options ? { options } : {}),
+ ...(optionsMode ? { optionsMode } : {}),
...(withMessage
? {
firstMessage: { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening') }
@@ -191,20 +196,20 @@ it.each(['firstMessage', 'options'] as const)(
}
)
-it('merges held option overrides with host seeds before the first acquisition', async () => {
- const input = params({ model: 'held-model' })
+it('replaces unrelated host model defaults before the first acquisition', async () => {
+ const input = params({ model: 'held-model' }, false, 'replace-model-selection')
expect(await call('agentSession.create', input, capable)).toMatchObject({
ok: true,
result: { ok: true }
})
- expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' })
+ expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model' })
expect(rig.adapter.acquire).not.toHaveBeenCalled()
expect(await rig.host.send({ callerKey: 'client-1' }, restTestSend('first work'))).toMatchObject({
ok: true
})
await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce())
expect(rig.adapter.acquire).toHaveBeenCalledWith(
- expect.objectContaining({ options: { model: 'held-model', effort: 'medium' } })
+ expect.objectContaining({ options: { model: 'held-model' } })
)
})
@@ -234,6 +239,32 @@ it('keeps the seeded permission mode beside explicit picks on an acquired create
)
})
+it('keeps the remembered model under a permission mode picked alone on an acquired create', async () => {
+ // The not-at-rest path `agent.launch` with a prompt takes.
+ const resolveStructuredAgentSessionCreateIntent = async (input: { envelope: unknown }) => ({
+ envelope: input.envelope,
+ location: {
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ },
+ provider: 'codex',
+ agent: 'codex',
+ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
+ options: { model: 'saved', effort: 'low', permissionMode: 'full-access' },
+ runtimeKind: 'native'
+ })
+ expect(
+ await call('agentSession.create', params({ permissionMode: 'ask' }), legacy, {
+ resolveStructuredAgentSessionCreateIntent
+ })
+ ).toMatchObject({ ok: true, result: { ok: true } })
+ expect(rig.adapter.acquire).toHaveBeenCalledWith(
+ expect.objectContaining({ options: { model: 'saved', effort: 'low' }, permissionMode: 'ask' })
+ )
+})
+
it('rejects an explicit option the resting agent would reject before founding', async () => {
expect(
await call('agentSession.create', params({ unknownOption: 'yes' }), capable)
@@ -249,7 +280,7 @@ it('rejects an explicit option the resting agent would reject before founding',
it.each(['options', 'firstMessage'] as const)(
'rejects changes to firstMessage or explicit options under a replayed create operation: %s',
async (field) => {
- const original = params({ model: 'held-model' }, true)
+ const original = params({ model: 'held-model' }, true, 'replace-model-selection')
// Keep delivery held while reusing the create id with different intent.
expect(
await call('agentSession.create', original, capable, {
@@ -258,7 +289,7 @@ it.each(['options', 'firstMessage'] as const)(
}
})
).toMatchObject({ ok: true, result: { ok: false } })
- expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' })
+ expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model' })
const first = original.firstMessage
if (!first) {
throw new Error('fixture first message missing')
@@ -272,6 +303,7 @@ it.each(['options', 'firstMessage'] as const)(
agent: original.agent,
now: NOW,
randomUuid: () => '00000000-0000-0000-0000-0000000000fa',
+ optionsMode: original.optionsMode,
options: field === 'options' ? { model: 'other-model' } : original.options,
firstMessage:
field === 'firstMessage' ? { ...first, body: hostTestMessage('changed') } : first
@@ -284,6 +316,34 @@ it.each(['options', 'firstMessage'] as const)(
}
)
+it.each([undefined, 'replace-model-selection'] as const)(
+ 'declared composition %s preserves ordinary defaults only when absent',
+ async (optionsMode) => {
+ const input = params({ model: 'held-model', effort: 'high' }, false, optionsMode)
+ await call('agentSession.create', input, capable, {
+ resolveStructuredAgentSessionCreateIntent: async ({ envelope }: { envelope: unknown }) => ({
+ envelope,
+ location: {
+ executionHostId: 'local',
+ wslDistro: null,
+ workspaceId: 'workspace-1',
+ workspaceKind: 'git-worktree'
+ },
+ provider: 'codex',
+ agent: 'codex',
+ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
+ options: { model: 'saved', effort: 'low', fastMode: 'false' },
+ runtimeKind: 'native'
+ })
+ })
+ expect(rig.store.getRecord(SESSION)?.options).toEqual({
+ model: 'held-model',
+ effort: 'high',
+ ...(optionsMode ? {} : { fastMode: 'false' })
+ })
+ }
+)
+
it('answers an old client whose start failed before spawning in plain words', async () => {
const raw = 'codex sessions pin CODEX_HOME, not CLAUDE_CONFIG_DIR'
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts
index 5e4d4750895..5b2d617a2a2 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts
@@ -1,3 +1,4 @@
+import { seededPermissionMode } from '../../../../shared/native-chat-session-option-defaults'
// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path.
import { assertAgentPromptRequestActive } from '../../orca-runtime-core'
@@ -54,6 +55,7 @@ export function structuredAgentSessionCreateIntentFingerprint(params: {
resumeFrom?: StructuredAgentSessionResumeSource
tabId?: string
firstMessage?: StructuredAgentSessionFirstMessage
+ optionsMode?: 'replace-model-selection'
options?: Readonly>
}): string {
return computeAgentSessionPayloadFingerprint({
@@ -65,6 +67,7 @@ export function structuredAgentSessionCreateIntentFingerprint(params: {
resumeFrom: params.resumeFrom,
tabId: params.tabId,
firstMessage: params.firstMessage,
+ optionsMode: params.optionsMode,
options: params.options
}
})
@@ -82,6 +85,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
resumeFrom?: StructuredAgentSessionResumeSource
structuredAgentCliSourceEvidence?: StructuredAgentCliSourceEvidence
/** Explicit picks override seed keys at rest; acquired in-process launches replace the seed. */
+ optionsMode?: 'replace-model-selection'
options?: Readonly>
/** Use the caller-reserved tab id at publication; absent, derive the legacy id. Keep it outside launch fingerprints. */
tabId?: string
@@ -133,15 +137,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
throw new AgentSessionRefusalError(refusal)
}
}
- const {
- agent: _resolvedAgent,
- provider: _resolvedProvider,
- hostLaunchDirectory,
- ...resolvedAttach
- } = resolved
+ const { agent: _agent, provider: _provider, hostLaunchDirectory, ...resolvedAttach } = resolved
// Fingerprint explicit picks, never replay-varying defaults; explicit options replace saved seeds, while permission default stays chat-owned.
+ const overlays = atRest
+ ? args.optionsMode !== 'replace-model-selection'
+ : Object.keys(args.options ?? {}).every((id) => id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID)
const picked = args.options
- ? atRest
+ ? overlays
? { ...resolved.options, ...args.options }
: { ...seededPermissionMode(resolved.options), ...args.options }
: resolved.options
@@ -291,6 +293,7 @@ export async function createStructuredAgentSessionForWorktree(args: {
worktree: string
agent: StructuredAgentId
activate: boolean
+ optionsMode?: 'replace-model-selection'
options?: Readonly>
tabId?: string
/** Create without starting the agent; its first message starts it. */
@@ -319,10 +322,3 @@ export async function createStructuredAgentSessionForWorktree(args: {
activate: args.activate
})
}
-
-function seededPermissionMode(
- options: Readonly> | undefined
-): Record {
- const mode = options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID]
- return mode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : {}
-}
diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts
index 7ad7cd1cf99..d7c3d161f6d 100644
--- a/src/main/runtime/workspaces/workspace-selectors.ts
+++ b/src/main/runtime/workspaces/workspace-selectors.ts
@@ -1,3 +1,4 @@
+import { detectProjectLaunchContext } from '../../preflight/workspace-agent-detection'
import { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '../terminal/launch/terminal-workspace-scope'
import type { ResolvedWorktree } from '../runtime-worktree-path-identity'
import { splitWorktreeIdForFilesystem, worktreeIdentityKey } from '../../../shared/worktree/id'
@@ -202,6 +203,11 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr
throw new Error('repo_not_found')
}
+ async detectProjectLaunchContext(repoSelector: string) {
+ const repo = await this.resolveRepoSelector(repoSelector)
+ return detectProjectLaunchContext(this.requireStore(), repo)
+ }
+
requireStore(): RuntimeStore {
if (!this.store) {
throw new Error('runtime_unavailable')
diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts
index 09a478bbb58..2ab59b3d5d7 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts
@@ -15,6 +15,7 @@ export type StructuredAgentSessionLaunchView = {
kind: 'new' | 'resume'
/** The encoded selection the launch seeded, shown until the host names the model. */
seedOptions?: Readonly>
+ explicitOptions?: Readonly>
/** Picks the launch holds and applies before it publishes. */
heldOptions: Readonly>
/** Where the chat runs; its own config may replace the listed default. */
@@ -79,10 +80,11 @@ function useLaunchView(
...(selection?.seed ? { seedOptions: selection.seed } : {}),
...(seedPending ? { seedPending: true as const } : {}),
heldOptions: held,
+ ...(selection?.explicit ? { explicitOptions: selection.explicit } : {}),
...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {})
}
: undefined,
- [held, intent.kind, selection?.seed, seedPending, worktreeId]
+ [held, intent.kind, selection?.seed, selection?.explicit, seedPending, worktreeId]
)
}
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx
index 2376a1f33f9..74a476939bd 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx
@@ -98,6 +98,7 @@ function renderNewChat(args: {
agent: AgentType
target?: RuntimeClientTarget
seedOptions?: Record
+ explicitOptions?: Record
sessionId?: string
}) {
const frames: Pill[] = []
@@ -117,6 +118,7 @@ function renderNewChat(args: {
kind: 'new',
heldOptions: {},
worktree: 'id:wt-1',
+ ...(args.explicitOptions ? { explicitOptions: args.explicitOptions } : {}),
...(args.seedOptions ? { seedOptions: args.seedOptions } : {})
}
})
@@ -149,6 +151,21 @@ describe('a new chat picker’s first frame', () => {
resetHostModelCatalogSnapshotsForTests()
})
+ it('shows the unlisted action model rather than a saved or listed replacement on every pending frame', async () => {
+ catalogAnswers(() => Promise.resolve(CLAUDE_LIST))
+ await preloadHostModelCatalogSnapshots(LOCAL_TARGET, ['claude'])
+ const view = renderNewChat({
+ agent: 'claude',
+ seedOptions: { model: 'opus[1m]', effort: 'high' },
+ explicitOptions: { model: 'unlisted-action' }
+ })
+ await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
+ expect(
+ view.frames.every((frame) => frame.model === 'unlisted-action' && frame.effort !== 'high')
+ ).toBe(true)
+ view.unmount()
+ })
+
it('names a saved Claude pick and its effort from the saved list on the first frame', async () => {
catalogAnswers(() => Promise.resolve(CLAUDE_LIST))
await preloadHostModelCatalogSnapshots(LOCAL_TARGET, ['claude'])
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts
index 6452e68cb27..a7e244fed4f 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts
@@ -1,7 +1,10 @@
+import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
+import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch'
+import type { AgentType } from '../../../../shared/agent-status-types'
+import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { toast } from 'sonner'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire'
-import type { AgentType } from '../../../../shared/agent-status-types'
import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog'
import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
@@ -9,25 +12,21 @@ import {
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
lockedStructuredAgentSessionOptionSnapshot,
- structuredAgentSessionOptionSnapshot
+ structuredAgentSessionOptionSnapshot,
+ type StructuredAgentSessionOptionState
} from '../../../../shared/structured-agent-session-options'
-import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { useRememberSessionOptionPicks } from './use-structured-agent-session-option-picks'
import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec'
-import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade'
import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state'
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
-import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch'
import {
getStructuredAgentSessionLaunchSelection,
holdStructuredAgentSessionLaunchOption,
type StructuredLaunchOptionOutcome
} from '@/lib/structured-agent-session-launch-options'
-const NO_HELD_OPTIONS: Readonly> = {}
-
-export function useStructuredAgentSessionOptions(args: {
+export type StructuredAgentSessionOptionsArgs = {
agent: AgentType
sessionId: string
target: RuntimeClientTarget
@@ -42,7 +41,11 @@ export function useStructuredAgentSessionOptions(args: {
unloadedTurnRevisions: number | undefined
mutate: StructuredAgentSessionMutate
launch?: StructuredAgentSessionLaunchView
-}) {
+}
+
+const NO_HELD_OPTIONS: Readonly> = {}
+
+export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOptionsArgs) {
const {
agent,
fence,
@@ -115,6 +118,12 @@ export function useStructuredAgentSessionOptions(args: {
receiveCatalog
})
+ const pendingView = useCallback(
+ (state: StructuredAgentSessionOptionState) =>
+ optionView(state, launchSeedOptions, held, launch?.explicitOptions),
+ [held, launch?.explicitOptions, launchSeedOptions, optionView]
+ )
+
const rememberOptionPicks = useRememberSessionOptionPicks(target, agent)
const sendStructuredOption = useCallback(
async (id: string, encoded: string): Promise => {
@@ -210,11 +219,11 @@ export function useStructuredAgentSessionOptions(args: {
lockSnapshotRef.current = lockSnapshot
})
const optionSnapshot = lockSnapshot(
- structuredAgentSessionOptionSnapshot(optionView(optionState, launchSeedOptions, held))
+ structuredAgentSessionOptionSnapshot(pendingView(optionState))
)
const setStructuredOption = useCallback(
async (id: string, value: string | boolean): Promise => {
- const view = optionView(optionStateRef.current, launchSeedOptions, held)
+ const view = pendingView(optionStateRef.current)
const pickState = {
...view,
pendingId: providerStarting || pendingOptionsRef.current[id] === undefined ? null : id
@@ -246,13 +255,11 @@ export function useStructuredAgentSessionOptions(args: {
},
[
fence,
- held,
- launchSeedOptions,
optionCatalog,
isCurrentContext,
optionStateRef,
- optionView,
pendingOptionsRef,
+ pendingView,
providerStarting,
sendStructuredOption,
sessionId,
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts
index 26118f02217..9d34e292fb3 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts
@@ -1,3 +1,4 @@
+import { structuredLaunchRequestedOptions } from '../../../../shared/native-chat-session-option-defaults'
import { useCallback } from 'react'
import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view'
import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options'
@@ -20,7 +21,15 @@ export function useStructuredAgentSessionSendOptions(
}
// Read held picks directly because launch acceptance can precede this render’s props.
const selection = getStructuredAgentSessionLaunchSelection(sessionId)
- // A pick on its way resolves as the picker shows it.
+ const requested = structuredLaunchRequestedOptions({
+ seed: selection?.seed ?? launch?.seedOptions,
+ explicit: selection?.explicit ?? launch?.explicitOptions,
+ held: { ...(selection?.held ?? launch?.heldOptions), ...pendingOptionsRef.current }
+ })
+ if (requested?.model !== undefined) {
+ return requested
+ }
+ // A pick on its way resolves as the picker shows it, keeping the model it names.
const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, {
...(selection?.held ?? launch?.heldOptions),
...pendingOptionsRef.current
diff --git a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx
index 0759c82442c..523daa91f49 100644
--- a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx
+++ b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx
@@ -64,15 +64,7 @@ export function ChecksFixDialog({
)
)
}}
- onStart={({ agent, commandInput, agentArgs }) =>
- startFixChecksFromDialog({
- targetRepoId,
- item,
- agent,
- commandInput,
- agentArgs
- })
- }
+ onStart={(launch) => startFixChecksFromDialog({ targetRepoId, item, ...launch })}
/>
)
}
diff --git a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts
index 3944540ef66..af5e55d7d5c 100644
--- a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts
+++ b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts
@@ -54,6 +54,8 @@ export async function startFixChecksFromDialog(args: {
commandInput: string
/** Omitted when CLI arguments do not apply, so the launch resolves the global Agents setting. */
agentArgs?: string
+ sessionOptions?: Readonly>
+ sessionOptionsYieldToAgentArgs?: true
}): Promise {
if (!args.targetRepoId) {
return false
@@ -66,6 +68,8 @@ export async function startFixChecksFromDialog(args: {
promptDelivery: 'submit-after-ready',
agentOverride: args.agent,
agentArgs: args.agentArgs,
+ ...(args.sessionOptions ? { sessionOptions: args.sessionOptions } : {}),
+ sessionOptionsYieldToAgentArgs: args.sessionOptionsYieldToAgentArgs,
openModalFallback: () => {
toast.error(
translate(
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx
index 915129eaefb..e5ef8f532fb 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx
@@ -234,24 +234,35 @@ describe('SourceControlAgentActionDialog', () => {
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
- agentArgs: ''
+ agentArgs: '--dangerously-bypass-approvals-and-sandbox'
})
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
- it('hides the dialog and auto-starts once when the saved repo launch recipe matches', async () => {
- resetStore(
- settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' }),
- [repoWithSavedRecipe()]
- )
- renderControlledDialog({ repoId: 'repo-1' })
+ it('auto-starts a sparse repo recipe without giving it inherited model ownership', async () => {
+ const settings = settingsWithGlobalRecipe({
+ agentId: 'codex',
+ commandInputTemplate: '{basePrompt}',
+ launchPreferences: { agentId: 'codex', model: 'gpt-5.6-terra' }
+ })
+ const repo = repoWithSavedRecipe()
+ const sparse = { agentArgs: '--model gpt-5.6-sol' }
+ repo.sourceControlAi = { actionOverrides: { resolveConflicts: sparse } }
+ resetStore(settings, [repo])
+ renderControlledDialog({ repoId: 'repo-1', savedAgentArgs: sparse.agentArgs })
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
- expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
- expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
+ expect(mocks.onStart).toHaveBeenCalledWith({
+ agent: 'codex',
+ commandInput: 'Resolve conflicts.',
+ agentArgs: `--dangerously-bypass-approvals-and-sandbox ${sparse.agentArgs}`,
+ sessionOptions: { model: 'gpt-5.6-terra' },
+ sessionOptionsYieldToAgentArgs: true
+ })
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
+ expect(repo.sourceControlAi.actionOverrides?.resolveConflicts).toEqual(sparse)
expect(container.textContent).not.toContain('Launch agent')
})
it('keeps saved arguments on a prospective SSH terminal launch', async () => {
@@ -279,7 +290,7 @@ describe('SourceControlAgentActionDialog', () => {
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
- agentArgs: '--model saved'
+ agentArgs: '--dangerously-bypass-approvals-and-sandbox --model saved'
})
})
// Paired-server agents belong to the workspace owner; the client list cannot detect them (#21429).
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
index 41a125b5776..8a3c141f431 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
@@ -53,6 +53,9 @@ export type SourceControlAgentActionDialogProps = {
commandInput: string
/** Omitted when CLI arguments do not apply to this launch, so it resolves the global setting. */
agentArgs?: string
+ /** The action's saved choice, on either route. */
+ sessionOptions?: Readonly>
+ sessionOptionsYieldToAgentArgs?: true
}) => boolean | Promise
}
@@ -81,6 +84,7 @@ export function SourceControlAgentActionDialog(
statusCopy,
agentArgs,
agentArgsApply,
+ recipe,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
@@ -118,6 +122,7 @@ export function SourceControlAgentActionDialog(
statusCopy={statusCopy}
agentArgs={agentArgs}
agentArgsApply={agentArgsApply}
+ recipe={recipe}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx
index 06ec7fecd67..976ce1b9833 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx
@@ -61,6 +61,11 @@ function renderForm(
statusCopy: null,
agentArgs: '',
agentArgsApply: true,
+ recipe: {
+ agentId: overrides.selectedAgent ?? 'codex',
+ commandInputTemplate: overrides.commandTemplate ?? '{basePrompt}',
+ agentArgs: overrides.agentArgs ?? ''
+ },
commandTemplate: '{basePrompt}',
savedCommandInputTemplate: '{basePrompt}',
saveLaunchRecipe: true,
@@ -119,6 +124,35 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(markup).toContain('Resolve the merge conflicts reported for this pull request.')
})
+ it('names the saved choice on chat and terminal launches alike', () => {
+ const recipe = {
+ agentId: 'codex' as const,
+ launchPreferences: {
+ agentId: 'codex' as const,
+ model: 'saved',
+ permissionMode: 'ask' as const
+ }
+ }
+ expect(renderForm({ recipe, agentArgsApply: true })).toContain(
+ 'Older servers use their own permission settings.'
+ )
+ expect(
+ renderForm({
+ recipe: { ...recipe, launchPreferences: { agentId: 'codex', model: 'saved' } },
+ agentArgsApply: false
+ })
+ ).toContain('Model from Settings: saved')
+ expect(
+ renderForm({
+ selectedAgent: 'antigravity',
+ recipe: {
+ agentId: 'antigravity',
+ launchPreferences: { agentId: 'antigravity', permissionMode: 'ask' }
+ }
+ })
+ ).toContain('Applies to chats only')
+ })
+
it('offers agents only, since an action cannot start without one', () => {
expect(renderForm()).toContain('data-allow-blank="false"')
})
@@ -133,6 +167,7 @@ describe('SourceControlAgentActionDialogForm', () => {
const globalMarkup = renderForm({
settings,
+ recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}' },
repo: repoWithoutSavedRecipe,
saveTargets,
saveTargetValue: 'global'
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
index edd6ab1e8fc..24d14ee86dd 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
@@ -13,14 +13,18 @@ import {
} from '@/components/ui/select'
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
-import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions'
+import type {
+ SourceControlActionRecipe,
+ SourceControlLaunchActionId
+} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { SourceControlAgentCliArgsField } from './SourceControlAgentCliArgsField'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
-import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
+import { describeSourceControlActionModel } from '../settings/source-control-action-model-copy'
+import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match'
import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
import { translate } from '@/i18n/i18n'
@@ -41,6 +45,7 @@ type SourceControlAgentActionDialogFormProps = {
agentArgs: string
/** False when the launch would be structured native chat; the field is then absent, not disabled. */
agentArgsApply: boolean
+ recipe: SourceControlActionRecipe
commandTemplate: string
savedCommandInputTemplate?: string | null
saveLaunchRecipe: boolean
@@ -87,6 +92,7 @@ export function SourceControlAgentActionDialogForm({
statusCopy,
agentArgs,
agentArgsApply,
+ recipe,
commandTemplate,
savedCommandInputTemplate,
saveLaunchRecipe,
@@ -110,19 +116,13 @@ export function SourceControlAgentActionDialogForm({
}: SourceControlAgentActionDialogFormProps): React.JSX.Element {
const defaultCommandTemplate = savedCommandInputTemplate ?? '{basePrompt}'
const commandTemplateIncludesBasePrompt = commandTemplate.includes('{basePrompt}')
- const selectedRecipe = selectedAgent
- ? {
- agentId: selectedAgent,
- commandInputTemplate: commandTemplate,
- agentArgs
- }
- : null
+ const selectedRecipe = recipe
const selectedSaveTarget = sourceControlLaunchSaveTargetFromValue(saveTargetValue, repo)
// Copy describes only the selected save target, which is the only one start/save writes.
const selectedLaunchRecipeAlreadySaved = Boolean(
selectedRecipe &&
selectedSaveTarget &&
- sourceControlActionRecipeMatchesTarget({
+ sourceControlActionRecipeSaveMatchesTarget({
actionId,
target: selectedSaveTarget,
recipe: selectedRecipe,
@@ -187,6 +187,10 @@ export function SourceControlAgentActionDialogForm({
{statusCopy}
]
+ ) : selectedAgent && selectedRecipe.launchPreferences?.agentId === selectedAgent ? (
+
+ {describeSourceControlActionModel(selectedAgent, selectedRecipe.launchPreferences)}
+
) : null}
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx
index ce837f1f2d6..72aabd5867e 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx
@@ -19,19 +19,12 @@ export function SourceControlAgentCliArgsField({
return (
onChange(event.target.value)}
className="h-8 font-mono text-xs"
/>
diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts
index 1c2cddb3b0d..06d01f32d0d 100644
--- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts
+++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts
@@ -29,6 +29,15 @@ vi.mock('sonner', () => ({
}))
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
+import { getDefaultSettings } from '../../../../shared/constants'
+import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
+import { saveSourceControlActionRecipe } from '../../../../shared/source-control-ai-recipe-save'
+import { sourceControlActionLaunchOptions } from '../../../../shared/source-control-action-launch-preferences'
+import {
+ resolveAgentLaunchCommand,
+ terminalSessionOptionInputs
+} from '../../../../shared/tui-agent-launch-command'
+import type { Repo } from '../../../../shared/repo-types'
function buildArgs(
overrides: Partial
[0]> = {}
@@ -38,7 +47,7 @@ function buildArgs(
trimmedCommandInput: 'Fix the bug',
agentArgs: '--model gpt-5',
agentArgsApply: true,
- commandTemplate: '{basePrompt}',
+ recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '--model gpt-5' },
saveTargetValue: 'none',
actionId: 'resolveComments',
repoId: null,
@@ -444,3 +453,101 @@ describe('runSourceControlAgentActionStart CLI arguments applicability', () => {
)
})
})
+
+it('executes the actual bound namespace while saving the literal default recipe', async () => {
+ const recipe = {
+ agentId: null,
+ commandInputTemplate: '{basePrompt}',
+ agentArgs: '',
+ launchPreferences: { agentId: 'codex' as const, model: 'chosen' }
+ }
+ const onStart = vi.fn().mockResolvedValue(true)
+ await runSourceControlAgentActionStart(
+ buildArgs({ recipe, onStart, selectedAgent: 'codex', saveTargetValue: 'global' })
+ )
+ expect(onStart).toHaveBeenCalledWith(
+ expect.objectContaining({ sessionOptions: { model: 'chosen' } })
+ )
+ expect(mocks.onSaveAgentDefault).toHaveBeenLastCalledWith(
+ { type: 'global' },
+ 'resolveComments',
+ recipe
+ )
+})
+
+// Saved to the repository, the copied choice owns the action there, as on every later launch.
+it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
+ 'persists the complete target for changed or unchanged Customize arguments %s',
+ async (agentArgs) => {
+ vi.clearAllMocks()
+ const base = getDefaultSettings('/tmp')
+ const terra = { agentId: 'codex' as const, model: 'gpt-5.6-terra' }
+ const fixCommitFailure = {
+ agentId: null,
+ commandInputTemplate: '{basePrompt}',
+ launchPreferences: terra
+ }
+ const settings = {
+ ...base,
+ sourceControlAi: { ...base.sourceControlAi!, actions: { fixCommitFailure } }
+ }
+ const legacy = { agentArgs: '--model gpt-5.6-sol' }
+ const repo: Pick = {
+ id: 'repo-1',
+ sourceControlAi: { actionOverrides: { fixCommitFailure: legacy } }
+ }
+ const shown = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
+ mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } })
+ const recipe = { ...shown, agentArgs }
+ mocks.onSaveAgentDefault.mockImplementation((target, actionId, recipe) => {
+ const saved = saveSourceControlActionRecipe({ target, actionId, recipe, settings, repo })
+ if ('update' in saved) {
+ repo.sourceControlAi = saved.update.sourceControlAi ?? undefined
+ }
+ })
+ const args = { recipe, agentArgs, settings, repo, saveTargetValue: 'repo', repoId: 'repo-1' }
+
+ await runSourceControlAgentActionStart(buildArgs({ ...args, actionId: 'fixCommitFailure' }))
+
+ expect(shown.launchPreferencesYieldToAgentArgs).toBe(true)
+ expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).toMatchObject({
+ agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}`,
+ sessionOptions: { model: 'gpt-5.6-terra' }
+ })
+ expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).not.toHaveProperty(
+ 'sessionOptionsYieldToAgentArgs'
+ )
+ expect(repo.sourceControlAi?.actionOverrides?.fixCommitFailure).toEqual({
+ agentId: null,
+ commandInputTemplate: '{basePrompt}',
+ agentArgs,
+ launchPreferences: terra
+ })
+ const next = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
+ const nextOptions = sourceControlActionLaunchOptions(next, 'codex')
+ expect(nextOptions).toEqual({ sessionOptions: { model: terra.model } })
+ const command = resolveAgentLaunchCommand({
+ agent: 'codex',
+ cmdOverrides: {},
+ platform: 'linux',
+ shell: 'posix',
+ agentArgs,
+ ...terminalSessionOptionInputs({
+ agent: 'codex',
+ agentArgs,
+ shell: 'posix',
+ sessionOptions: nextOptions.sessionOptions,
+ yieldToAgentArgs: nextOptions.sessionOptionsYieldToAgentArgs
+ })
+ })
+ expect(command).toMatchObject({ ok: true, appliedSessionOptions: { model: terra.model } })
+ if (command.ok) {
+ expect(command.command).not.toContain(agentArgs.split(' ')[1])
+ }
+ expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
+ await runSourceControlAgentActionStart(
+ buildArgs({ ...args, recipe: next, actionId: 'fixCommitFailure' })
+ )
+ expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
+ }
+)
diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts
index 9a9dd995892..d1b7a3b6b85 100644
--- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts
+++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts
@@ -10,11 +10,19 @@ import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
-import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
+import {
+ saveSourceControlActionRecipe,
+ type SourceControlAiWriteTarget
+} from '../../../../shared/source-control-ai-recipe-save'
+import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
-import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
+import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match'
import { resolveSourceControlAgentSaveTarget } from './source-control-agent-action-dialog-support'
+import {
+ sourceControlActionAgentArgs,
+ sourceControlActionLaunchOptions
+} from '../../../../shared/source-control-action-launch-preferences'
import { admitSourceControlRecovery } from './source-control-recovery-admission'
type RunSourceControlAgentActionStartArgs = {
@@ -23,7 +31,7 @@ type RunSourceControlAgentActionStartArgs = {
agentArgs: string
/** False when the launch would be structured native chat, which reads no CLI arguments. */
agentArgsApply: boolean
- commandTemplate: string
+ recipe: SourceControlActionRecipe
saveTargetValue: string
actionId: SourceControlLaunchActionId
repoId?: string | null
@@ -41,6 +49,9 @@ type RunSourceControlAgentActionStartArgs = {
commandInput: string
/** Omitted when CLI arguments do not apply, so the launch resolves the global setting. */
agentArgs?: string
+ /** The action's saved choice, on either route. */
+ sessionOptions?: Readonly>
+ sessionOptionsYieldToAgentArgs?: true
}) => boolean | Promise
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
@@ -60,7 +71,7 @@ export async function runSourceControlAgentActionStart({
trimmedCommandInput,
agentArgs,
agentArgsApply,
- commandTemplate,
+ recipe,
saveTargetValue,
actionId,
repoId,
@@ -94,7 +105,40 @@ export async function runSourceControlAgentActionStart({
let launchFailureNotified = false
let launchAcceptedNotified = false
// Undefined selects global agent arguments; empty string would suppress that fallback.
- const launchAgentArgs = agentArgsApply ? agentArgs : undefined
+ let launchAgentArgs = agentArgsApply ? agentArgs : undefined
+ const saveTarget = resolveSourceControlAgentSaveTarget(saveTargetValue, repoId)
+ // The dialog edits agent, arguments and prompt; the model stays the saved one for that agent.
+ // Why: a recipe being saved launches as it will resolve once saved, so the next launch matches.
+ let launchRecipe = recipe
+ let recipeToSave = recipe
+ if (saveTarget && settings) {
+ const saved = saveSourceControlActionRecipe({
+ target: saveTarget,
+ settings,
+ repo,
+ actionId,
+ recipe
+ })
+ recipeToSave =
+ ('sourceControlAi' in saved
+ ? saved.sourceControlAi.actions?.[actionId]
+ : saved.update.sourceControlAi?.actionOverrides?.[actionId]) ?? recipe
+ launchRecipe = resolveSourceControlActionRecipe({
+ settings:
+ 'sourceControlAi' in saved
+ ? { ...settings, sourceControlAi: saved.sourceControlAi }
+ : settings,
+ repo:
+ 'update' in saved ? { sourceControlAi: saved.update.sourceControlAi ?? undefined } : repo,
+ actionId
+ })
+ }
+ launchAgentArgs = sourceControlActionAgentArgs(
+ { ...launchRecipe, agentArgs: launchAgentArgs },
+ selectedAgent,
+ settings
+ )
+ const launchOptions = sourceControlActionLaunchOptions(launchRecipe, selectedAgent)
const notifyLaunchAccepted = (): void => {
if (launchAcceptedNotified) {
return
@@ -106,7 +150,8 @@ export async function runSourceControlAgentActionStart({
launched = await onStart({
agent: selectedAgent,
commandInput: trimmedCommandInput,
- agentArgs: launchAgentArgs
+ agentArgs: launchAgentArgs,
+ ...launchOptions
})
if (launched) {
notifyLaunchAccepted()
@@ -119,6 +164,7 @@ export async function runSourceControlAgentActionStart({
groupId: groupId ?? worktreeId,
prompt: trimmedCommandInput,
agentArgs: launchAgentArgs,
+ ...launchOptions,
promptDelivery,
launchPlatform,
launchSource
@@ -157,25 +203,19 @@ export async function runSourceControlAgentActionStart({
return false
}
- const saveTarget = resolveSourceControlAgentSaveTarget(saveTargetValue, repoId)
- const launchRecipe = {
- agentId: selectedAgent,
- commandInputTemplate: commandTemplate,
- agentArgs
- }
const launchRecipeAlreadySaved = Boolean(
saveTarget &&
- sourceControlActionRecipeMatchesTarget({
+ sourceControlActionRecipeSaveMatchesTarget({
actionId,
target: saveTarget,
- recipe: launchRecipe,
+ recipe: recipeToSave,
settings,
repo
})
)
if (saveTarget && onSaveAgentDefault && !launchRecipeAlreadySaved) {
try {
- await onSaveAgentDefault(saveTarget, actionId, launchRecipe)
+ await onSaveAgentDefault(saveTarget, actionId, recipeToSave)
} catch (error) {
// Prompt delivery succeeded; recipe-save failure must not prevent accepted-launch callbacks from settling.
console.error('onSaveAgentDefault failed', error)
diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts
index 83055b58e77..6ead241e867 100644
--- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts
@@ -9,7 +9,10 @@ import {
type SourceControlActionId,
type SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
-import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
+import {
+ saveSourceControlActionRecipe,
+ type SourceControlAiWriteTarget
+} from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
@@ -17,12 +20,14 @@ type NormalizedSourceControlActionRecipe = {
agentId: SourceControlActionRecipe['agentId'] | null
commandInputTemplate: string
agentArgs: string
+ launchPreferences: string
}
type PersistedSourceControlActionRecipe = {
agentId?: SourceControlActionRecipe['agentId']
commandInputTemplate?: string | null
agentArgs?: string | null
+ launchPreferences?: SourceControlActionRecipe['launchPreferences']
}
function normalizeSourceControlActionRecipeForComparison(
@@ -35,7 +40,17 @@ function normalizeSourceControlActionRecipeForComparison(
typeof recipe?.commandInputTemplate === 'string'
? recipe.commandInputTemplate.trim()
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
- agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : ''
+ agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : '',
+ launchPreferences: recipe?.launchPreferences
+ ? JSON.stringify([
+ recipe.launchPreferences.agentId,
+ recipe.launchPreferences.model ?? null,
+ Object.entries(recipe.launchPreferences.options ?? {}).sort(([a], [b]) =>
+ a.localeCompare(b)
+ ),
+ recipe.launchPreferences.permissionMode ?? null
+ ])
+ : ''
}
}
@@ -46,7 +61,8 @@ function sourceControlActionRecipesMatch(
return (
left.agentId === right.agentId &&
left.commandInputTemplate === right.commandInputTemplate &&
- left.agentArgs === right.agentArgs
+ left.agentArgs === right.agentArgs &&
+ left.launchPreferences === right.launchPreferences
)
}
@@ -62,11 +78,7 @@ function readSavedSourceControlActionRecipeAtTarget(input: {
if (!repoRecipe) {
return null
}
- return resolveSourceControlActionRecipe({
- actionId: input.actionId,
- settings: input.settings,
- repo: input.repo
- })
+ return repoRecipe
}
const source = normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
@@ -106,7 +118,10 @@ export function sourceControlActionRecipeMatchesTarget(input: {
return false
}
const current = normalizeSourceControlActionRecipeForComparison(input.actionId, input.recipe)
- const saved = normalizeSourceControlActionRecipeForComparison(input.actionId, savedRecipe)
+ const saved = normalizeSourceControlActionRecipeForComparison(
+ input.actionId,
+ input.target.type === 'repo' ? resolveSourceControlActionRecipe(input) : savedRecipe
+ )
if (!sourceControlActionRecipesMatch(current, saved)) {
return false
}
@@ -115,3 +130,30 @@ export function sourceControlActionRecipeMatchesTarget(input: {
}
return (input.customAgentCommand ?? '').trim() === readSavedCustomAgentCommandAtTarget(input)
}
+
+export function sourceControlActionRecipeSaveMatchesTarget(
+ input: Parameters[0]
+): boolean {
+ const stored = readSavedSourceControlActionRecipeAtTarget(input)
+ if (!stored || !input.settings) {
+ return false
+ }
+ const saved = saveSourceControlActionRecipe({ ...input, settings: input.settings })
+ const planned =
+ 'sourceControlAi' in saved
+ ? saved.sourceControlAi.actions?.[input.actionId]
+ : saved.update.sourceControlAi?.actionOverrides?.[input.actionId]
+ // A complete save owns these fields; inherited equal values do not establish that ownership.
+ return Boolean(
+ planned &&
+ stored.agentId === planned.agentId &&
+ stored.commandInputTemplate === planned.commandInputTemplate &&
+ stored.agentArgs === planned.agentArgs &&
+ sourceControlActionRecipesMatch(
+ normalizeSourceControlActionRecipeForComparison(input.actionId, stored),
+ normalizeSourceControlActionRecipeForComparison(input.actionId, planned)
+ ) &&
+ (!isCustomAgentId(planned.agentId) ||
+ (input.customAgentCommand ?? '').trim() === readSavedCustomAgentCommandAtTarget(input))
+ )
+}
diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts
index ff33aee447b..c2f255cc68b 100644
--- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts
@@ -1,6 +1,7 @@
import type { getAgentCatalog } from '@/lib/agent-catalog'
import type { useAppStore } from '@/store'
import type { useRepoById } from '@/store/selectors'
+import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
@@ -21,6 +22,7 @@ export type UseSourceControlAgentActionDialogResult = {
agentArgs: string
/** False when this launch would be a structured native chat session, which reads no CLI arguments. */
agentArgsApply: boolean
+ recipe: SourceControlActionRecipe
commandTemplate: string
saveLaunchRecipe: boolean
saveTargetValue: string
diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts
index d5cb86112fc..62610470f30 100644
--- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts
@@ -1,3 +1,5 @@
+import { useAppStore } from '@/store'
+import { ensureDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -146,3 +148,16 @@ export function buildSourceControlAgentScopeNote(launchAgentScope: {
globalAgentLabel: labelFor(launchAgentScope.globalAgentId)
}
}
+
+/** The workspace owner answers detection, including a paired host's SSH workspace. */
+export function detectSourceControlActionAgents(
+ worktreeId: string | null | undefined,
+ connectionId: string | null | undefined
+): Promise {
+ const state = useAppStore.getState()
+ return worktreeId
+ ? ensureDetectedAgentsForWorktree(state, worktreeId)
+ : typeof connectionId === 'string'
+ ? state.ensureRemoteDetectedAgents(connectionId)
+ : state.ensureDetectedAgents()
+}
diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts
index 7eddcfd5942..ad457587065 100644
--- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts
@@ -11,12 +11,15 @@ import {
ensureDetectedAgentsForWorktree,
type WorktreeAgentInventoryState
} from '@/lib/agent-detection-target-inventory'
-import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup'
import {
pickSourceControlLaunchAgent,
readSourceControlLaunchRecipeAgentId
} from '../../../../../../shared/source-control-launch-agent-selection'
import { isTuiAgentEnabled } from '../../../../../../shared/tui-agent-selection'
+import {
+ sourceControlActionAgentArgs,
+ sourceControlActionLaunchOptions
+} from '../../../../../../shared/source-control-action-launch-preferences'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
@@ -119,14 +122,6 @@ export async function launchSourceControlRecoveryAgentWithDefault({
const detectedAgents = [...(choices.detected ?? [])]
const store = getStoreState()
const savedRecipe = getLaunchActionRecipe(actionId)
- const agentArgsPlan = planAgentCliArgsSuffix(
- savedRecipe.agentArgs,
- activeSourceControlLaunchPlatform === 'win32' ? 'powershell' : 'posix'
- )
- if (!agentArgsPlan.ok) {
- toast.error(agentArgsPlan.error)
- return false
- }
if (!basePrompt) {
toast.error(copy.promptUnavailable)
return false
@@ -171,7 +166,8 @@ export async function launchSourceControlRecoveryAgentWithDefault({
worktreeId: activeWorktreeId,
groupId: activeGroupId ?? activeWorktreeId,
prompt,
- agentArgs: savedRecipe.agentArgs,
+ agentArgs: sourceControlActionAgentArgs(savedRecipe, agent, store.settings),
+ ...sourceControlActionLaunchOptions(savedRecipe, agent),
promptDelivery: 'submit-after-ready',
launchPlatform: activeSourceControlLaunchPlatform,
launchSource: 'source_control_recovery'
diff --git a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts
index 81f7b92af97..ed09e6a52f1 100644
--- a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts
+++ b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts
@@ -7,6 +7,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { isSourceControlAgentDetectedAndEnabled } from './source-control-agent-action-dialog-support'
+import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
type SavedSourceControlAgentActionTargetValue = 'repo' | 'global'
@@ -36,10 +37,7 @@ type UseSavedSourceControlAgentActionAutoStartArgs = {
isStarting: boolean
detectedAgents: TuiAgent[]
disabledAgents: TuiAgent[] | undefined
- onAutoStart: (args: {
- detectedAgents: TuiAgent[]
- saveTargetValue: SavedSourceControlAgentActionTargetValue
- }) => Promise
+ onAutoStart: (args: { detectedAgents: TuiAgent[] }) => Promise
}
type SavedSourceControlAgentActionAutoStartResult = {
@@ -57,6 +55,7 @@ function buildSavedLaunchRecipe(input: {
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
+ savedLaunchPreferences?: SourceControlActionRecipe['launchPreferences']
}): SourceControlActionRecipe | null {
if (!input.savedAgentId) {
return null
@@ -64,7 +63,8 @@ function buildSavedLaunchRecipe(input: {
return {
agentId: input.savedAgentId,
commandInputTemplate: input.savedCommandInputTemplate ?? '{basePrompt}',
- agentArgs: input.savedAgentArgs ?? ''
+ agentArgs: input.savedAgentArgs ?? '',
+ ...(input.savedLaunchPreferences ? { launchPreferences: input.savedLaunchPreferences } : {})
}
}
@@ -111,6 +111,7 @@ function buildReceiptKey(input: {
savedAgentId: TuiAgent
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
+ savedLaunchPreferences?: SourceControlActionRecipe['launchPreferences']
repoId?: string | null
connectionId?: string | null
worktreeId?: string | null
@@ -122,6 +123,7 @@ function buildReceiptKey(input: {
input.savedAgentId,
input.savedCommandInputTemplate ?? '{basePrompt}',
input.savedAgentArgs ?? '',
+ input.savedLaunchPreferences ?? null,
input.repoId ?? null,
input.connectionId ?? null,
input.worktreeId ?? null,
@@ -156,14 +158,19 @@ export function useSavedSourceControlAgentActionAutoStart({
const autoStartedOpenCycleRef = useRef(0)
const [receiptState, setReceiptState] = useState(null)
+ const savedLaunchPreferences = useMemo(() => {
+ const effective = resolveSourceControlActionRecipe({ settings, repo, actionId })
+ return effective.agentId === savedAgentId ? effective.launchPreferences : undefined
+ }, [actionId, repo, savedAgentId, settings])
const savedLaunchRecipe = useMemo(
() =>
buildSavedLaunchRecipe({
savedAgentId,
savedCommandInputTemplate,
- savedAgentArgs
+ savedAgentArgs,
+ savedLaunchPreferences
}),
- [savedAgentArgs, savedAgentId, savedCommandInputTemplate]
+ [savedAgentArgs, savedAgentId, savedCommandInputTemplate, savedLaunchPreferences]
)
const matchedSavedReceiptTargetValue = useMemo(
() =>
@@ -186,6 +193,7 @@ export function useSavedSourceControlAgentActionAutoStart({
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
+ savedLaunchPreferences,
repoId,
connectionId,
worktreeId,
@@ -200,6 +208,7 @@ export function useSavedSourceControlAgentActionAutoStart({
savedAgentArgs,
savedAgentId,
savedCommandInputTemplate,
+ savedLaunchPreferences,
worktreeId
])
@@ -262,10 +271,7 @@ export function useSavedSourceControlAgentActionAutoStart({
return
}
autoStartedOpenCycleRef.current = openCycle
- void onAutoStart({
- detectedAgents,
- saveTargetValue: matchedSavedReceiptTargetValue
- })
+ void onAutoStart({ detectedAgents })
.then((launched) => {
if (!launched) {
revealDialog()
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx
new file mode 100644
index 00000000000..a1a5cc449f9
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx
@@ -0,0 +1,85 @@
+// @vitest-environment happy-dom
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { GlobalSettings } from '../../../../shared/global-settings-types'
+import { getDefaultSettings } from '../../../../shared/constants'
+const mocks = vi.hoisted(
+ (): { settings: GlobalSettings | null; agents: ReturnType } => ({
+ settings: null,
+ agents: vi.fn().mockResolvedValue(['claude', 'codex'])
+ })
+)
+vi.mock('@/store', () => ({
+ useAppStore: Object.assign(
+ (select: (state: unknown) => unknown) => select({ settings: mocks.settings }),
+ { getState: () => ({ settings: mocks.settings, ensureDetectedAgents: mocks.agents }) }
+ )
+}))
+vi.mock('@/store/selectors', () => ({ useRepoById: () => null }))
+vi.mock('@/runtime/local-runtime-capabilities', () => ({
+ ensureLocalRuntimeCapabilities: async () => [],
+ readLocalRuntimeCapabilitiesOrUnknown: () => []
+}))
+vi.mock('./source-control-launch-agent-args-applicability', () => ({
+ sourceControlLaunchAppliesAgentArgs: () => false
+}))
+vi.mock('./use-source-control-launchable-agents', () => ({
+ useSourceControlLaunchableAgents: () => ({
+ detecting: false,
+ refreshAgents: mocks.agents,
+ refreshLaunchableAgents: mocks.agents,
+ launchableAgents: ['claude', 'codex']
+ })
+}))
+vi.mock('./useSavedSourceControlAgentActionAutoStart', () => ({
+ useSavedSourceControlAgentActionAutoStart: () => ({ autoLaunchPending: false })
+}))
+vi.mock('./useSourceControlAgentActionStart', () => ({
+ useSourceControlAgentActionStart: () => ({
+ deliveryPlan: { status: 'idle' },
+ resetDeliveryPlan: () => {},
+ isStarting: false,
+ handleStart: async () => {},
+ startWithDetectedAgents: async () => true
+ })
+}))
+import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog'
+afterEach(cleanup)
+it('keeps a default recipe and dormant choice through display changes, then records an actual edit', async () => {
+ mocks.settings = { ...getDefaultSettings('/tmp/pr-30449'), defaultTuiAgent: 'codex' }
+ const launchPreferences = {
+ agentId: 'codex' as const,
+ model: 'chosen',
+ options: { effort: 'high' }
+ }
+ mocks.settings.sourceControlAi!.actions!.resolveConflicts = { agentId: null, launchPreferences }
+ const { result, rerender } = renderHook(() =>
+ useSourceControlAgentActionDialog({
+ open: true,
+ onOpenChange: () => {},
+ actionId: 'resolveConflicts',
+ title: '',
+ description: '',
+ baseCommandInput: 'fix',
+ launchSource: 'source_control_recovery',
+ savedAgentId: null
+ })
+ )
+ await waitFor(() => expect(result.current.detecting).toBe(false))
+ expect(result.current.selectedAgent).toBe('codex')
+ act(() => result.current.onCommandTemplateChange('{basePrompt} edited'))
+ mocks.settings = { ...mocks.settings, defaultTuiAgent: 'claude' }
+ rerender()
+ await waitFor(() => expect(result.current.detecting).toBe(false))
+ expect(result.current.selectedAgent).toBe('claude')
+ expect(result.current.recipe).toMatchObject({
+ agentId: null,
+ launchPreferences,
+ commandInputTemplate: '{basePrompt} edited'
+ })
+ act(() => result.current.onSelectedAgentChange('codex'))
+ expect(result.current.recipe).toMatchObject({ agentId: 'codex', launchPreferences })
+ act(() => result.current.onSelectedAgentChange('claude'))
+ expect(result.current.recipe.agentId).toBe('claude')
+ expect(result.current.recipe.launchPreferences).toBeUndefined()
+})
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
index dde8f28f3ee..00ab33e7e3c 100644
--- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
pickSourceControlLaunchAgent,
resolveSourceControlLaunchAgentScope
} from '../../../../shared/source-control-launch-agent-selection'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
+import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
+import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -56,11 +59,18 @@ export function useSourceControlAgentActionDialog({
// Update existing repo overrides in place; a new global default would remain shadowed.
const defaultSaveTargetValue =
launchAgentScope.overridesGlobalAgent && repoId ? 'repo' : DEFAULT_SAVE_TARGET_VALUE
- const [commandTemplate, setCommandTemplate] = useState(
- savedCommandInputTemplate ?? '{basePrompt}'
+ const initialRecipe = useCallback(
+ (): SourceControlActionRecipe => ({
+ ...resolveSourceControlActionRecipe({ settings, repo, actionId }),
+ agentId: savedAgentId ?? null,
+ commandInputTemplate: savedCommandInputTemplate ?? '{basePrompt}',
+ agentArgs: savedAgentArgs ?? ''
+ }),
+ [settings, repo, actionId, savedAgentId, savedCommandInputTemplate, savedAgentArgs]
)
- const [agentArgs, setAgentArgs] = useState(savedAgentArgs ?? '')
- const [selectedAgent, setSelectedAgent] = useState(savedAgentId ?? null)
+ const [recipe, setRecipe] = useState(initialRecipe)
+ const commandTemplate = recipe.commandInputTemplate ?? '{basePrompt}'
+ const agentArgs = recipe.agentArgs ?? ''
const openCycleRef = useRef(0)
const wasOpenRef = useRef(false)
const [openCycle, setOpenCycle] = useState(0)
@@ -91,32 +101,19 @@ export function useSourceControlAgentActionDialog({
if (!wasOpenRef.current) {
openCycleRef.current = cycle
setOpenCycle(cycle)
+ setRecipe(initialRecipe())
+ setSaveLaunchRecipe(true)
+ setSaveTargetValue(defaultSaveTargetValue)
}
wasOpenRef.current = true
setDiscovery({ cycle, outcome: 'pending' })
- setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
- setAgentArgs(savedAgentArgs ?? '')
- setSelectedAgent(savedAgentId ?? null)
- setSaveLaunchRecipe(true)
- setSaveTargetValue(defaultSaveTargetValue)
let stale = false
// Settle CLI-argument applicability with discovery so structured launches never flash an inapplicable field.
void refreshAgents().then(
- ({ detected, launchable }) => {
+ () => {
if (stale || openCycleRef.current !== cycle) {
return
}
- setSelectedAgent(
- (current) =>
- current ??
- pickSourceControlLaunchAgent({
- savedAgent: savedAgentId,
- defaultAgent: settings?.defaultTuiAgent,
- detectedAgents: detected,
- disabledAgents,
- chosenOpensAsChat: (agent) => launchable.includes(agent)
- })
- )
setDiscovery({ cycle, outcome: 'success' })
},
(error: unknown) => {
@@ -129,20 +126,18 @@ export function useSourceControlAgentActionDialog({
return () => {
stale = true
}
- }, [
- defaultSaveTargetValue,
- disabledAgents,
- open,
- refreshAgents,
- savedAgentId,
- savedAgentArgs,
- savedCommandInputTemplate,
- repoId,
- settings?.defaultTuiAgent
- ])
+ }, [defaultSaveTargetValue, disabledAgents, open, refreshAgents, initialRecipe])
const closeDialog = useCallback(() => onOpenChange(false), [onOpenChange])
+ const selectedAgent =
+ recipe.agentId && !isCustomAgentId(recipe.agentId)
+ ? recipe.agentId
+ : pickSourceControlLaunchAgent({
+ defaultAgent: settings?.defaultTuiAgent,
+ detectedAgents: launchableAgents,
+ disabledAgents
+ })
const { agentOptions, selectedAgentUnavailable, hasEnabledAgents } = useMemo(
() =>
buildSourceControlAgentAvailability({
@@ -179,7 +174,7 @@ export function useSourceControlAgentActionDialog({
trimmedCommandInput,
agentArgs,
agentArgsApply,
- commandTemplate,
+ recipe,
saveLaunchRecipe,
saveTargetValue,
actionId,
@@ -247,10 +242,10 @@ export function useSourceControlAgentActionDialog({
isStarting,
detectedAgents: launchableAgents,
disabledAgents,
- onAutoStart: ({ detectedAgents: agentsForLaunch, saveTargetValue: matchedTargetValue }) =>
+ onAutoStart: ({ detectedAgents: agentsForLaunch }) =>
startWithDetectedAgents({
detectedAgents: agentsForLaunch,
- saveTargetValueOverride: matchedTargetValue
+ saveTargetValueOverride: 'none'
})
})
@@ -271,11 +266,18 @@ export function useSourceControlAgentActionDialog({
},
[resetDeliveryPlan]
)
- const onSelectedAgentChange = useMemo(() => resetPlanAfter(setSelectedAgent), [resetPlanAfter])
- const onAgentArgsChange = useMemo(() => resetPlanAfter(setAgentArgs), [resetPlanAfter])
- const onCommandTemplateChange = useMemo(
- () => resetPlanAfter(setCommandTemplate),
- [resetPlanAfter]
+ const onSelectedAgentChange = resetPlanAfter((agent: TuiAgent | null) =>
+ setRecipe(({ launchPreferences, ...current }) => ({
+ ...current,
+ agentId: agent,
+ ...(launchPreferences?.agentId === agent ? { launchPreferences } : {})
+ }))
+ )
+ const onAgentArgsChange = resetPlanAfter((agentArgs: string) =>
+ setRecipe((current) => ({ ...current, agentArgs }))
+ )
+ const onCommandTemplateChange = resetPlanAfter((commandInputTemplate: string) =>
+ setRecipe((current) => ({ ...current, commandInputTemplate }))
)
const onSaveLaunchRecipeChange = useMemo(
() => resetPlanAfter(setSaveLaunchRecipe),
@@ -299,6 +301,7 @@ export function useSourceControlAgentActionDialog({
agentArgs,
agentArgsApply,
commandTemplate,
+ recipe,
saveLaunchRecipe,
saveTargetValue,
saveTargets,
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts
index 8fdef3042e0..6798808bc11 100644
--- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts
@@ -23,7 +23,7 @@ type UseSourceControlAgentActionStartArgs = {
agentArgs: string
/** False when the launch would be structured native chat, which reads no CLI arguments. */
agentArgsApply: boolean
- commandTemplate: string
+ recipe: SourceControlActionRecipe
saveLaunchRecipe: boolean
saveTargetValue: string
actionId: SourceControlLaunchActionId
@@ -45,6 +45,9 @@ type UseSourceControlAgentActionStartArgs = {
commandInput: string
/** Omitted when CLI arguments do not apply, so the launch resolves the global setting. */
agentArgs?: string
+ /** The action's saved choice, on either route. */
+ sessionOptions?: Readonly>
+ sessionOptionsYieldToAgentArgs?: true
}) => boolean | Promise
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
@@ -81,7 +84,7 @@ export function useSourceControlAgentActionStart({
trimmedCommandInput,
agentArgs,
agentArgsApply,
- commandTemplate,
+ recipe,
saveLaunchRecipe,
saveTargetValue,
actionId,
@@ -156,7 +159,7 @@ export function useSourceControlAgentActionStart({
trimmedCommandInput,
agentArgs,
agentArgsApply,
- commandTemplate,
+ recipe,
saveTargetValue: saveLaunchRecipe ? (saveTargetValueOverride ?? saveTargetValue) : 'none',
actionId,
repoId,
@@ -191,7 +194,7 @@ export function useSourceControlAgentActionStart({
agentArgs,
agentArgsApply,
commandInput,
- commandTemplate,
+ recipe,
connectionUnavailable,
groupId,
isRemote,
diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx
index f5fb08aed20..d96c66bb090 100644
--- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx
+++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx
@@ -3,9 +3,42 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentCommandOverrideInput, AgentDefaultArgsInput } from './AgentLaunchDefaultsEditor'
+import { TooltipProvider } from '../ui/tooltip'
afterEach(cleanup)
+it.each(['', '--default'])(
+ 'hides the %j placeholder while a permission chip is present',
+ (defaultArgs) => {
+ const editor = (withChip: boolean) => (
+
+ null,
+ onRemove: vi.fn()
+ }
+ : undefined
+ }
+ />
+
+ )
+ const view = render(editor(true))
+ expect(screen.getByRole('button', { name: /Remove --permission-mode default/ })).toBeDefined()
+ const input = screen.getByRole('textbox', { name: 'Arguments' })
+ expect(input).toHaveProperty('placeholder', '')
+ expect(input).toHaveProperty('value', '')
+ view.rerender(editor(false))
+ expect(input).toHaveProperty('placeholder', defaultArgs || 'No default arguments')
+ }
+)
+
describe('launch field reset ordering', () => {
it.each(['arguments', 'command'] as const)(
'honors the first Reset click after blurring a focused %s field',
diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx
index f402891bbc3..085d8d626ae 100644
--- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx
+++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx
@@ -88,7 +88,9 @@ export function AgentDefaultArgsInput({
argsOverride,
onSaveArgs,
permissionChip,
- note
+ note,
+ disabled = false,
+ onDraftChange
}: {
defaultArgs: string
argsOverride: string
@@ -100,12 +102,14 @@ export function AgentDefaultArgsInput({
onRemove: () => void
}
note?: string
+ disabled?: boolean
+ onDraftChange?: (value: string) => void
}): React.JSX.Element {
const [draft, setDraft] = useState(null)
const inputRef = useRef(null)
const chipRef = useRef(null)
const chipButtonRef = useRef(null)
- const argsDraft = draft ?? argsOverride
+ const argsDraft = onDraftChange ? argsOverride : (draft ?? argsOverride)
const { pending, write } = useAgentLaunchFieldWrite()
useLayoutEffect(() => {
const input = inputRef.current
@@ -126,11 +130,11 @@ export function AgentDefaultArgsInput({
return () => observer.disconnect()
}, [permissionChip?.flag])
const commitArgs = (): void => {
- if (pending || draft === null) {
+ if (disabled || pending || (!onDraftChange && draft === null)) {
return
}
- const value = draft.trim()
- if (value === argsOverride) {
+ const value = argsDraft.trim()
+ if (!onDraftChange && value === argsOverride) {
setDraft(null)
return
}
@@ -142,11 +146,11 @@ export function AgentDefaultArgsInput({
}
return (
-
-
+
+
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
-
+
{
@@ -165,9 +169,14 @@ export function AgentDefaultArgsInput({
{...permissionChip}
chipRef={chipRef}
buttonRef={chipButtonRef}
- disabled={pending}
+ disabled={disabled || pending}
onRemove={() => {
- if (draft !== null) {
+ if (disabled || pending) {
+ return
+ }
+ if (onDraftChange) {
+ onDraftChange(permissionChip.removeRecipe(argsDraft) ?? argsDraft)
+ } else if (draft !== null) {
setDraft(permissionChip.removeRecipe(draft) ?? draft)
}
inputRef.current?.focus()
@@ -180,9 +189,16 @@ export function AgentDefaultArgsInput({
ref={inputRef}
rows={1}
value={argsDraft}
+ disabled={disabled}
readOnly={pending}
- onChange={(event) => setDraft(event.target.value.replace(/[\r\n]/g, ''))}
+ aria-label={translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
+ onChange={(event) =>
+ (onDraftChange ?? setDraft)(event.target.value.replace(/[\r\n]/g, ''))
+ }
onKeyDown={(event) => {
+ if (disabled || pending) {
+ return
+ }
if (
permissionChip &&
event.key === 'Backspace' &&
@@ -206,8 +222,13 @@ export function AgentDefaultArgsInput({
}
}}
placeholder={
- defaultArgs ||
- translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments')
+ permissionChip
+ ? undefined
+ : defaultArgs ||
+ translate(
+ 'auto.components.settings.AgentsPane.6f99bf5dd0',
+ 'No default arguments'
+ )
}
spellCheck={false}
className="block min-h-5 [field-sizing:content]"
@@ -218,8 +239,11 @@ export function AgentDefaultArgsInput({
type="button"
variant="ghost"
size="xs"
+ disabled={disabled}
onClick={() => {
- setDraft(argsDraft)
+ if (!onDraftChange) {
+ setDraft(argsDraft)
+ }
write(
defaultArgs,
() => onSaveArgs(defaultArgs),
diff --git a/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx b/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx
index 103a2217019..63f19ef2dfa 100644
--- a/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx
+++ b/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx
@@ -5,6 +5,7 @@ import type { AgentPermissionChoice } from '../../../../shared/tui-agent-permiss
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
+import { Label } from '../ui/label'
import {
DropdownMenu,
DropdownMenuContent,
@@ -174,31 +175,53 @@ export type AgentPermissionChoiceRow = {
export function AgentPermissionMenu({
agentId,
label,
- permission
+ permission,
+ inheritLabel,
+ disabled,
+ size = 'xs',
+ layout = 'inline'
}: {
agentId: string
label: string
permission: AgentPermissionChoiceRow
+ inheritLabel?: string
+ disabled?: boolean
+ size?: 'xs' | 'sm'
+ layout?: 'inline' | 'stacked'
}): React.JSX.Element {
- const followAll = translate('settings.agents.permissionSameAsAll', 'Same as all agents')
+ const followAll =
+ inheritLabel ?? translate('settings.agents.permissionSameAsAll', 'Same as all agents')
const current = agentPermissionChoiceLabel(permission.choice, label)
return (
-
-
- {translate('settings.agents.rowPermissions', 'Permissions')}
-
+
+ {layout === 'stacked' ? (
+
+ ) : (
+
+ {translate('settings.agents.rowPermissions', 'Permissions')}
+
+ )}
-
+