diff --git a/config/vitest.config.ts b/config/vitest.config.ts
index adae6fa4d13..5647a9a1710 100644
--- a/config/vitest.config.ts
+++ b/config/vitest.config.ts
@@ -21,15 +21,7 @@ const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-e
const paletteMeasurementFile =
'src/renderer/src/lib/palette-match/palette-match-performance.test.ts'
const measurementFiles = [measurementFile, paletteMeasurementFile]
-const phoneContractModules = [
- 'mobile/src/session/native-chat/mobile-native-chat-stream-frame.ts',
- 'mobile/src/session/mobile-native-chat-render-data.ts',
- 'mobile/src/i18n/mobile-i18n.ts',
- 'mobile/src/i18n/locale-catalog-imports.ts'
-].map((file) => normalizePath(resolve(file)))
-const phoneContractPattern = new RegExp(
- `^(?:${phoneContractModules.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?:\\?.*)?$`
-)
+const phoneContractPattern = /\/mobile\/src\/.*\.(?:ts|tsx)(?:\?.*)?$/
const transforms = {
oxc: { exclude: [/\.js$/, phoneContractPattern] },
plugins: [
@@ -39,7 +31,7 @@ const transforms = {
enforce: 'pre',
async transform(source, id) {
const filename = normalizePath(id.split('?')[0])
- if (!phoneContractModules.includes(filename)) {
+ if (!phoneContractPattern.test(filename)) {
return null
}
const result = await transformWithOxc(source, filename, { tsconfig: false })
diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts
index dd87bfa2db1..a9c4d8d9493 100644
--- a/mobile/src/session/pr-ai-triage-launch.test.ts
+++ b/mobile/src/session/pr-ai-triage-launch.test.ts
@@ -191,7 +191,7 @@ describe('launchAgentWithPrompt', () => {
expect(launchParams(sendRequest)).toHaveProperty('agentArgs', '--model opus')
})
- it('keeps inherited phone options without raw conflicts when the workspace shell is unknown', async () => {
+ it('forwards the repository raw group when the workspace shell is unknown', async () => {
const settings = {
defaultTuiAgent: 'codex' as const,
sourceControlAi: {
@@ -213,7 +213,7 @@ describe('launchAgentWithPrompt', () => {
}
expect(
resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixChecks' })
- ).toMatchObject({ launchPreferencesYieldToAgentArgs: true })
+ ).not.toHaveProperty('launchPreferences')
const { client, sendRequest } = hostClient({
settings,
repo,
@@ -224,8 +224,8 @@ describe('launchAgentWithPrompt', () => {
kind: 'sent'
})
const params = AgentLaunchReplay.parse(launchParams(sendRequest))
- expect(params).toMatchObject({ surface: 'terminal', chatOptions: { model: 'gpt-5.4' } })
- expect(params).not.toHaveProperty('agentArgs')
+ expect(params).toMatchObject({ surface: 'terminal', agentArgs: '--model gpt-5.3-codex' })
+ expect(params).not.toHaveProperty('chatOptions')
const preferences = toAgentLaunchPreferences(params.chatOptions, params.agent)
const startup = buildAgentStartupPlan({
...resolveAgentStartupPlanInputs({
@@ -239,14 +239,14 @@ describe('launchAgentWithPrompt', () => {
}),
prompt: params.prompt?.text ?? ''
})
- expect(startup?.launchCommand).toContain('gpt-5.4')
- expect(startup?.launchCommand).not.toContain('gpt-5.3-codex')
+ expect(startup?.launchCommand).toContain('gpt-5.3-codex')
+ expect(startup?.launchCommand).not.toContain('gpt-5.4')
sendRequest.mockClear()
await expect(run(client, capabilities)).resolves.toEqual({ kind: 'sent' })
const olderHostParams = AgentLaunchReplay.parse(launchParams(sendRequest))
expect(olderHostParams).not.toHaveProperty('surface')
- expect(olderHostParams).not.toHaveProperty('agentArgs')
+ expect(olderHostParams.agentArgs).toBe('--model gpt-5.3-codex')
expect(olderHostParams.chatOptions).toEqual(params.chatOptions)
})
diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts
index 0a364356a41..7a7b3012cde 100644
--- a/mobile/src/session/pr-ai-triage-launch.ts
+++ b/mobile/src/session/pr-ai-triage-launch.ts
@@ -91,8 +91,7 @@ export async function launchAgentWithPrompt(args: {
worktreeId: args.worktreeId,
agent: resolved.agent,
...(projection.sessionOptions ? { chatOptions: projection.sessionOptions } : {}),
- // The phone lacks the workspace shell needed to project inherited choices over raw flags.
- agentArgs: projection.sessionOptionsYieldToAgentArgs ? undefined : projection.agentArgs,
+ agentArgs: projection.agentArgs,
surface: projection.requestedSurface,
prompt: { text, delivery: 'submit' },
launchSource: args.launchSource
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 a9636894b59..920970bc65e 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
@@ -119,6 +119,18 @@ describe('Store', () => {
options: { thinkingEffort: 'high' }
}
}
+ const canonicalRecipe = {
+ ...recipe,
+ launchPreferences: {
+ agentId: 'codex',
+ modelSelection: {
+ base: 'default',
+ model: { agentId: 'codex', value: 'gpt-6-sol' },
+ options: { thinkingEffort: { agentId: 'codex', value: 'high' } }
+ },
+ permissionMode: 'ask'
+ }
+ }
const sparse = { agentArgs: '--model gpt-6-sol' }
const repo = makeRepo()
writeDataFile({
@@ -135,14 +147,16 @@ describe('Store', () => {
})
const store = await createStore()
- expect(store.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe)
+ expect(store.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(canonicalRecipe)
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.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(
+ canonicalRecipe
+ )
expect(reopened.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual(
sparse
)
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 03065fdd6ab..d6e6f1a2f5d 100644
--- a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts
+++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts
@@ -90,17 +90,21 @@ export function prepareLoadedTerminalSettings(
markNeedsSave()
}
const legacyCommitMessageAi = parsed.settings?.commitMessageAi
- let migratedSourceControlAi = rawSourceControlAiMissing
+ const sourceBeforeNormalization = rawSourceControlAiMissing
? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi)
- : mergeLegacyCommitMessageAiIntoSourceControlAi(
- parsed.settings?.sourceControlAi,
- legacyCommitMessageAi
- )
- const actions = migrateActionPermissionArgs(migratedSourceControlAi.actions)
- if (actions !== migratedSourceControlAi.actions) {
- migratedSourceControlAi = { ...migratedSourceControlAi, actions }
+ : rawSourceControlAi
+ const sourceActions = {
+ ...sourceBeforeNormalization.launchActionDefaults,
+ ...sourceBeforeNormalization.actions
+ }
+ const actions = migrateActionPermissionArgs(sourceActions)
+ if (actions !== sourceActions) {
markNeedsSave()
}
+ const sourceWithActions = { ...sourceBeforeNormalization, actions }
+ const migratedSourceControlAi = rawSourceControlAiMissing
+ ? sourceWithActions
+ : mergeLegacyCommitMessageAiIntoSourceControlAi(sourceWithActions, legacyCommitMessageAi)
migratedExternalVisibility.repos = migratedExternalVisibility.repos.map((repo) => {
const actionOverrides = migrateActionPermissionArgs(repo.sourceControlAi?.actionOverrides)
if (actionOverrides === repo.sourceControlAi?.actionOverrides) {
diff --git a/src/main/persistence/persistence-load-repair-durability.test.ts b/src/main/persistence/persistence-load-repair-durability.test.ts
index f29931bf256..01653a5afef 100644
--- a/src/main/persistence/persistence-load-repair-durability.test.ts
+++ b/src/main/persistence/persistence-load-repair-durability.test.ts
@@ -1,7 +1,12 @@
-import * as permissionLift from '../../shared/tui-agent-permission-lift'
+import { projectSourceControlActionLaunch } from '../../shared/source-control-action-launch-preferences'
+import { resolveSourceControlActionRecipe } from '../../shared/source-control-ai-operation-policy'
+import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
+import { resolveAgentLaunchCommand } from '../../shared/tui-agent-launch-command'
+import { isTuiAgent } from '../../shared/tui-agent-config'
+import * as reconciliation from '../../shared/agent-choice-reconciliation'
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 choiceArgs from '../../shared/agent-choice-args'
import * as shell from '../../shared/tui-agent-startup-shell'
import { agentPermissionChoices } from '../../shared/tui-agent-permission-choice'
import {
@@ -90,6 +95,72 @@ describe('load-time normalization durability', () => {
rmSync(testState.dir, { recursive: true, force: true })
})
+ it.each(['claude', 'codex'] as const)(
+ 'retains independent %s effort when lifting a model through load, save and reopen',
+ async (agent) => {
+ const settled = await settledProfile()
+ await closeTestStores()
+ const source = settled.settings.sourceControlAi
+ if (!source) {
+ throw new Error('Missing settled Source Control settings')
+ }
+ const model = agent === 'claude' ? 'opus' : 'gpt-5.4'
+ const rawModel = `--model ${model}`
+ const rawEffort = agent === 'claude' ? '--effort low' : '-c model_reasoning_effort=low'
+ const choices = { agentId: agent, options: { effort: 'high' } }
+ settled.settings.sourceControlAi = {
+ ...source,
+ actions: {
+ fixChecks: {
+ agentId: agent,
+ agentArgs: `${rawModel} ${rawEffort}`,
+ launchPreferences: choices
+ }
+ }
+ }
+ settled.repos = [
+ {
+ id: 'independent-effort',
+ displayName: 'recipe',
+ badgeColor: '',
+ addedAt: 0,
+ path: '/tmp/recipe',
+ sourceControlAi: {
+ actionOverrides: {
+ fixChecks: { agentId: agent, agentArgs: rawModel, launchPreferences: choices }
+ }
+ }
+ }
+ ]
+ writeDataFile(settled)
+ const expected = {
+ agentId: agent,
+ modelSelection: { base: 'default', options: { effort: { agentId: agent, value: 'high' } } }
+ }
+ const assertChoices = (store: ReturnType) => {
+ expect(store.getSettings().sourceControlAi?.actions?.fixChecks).toMatchObject({
+ agentArgs: rawEffort,
+ launchPreferences: expected,
+ argumentChoices: { syntaxAgent: agent, nodes: [{ at: 0, text: rawModel }] }
+ })
+ expect(store.getRepos()[0]?.sourceControlAi?.actionOverrides?.fixChecks).toMatchObject({
+ agentArgs: '',
+ launchPreferences: expected,
+ argumentChoices: { syntaxAgent: agent, nodes: [{ at: 0, text: rawModel }] }
+ })
+ }
+ const store = createStore()
+ assertChoices(store)
+ store.flush()
+ await closeTestStores()
+ const persisted = readDataFile()
+ const reopened = createStore()
+ assertChoices(reopened)
+ reopened.flush()
+ expect(readDataFile()).toEqual(persisted)
+ }
+ )
+
const loadWork: number[][] = []
it.each([32, 320])(
'migrates %i owned rows through actual typed-profile load, flush and no-op reopen',
@@ -112,11 +183,19 @@ describe('load-time normalization durability', () => {
actions: {
fixChecks: {
agentId: 'claude',
- agentArgs: '--dangerously-skip-permissions --verbose',
- launchPreferences: { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } }
+ agentArgs:
+ '--model retired-unlisted-id --effort high --dangerously-skip-permissions --verbose'
},
resolveComments: { agentId: 'codex', agentArgs: literal },
- resolveConflicts: { agentId: 'gemini', agentArgs: '--yolo' },
+ resolveConflicts: {
+ agentId: 'claude',
+ agentArgs: '--effort high',
+ launchPreferences: {
+ agentId: 'codex',
+ permissionMode: 'ask',
+ modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } }
+ }
+ },
fixPushFailure: { agentArgs: ' --dangerously-skip-permissions ' },
fixCommitFailure: conflict,
commitMessage: { agentId: 'claude', agentArgs: '--dangerously-skip-permissions' }
@@ -133,17 +212,18 @@ describe('load-time normalization durability', () => {
fixChecks: {
agentId: i % 2 ? 'claude' : 'codex',
agentArgs: (i % 2
- ? '--dangerously-skip-permissions'
- : '--dangerously-bypass-approvals-and-sandbox'
+ ? '--model retired-unlisted-id --effort high --dangerously-skip-permissions'
+ : '-c model=retired-unlisted-id -c model_reasoning_effort=high --dangerously-bypass-approvals-and-sandbox'
).padEnd(64)
},
- resolveComments: { agentArgs: ' --dangerously-bypass-approvals-and-sandbox ' },
+ resolveComments: { agentArgs: ' --model untouched --effort high ' },
+ resolveConflicts: { agentId: 'custom', agentArgs: ' --model untouched --effort high ' },
fixPushFailure: { agentId: null, agentArgs: literal }
}
}
}))
- const lift = vi.spyOn(permissionLift, 'liftAgentPermissionArgs')
- const cut = vi.spyOn(permissionArgs, 'permissionRecipeCut')
+ const lift = vi.spyOn(reconciliation, 'reconcileAgentChoiceArgsDetailed')
+ const cut = vi.spyOn(choiceArgs, 'analyzeAgentChoiceArgs')
const tokenize = vi.spyOn(shell, 'tokenizeStartupCommand')
const prepare = terminalPreparation.prepareLoadedTerminalSettings
const marks = vi.fn()
@@ -156,7 +236,8 @@ describe('load-time normalization durability', () => {
)
writeDataFile(settled)
const store = createStore()
- expect(lift).toHaveBeenCalledTimes(n + 4)
+ // Four global raw recipes plus validation of their six newly canonical terms.
+ expect(lift).toHaveBeenCalledTimes(n + 10)
const work = [
cut.mock.calls.length,
tokenize.mock.calls.length,
@@ -185,13 +266,28 @@ describe('load-time normalization durability', () => {
expect(store.getSettings().sourceControlAi?.actions?.fixChecks).toMatchObject({
agentArgs: '--verbose',
+ argumentChoices: {
+ syntaxAgent: 'claude',
+ nodes: [
+ { at: 0, text: '--model retired-unlisted-id' },
+ { at: 0, text: '--effort high' }
+ ]
+ },
launchPreferences: {
agentId: 'claude',
- model: 'sonnet',
- options: { effort: 'high' },
permissionMode: 'full-access'
}
})
+ if (n === 32) {
+ const failure = vi
+ .spyOn(domainWrites, 'writeProfileStateDomains')
+ .mockImplementationOnce(() => {
+ throw new Error('disk refused')
+ })
+ expect(() => store.flushOrThrow()).toThrow('disk refused')
+ expect(readDataFile()).toEqual(settled)
+ failure.mockRestore()
+ }
store.flush()
await closeTestStores()
marks.mockClear()
@@ -200,18 +296,84 @@ describe('load-time normalization durability', () => {
for (const row of reopened.getRepos()) {
expect(row.sourceControlAi?.actionOverrides?.fixChecks).toMatchObject({
agentArgs: '',
+ argumentChoices: {
+ syntaxAgent: row.sourceControlAi?.actionOverrides?.fixChecks?.agentId,
+ nodes: [
+ {
+ at: 0,
+ text:
+ row.sourceControlAi?.actionOverrides?.fixChecks?.agentId === 'claude'
+ ? '--model retired-unlisted-id'
+ : '-c model=retired-unlisted-id'
+ },
+ {
+ at: 0,
+ text:
+ row.sourceControlAi?.actionOverrides?.fixChecks?.agentId === 'claude'
+ ? '--effort high'
+ : '-c model_reasoning_effort=high'
+ }
+ ]
+ },
launchPreferences: {
agentId: row.sourceControlAi?.actionOverrides?.fixChecks?.agentId,
permissionMode: 'full-access'
}
})
}
+ for (const row of reopened.getRepos().slice(0, 2)) {
+ const recipe = resolveSourceControlActionRecipe({
+ settings: reopened.getSettings(),
+ repo: row,
+ actionId: 'fixChecks'
+ })
+ if (!isTuiAgent(recipe.agentId)) {
+ throw new Error('Missing concrete migrated agent')
+ }
+ const projection = projectSourceControlActionLaunch(recipe, recipe.agentId, {
+ basePrompt: 'Fix checks'
+ })
+ const launch = resolveAgentLaunchCommand({
+ ...resolveAgentStartupPlanInputs({
+ agent: recipe.agentId,
+ settings: reopened.getSettings(),
+ platform: 'linux',
+ isRemote: false,
+ shell: 'posix',
+ agentArgs: projection.agentArgs,
+ sessionOptions: projection.sessionOptions
+ }),
+ shell: 'posix'
+ })
+ expect(launch.ok).toBe(true)
+ if (!launch.ok) {
+ throw new Error(launch.error)
+ }
+ expect(launch.appliedSessionOptions).toEqual({
+ model: 'retired-unlisted-id',
+ effort: 'high',
+ permissionMode: 'full-access'
+ })
+ expect(launch.command).toBe(
+ recipe.agentId === 'claude'
+ ? "claude '--model' 'retired-unlisted-id' '--effort' 'high' '--dangerously-skip-permissions'"
+ : "codex '-m' 'retired-unlisted-id' '-c' 'model_reasoning_effort=high' '--dangerously-bypass-approvals-and-sandbox'"
+ )
+ }
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' }
+ launchPreferences: {
+ agentId: 'codex',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'inherit',
+ model: { agentId: 'claude', value: null }
+ }
+ },
+ argumentChoices: { syntaxAgent: 'claude', nodes: [{ at: 0, text: '--effort high' }] }
})
expect(actions?.resolveComments?.agentArgs).toBe(literal)
expect(actions?.fixPushFailure?.agentArgs).toBe(' --dangerously-skip-permissions ')
@@ -221,6 +383,9 @@ describe('load-time normalization durability', () => {
expect(rows?.resolveComments).toEqual(
settled.repos[0]?.sourceControlAi?.actionOverrides?.resolveComments
)
+ expect(rows?.resolveConflicts).toEqual(
+ settled.repos[0]?.sourceControlAi?.actionOverrides?.resolveConflicts
+ )
expect(rows?.fixPushFailure).toEqual(
settled.repos[0]?.sourceControlAi?.actionOverrides?.fixPushFailure
)
@@ -238,6 +403,90 @@ describe('load-time normalization durability', () => {
}
)
+ it('durably demotes a well-shaped current term whose effort is no longer supported', async () => {
+ const settled = await settledProfile()
+ await closeTestStores()
+ settled.settings.sourceControlAi!.actions!.fixChecks = {
+ agentId: 'codex',
+ agentArgs: '--search',
+ argumentChoices: {
+ syntaxAgent: 'codex',
+ nodes: [{ at: 0, text: '-c model_reasoning_effort=retired' }]
+ }
+ }
+ settled.repos = [
+ {
+ id: 'retired-effort',
+ displayName: 'recipe',
+ badgeColor: '',
+ addedAt: 0,
+ path: '/tmp/recipe',
+ sourceControlAi: {
+ actionOverrides: { fixChecks: settled.settings.sourceControlAi!.actions!.fixChecks }
+ }
+ }
+ ]
+ const mark = vi.fn()
+ terminalPreparation.prepareLoadedTerminalSettings(settled, mark)
+ expect(mark).toHaveBeenCalled()
+ writeDataFile(settled)
+ const persisted = await reloadAndSettleWrites()
+ expect(persisted.settings.sourceControlAi?.actions?.fixChecks).toMatchObject({
+ agentArgs: ' -c model_reasoning_effort=retired --search'
+ })
+ expect(persisted.settings.sourceControlAi?.actions?.fixChecks?.argumentChoices).toBeUndefined()
+ expect(persisted.repos[0].sourceControlAi?.actionOverrides?.fixChecks).toEqual(
+ persisted.settings.sourceControlAi?.actions?.fixChecks
+ )
+ await closeTestStores()
+ expect(createStore().getSettings().sourceControlAi?.actions?.fixChecks).toEqual(
+ persisted.settings.sourceControlAi?.actions?.fixChecks
+ )
+ })
+
+ it.each(['document', 'selection'] as const)(
+ 'rejects malformed %s writes and preserves stored data at the load error owner',
+ async (kind) => {
+ const settled = await settledProfile()
+ const invalid =
+ kind === 'document'
+ ? {
+ agentId: 'claude' as const,
+ agentArgs: '',
+ argumentChoices: {
+ syntaxAgent: 'claude' as const,
+ nodes: [{ at: 99, text: '--model opus' }]
+ }
+ }
+ : {
+ agentId: 'claude' as const,
+ launchPreferences: {
+ agentId: 'claude' as const,
+ modelSelection: {
+ base: 'inherit' as const,
+ model: { agentId: 'claude' as const, value: 'x'.repeat(513) }
+ }
+ }
+ }
+ const store = createStore()
+ const source = store.getSettings().sourceControlAi!
+ expect(() =>
+ store.updateSettings({ sourceControlAi: { ...source, actions: { fixChecks: invalid } } })
+ ).toThrow(
+ kind === 'document'
+ ? 'Invalid action argument choice position'
+ : 'Invalid action model choice'
+ )
+ expect(store.getSettings().sourceControlAi).toEqual(source)
+ await closeTestStores()
+ settled.settings.sourceControlAi = { ...source, actions: { fixChecks: invalid } }
+ writeDataFile(settled)
+ const raw = readDataFile()
+ expect(() => createStore()).toThrow('Failed to load imported profile state')
+ expect(readDataFile()).toEqual(raw)
+ }
+ )
+
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/runtime/rpc/methods/repo-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/repo-rpc.test-fixture.ts
new file mode 100644
index 00000000000..672e64cb1cb
--- /dev/null
+++ b/src/main/runtime/rpc/methods/repo-rpc.test-fixture.ts
@@ -0,0 +1,18 @@
+import { RpcDispatcher } from '../dispatcher'
+import type { RpcRequest } from '../core'
+import type { OrcaRuntimeService } from '../../orca-runtime'
+import { REPO_METHODS } from './repo'
+
+export function makeRequest(method: string, params?: unknown): RpcRequest {
+ return { id: 'req-1', authToken: 'tok', method, params }
+}
+
+export function repoDispatcher(
+ runtime: Pick & Partial
+) {
+ return new RpcDispatcher({
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These RPC cases supply every runtime method their handlers and envelope access.
+ runtime: runtime as OrcaRuntimeService,
+ methods: REPO_METHODS
+ })
+}
diff --git a/src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts b/src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts
new file mode 100644
index 00000000000..10bdbc9a7dd
--- /dev/null
+++ b/src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts
@@ -0,0 +1,63 @@
+import '../unused-default-rpc-methods.test-fixture'
+import { describe, expect, it, vi } from 'vitest'
+import type { OrcaRuntimeService } from '../../orca-runtime'
+import { makeRequest, repoDispatcher } from './repo-rpc.test-fixture'
+
+describe('repo RPC methods', () => {
+ it('preserves the canonical action document through update, list and show envelopes', async () => {
+ const sourceControlAi = {
+ actionOverrides: {
+ fixChecks: {
+ agentId: 'codex' as const,
+ agentArgs: '--search',
+ launchPreferences: {
+ agentId: 'claude' as const,
+ permissionMode: 'ask' as const,
+ modelSelection: {
+ base: 'inherit' as const,
+ model: { agentId: 'codex' as const, value: null }
+ }
+ },
+ argumentChoices: {
+ syntaxAgent: 'codex' as const,
+ nodes: [{ at: 0, text: '-c model=o3' }]
+ }
+ }
+ }
+ }
+ const repo = {
+ id: 'repo-1',
+ path: '/repo',
+ displayName: 'repo',
+ badgeColor: '',
+ addedAt: 0,
+ sourceControlAi
+ }
+ const updateRepo = vi.fn().mockResolvedValue(repo)
+ const dispatcher = repoDispatcher({
+ getRuntimeId: () => 'test-runtime',
+ updateRepo,
+ listVisibleRepos: () => [repo],
+ showRepo: async () => repo
+ })
+ expect(
+ await dispatcher.dispatch(
+ makeRequest('repo.update', {
+ repo: 'id:repo-1',
+ updates: { sourceControlAi }
+ })
+ )
+ ).toMatchObject({ ok: true, result: { repo: { sourceControlAi } } })
+ expect(updateRepo).toHaveBeenCalledExactlyOnceWith('id:repo-1', { sourceControlAi })
+ expect(await dispatcher.dispatch(makeRequest('repo.list'))).toMatchObject({
+ ok: true,
+ result: { repos: [{ sourceControlAi }] }
+ })
+ expect(
+ await dispatcher.dispatch(makeRequest('repo.show', { repo: 'id:repo-1' }))
+ ).toMatchObject({
+ ok: true,
+ result: { repo: { sourceControlAi } }
+ })
+ })
+})
diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts
index c86ba069849..444c11ff385 100644
--- a/src/main/runtime/rpc/methods/repo.test.ts
+++ b/src/main/runtime/rpc/methods/repo.test.ts
@@ -1,9 +1,9 @@
import '../unused-default-rpc-methods.test-fixture'
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
-import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { REPO_METHODS } from './repo'
+import { makeRequest, repoDispatcher } from './repo-rpc.test-fixture'
import {
NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
@@ -14,20 +14,6 @@ import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runti
import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits'
import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../../shared/ssh-target-management'
-function makeRequest(method: string, params?: unknown): RpcRequest {
- return { id: 'req-1', authToken: 'tok', method, params }
-}
-
-function repoDispatcher(
- runtime: Pick & Partial
-) {
- return new RpcDispatcher({
- // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These RPC cases supply every runtime method their handlers and envelope access.
- runtime: runtime as OrcaRuntimeService,
- methods: REPO_METHODS
- })
-}
-
describe('repo RPC methods', () => {
it('advertises qualified-ref support from hosts and native remote clients', () => {
expect(RUNTIME_CAPABILITIES).toContain(REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY)
diff --git a/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts b/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts
index 4faa1bcb261..7b28ea7f778 100644
--- a/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts
+++ b/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import { RuntimeClientSettingsController } from './runtime-client-settings'
import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture'
import type { GlobalSettings } from '../../shared/global-settings-types'
+import { SourceControlAiSettings } from '../../shared/rpc-contract/git-params'
+import { liftActionPermissionArgs } from '../../shared/source-control-action-permission-lift'
import { getDefaultSourceControlAiSettings } from '../../shared/source-control-ai'
// settings.get is an allowlist: omitted per-action agent recipes look unsaved to paired AI buttons.
@@ -16,15 +18,28 @@ function hostSettings(sourceControlAi: GlobalSettings['sourceControlAi']): Parti
describe('RuntimeClientSettingsController source control launch recipes', () => {
it('publishes the recipe saved for a launch action', () => {
- const projected = projectionOf(
- hostSettings({
- ...getDefaultSourceControlAiSettings(),
- actions: { fixChecks: { agentId: 'codex', agentArgs: '--fast' } }
- })
+ const recipe = liftActionPermissionArgs({
+ agentId: 'codex' as const,
+ agentArgs: '-c model=o3 --fast',
+ launchPreferences: {
+ agentId: 'claude',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'inherit',
+ options: { effort: { agentId: 'codex', value: null } }
+ }
+ }
+ })
+ const source = { ...getDefaultSourceControlAiSettings(), actions: { fixChecks: recipe } }
+ expect(SourceControlAiSettings.parse(source).actions?.fixChecks).toEqual(recipe)
+ const projected = projectionOf(hostSettings(source))
+ expect(projected.sourceControlAi.actions.fixChecks?.launchPreferences).toEqual(
+ recipe.launchPreferences
)
expect(projected.sourceControlAi.actions.fixChecks).toMatchObject({
agentId: 'codex',
- agentArgs: '--fast'
+ agentArgs: '--fast',
+ argumentChoices: { syntaxAgent: 'codex', nodes: [{ at: 0, text: '-c model=o3' }] }
})
})
diff --git a/src/main/runtime/runtime-project-host-setup-publication.test.ts b/src/main/runtime/runtime-project-host-setup-publication.test.ts
index e0f05ab6711..a7130b1b1b4 100644
--- a/src/main/runtime/runtime-project-host-setup-publication.test.ts
+++ b/src/main/runtime/runtime-project-host-setup-publication.test.ts
@@ -13,7 +13,24 @@ const repo: Repo = {
displayName: 'app',
badgeColor: 'blue',
addedAt: 1,
- kind: 'git'
+ kind: 'git',
+ sourceControlAi: {
+ actionOverrides: {
+ fixChecks: {
+ agentId: 'claude',
+ agentArgs: '',
+ launchPreferences: {
+ agentId: 'codex',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'inherit',
+ model: { agentId: 'claude', value: null }
+ }
+ },
+ argumentChoices: { syntaxAgent: 'claude', nodes: [{ at: 0, text: '--effort high' }] }
+ }
+ }
+ }
}
const projection = projectHostSetupProjectionFromRepos([repo])
const project = projection.projects[0]
@@ -55,14 +72,25 @@ function makeController(): {
describe('RuntimeProjectHostSetupController publication', () => {
it('publishes a created setup to open clients', () => {
const { controller, notifyReposChanged } = makeController()
- controller.createSetup({ projectId: project.id, hostId: 'local', path: '/srv/app' })
+ const result = controller.createSetup({
+ projectId: project.id,
+ hostId: 'local',
+ path: '/srv/app'
+ })
+ expect(result.setup.sourceControlAi).toEqual(repo.sourceControlAi)
+ expect(controller.listSetups()).toEqual([setup])
expect(notifyReposChanged).toHaveBeenCalledTimes(1)
})
it('publishes a rename without retiring cached worktree scans', async () => {
const { controller, notifyReposChanged, invalidateResolvedWorktrees, retired } =
makeController()
- await controller.updateSetup({ setupId: setup.id, updates: { displayName: 'renamed' } })
+ const result = await controller.updateSetup({
+ setupId: setup.id,
+ updates: { displayName: 'renamed' }
+ })
+ expect(result.setup.sourceControlAi).toEqual(repo.sourceControlAi)
+ expect(result.repo?.sourceControlAi).toEqual(repo.sourceControlAi)
expect(notifyReposChanged).toHaveBeenCalledTimes(1)
expect(invalidateResolvedWorktrees).toHaveBeenCalled()
expect(retired).toEqual([])
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
index 496f2bfcd69..e60e699d9a9 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx
@@ -1,3 +1,4 @@
+import type { CurrentActionSelection } from '../../../../shared/source-control-action-selection'
import React from 'react'
import type { RuntimeGitContext } from '@/runtime/runtime-git-client-context'
import {
@@ -22,6 +23,7 @@ import { translate } from '@/i18n/i18n'
export type SourceControlAgentActionDialogProps = {
open: boolean
initialRecipe?: SourceControlActionRecipe
+ initialSelection?: CurrentActionSelection
onOpenChange: (open: boolean) => void
actionId: SourceControlLaunchActionId
title: string
@@ -127,7 +129,11 @@ function SourceControlAgentActionEditor(
agentArgsApply={agentArgsApply}
recipe={recipe}
commandTemplate={commandTemplate}
- savedCommandInputTemplate={savedCommandInputTemplate}
+ savedCommandInputTemplate={
+ props.initialSelection?.current.commandInputTemplate ??
+ props.initialRecipe?.commandInputTemplate ??
+ savedCommandInputTemplate
+ }
saveLaunchRecipe={saveLaunchRecipe}
saveTargetValue={saveTargetValue}
saveTargets={saveTargets}
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx
index dc1c6e91d1e..63c0f869f9c 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx
@@ -1,3 +1,5 @@
+import { sourceControlActionRecipeEntry } from '../../../../shared/source-control-action-selection'
+import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import React, { type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
@@ -47,7 +49,9 @@ vi.mock('../source-control/SourceControlActionVariableChips', () => ({
}))
function renderForm(
- overrides: Partial> = {}
+ overrides: Partial<
+ Omit, 'recipe'>
+ > & { recipe?: SourceControlActionRecipe } = {}
): string {
return renderToStaticMarkup(
React.createElement(SourceControlAgentActionDialogForm, {
@@ -66,11 +70,6 @@ function renderForm(
statusCopy: null,
agentArgs: '',
agentArgsApply: true,
- recipe: {
- agentId: overrides.selectedAgent ?? 'codex',
- commandInputTemplate: overrides.commandTemplate ?? '{basePrompt}',
- ...(overrides.agentArgs !== undefined ? { agentArgs: overrides.agentArgs } : {})
- },
commandTemplate: '{basePrompt}',
savedCommandInputTemplate: '{basePrompt}',
saveLaunchRecipe: true,
@@ -93,7 +92,14 @@ function renderForm(
onSaveAgentDefaultChange: () => {},
onCancel: () => {},
onStart: () => {},
- ...overrides
+ ...overrides,
+ recipe: sourceControlActionRecipeEntry(
+ overrides.recipe ?? {
+ agentId: overrides.selectedAgent ?? 'codex',
+ commandInputTemplate: overrides.commandTemplate ?? '{basePrompt}',
+ ...(overrides.agentArgs !== undefined ? { agentArgs: overrides.agentArgs } : {})
+ }
+ )
})
)
}
@@ -143,10 +149,34 @@ describe('SourceControlAgentActionDialogForm', () => {
)
expect(
renderForm({
- recipe: { ...recipe, launchPreferences: { agentId: 'codex', model: 'saved' } },
+ recipe: {
+ ...recipe,
+ launchPreferences: {
+ agentId: 'claude',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'inherit',
+ model: { agentId: 'codex', value: 'saved' }
+ }
+ }
+ },
agentArgsApply: false
})
).toContain('Model from Settings: saved')
+ expect(
+ renderForm({
+ recipe: {
+ agentId: 'codex',
+ launchPreferences: {
+ agentId: 'codex',
+ modelSelection: {
+ base: 'default',
+ options: { effort: { agentId: 'codex', value: 'high' } }
+ }
+ }
+ }
+ })
+ ).toContain('Model from Settings: High')
expect(
renderForm({
selectedAgent: 'antigravity',
diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
index 7a6ab9bf8ab..028201d557a 100644
--- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx
@@ -1,3 +1,4 @@
+import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection'
import React from 'react'
import { Info, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react'
import AgentCombobox from '@/components/agent/AgentCombobox'
@@ -13,10 +14,7 @@ import {
} from '@/components/ui/select'
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
-import type {
- SourceControlActionRecipe,
- SourceControlLaunchActionId
-} from '../../../../shared/source-control-ai-actions'
+import type { 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'
@@ -28,6 +26,7 @@ import { describeSourceControlActionModel } from '../settings/source-control-act
import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match'
import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
import { translate } from '@/i18n/i18n'
+import { selectedSourceControlActionPreferences } from '../../../../shared/source-control-action-launch-preferences'
/** Expose only delivery errors the user can fix before launch. */
export type SourceControlAgentActionDeliveryPlanState =
@@ -47,7 +46,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
+ recipe: SelectedSourceControlActionRecipe
commandTemplate: string
savedCommandInputTemplate?: string | null
saveLaunchRecipe: boolean
@@ -120,6 +119,9 @@ export function SourceControlAgentActionDialogForm({
const defaultCommandTemplate = savedCommandInputTemplate ?? '{basePrompt}'
const commandTemplateIncludesBasePrompt = commandTemplate.includes('{basePrompt}')
const selectedRecipe = recipe
+ const selectedPreferences = selectedAgent
+ ? selectedSourceControlActionPreferences(selectedRecipe, selectedAgent)
+ : undefined
const selectedSaveTarget = sourceControlLaunchSaveTargetFromValue(saveTargetValue, repo)
// Copy describes only the selected save target, which is the only one start/save writes.
const selectedLaunchRecipeAlreadySaved = Boolean(
@@ -192,9 +194,9 @@ export function SourceControlAgentActionDialogForm({
{statusCopy}
- ) : selectedAgent && selectedRecipe.launchPreferences?.agentId === selectedAgent ? (
+ ) : selectedAgent && selectedPreferences ? (
- {describeSourceControlActionModel(selectedAgent, selectedRecipe.launchPreferences)}
+ {describeSourceControlActionModel(selectedAgent, selectedPreferences)}
) : null}
diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx
index 39d5969f3e5..d28efd0c88f 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx
@@ -8,8 +8,6 @@ import { SourceControlAgentActionDialog } from '../SourceControlAgentActionDialo
import { ChecksPanelUpdatedAtMetadata } from '../checks-panel-updated-at-metadata'
import { getChecksPanelRefreshErrorBannerLine } from '../github-refresh-error-copy'
import { resolveChecksPanelHostedReviewModifierDestination } from '../checks-panel-hosted-review-click-routing'
-import { readSourceControlLaunchRecipeAgentId } from '../../../../../shared/source-control-launch-agent-selection'
-import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai'
import { clearPendingPRCommentAiAck } from '../pr-comments-ai-launch-ack'
import { getBrokenChecks } from '../../../../../shared/pr-checks-fix-prompt'
import { PRTriageStrip } from './triage-strip'
@@ -291,35 +289,6 @@ export function ChecksPanelActiveContent({
promptDelivery="submit-after-ready"
launchPlatform={activeSourceControlLaunchPlatform}
launchSource={agentComposerState?.launchSource ?? 'task_page'}
- savedAgentId={
- agentComposerState
- ? readSourceControlLaunchRecipeAgentId(
- resolveSourceControlActionRecipe({
- settings,
- repo,
- actionId: agentComposerState.actionId
- })
- )
- : null
- }
- savedCommandInputTemplate={
- agentComposerState
- ? (resolveSourceControlActionRecipe({
- settings,
- repo,
- actionId: agentComposerState.actionId
- }).commandInputTemplate ?? null)
- : null
- }
- savedAgentArgs={
- agentComposerState
- ? (resolveSourceControlActionRecipe({
- settings,
- repo,
- actionId: agentComposerState.actionId
- }).agentArgs ?? null)
- : null
- }
onSaveAgentDefault={saveLaunchActionDefault}
{...agentComposerState?.launchContext}
/>
diff --git a/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx
index d2f1e55ef3b..55bca07d3c0 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx
@@ -370,7 +370,7 @@ it.each([false, true])(
await act(async () => f.inventory.resolve(['claude']))
await waitFor(() => expect(f.composer).toHaveBeenCalledOnce())
expect(launch).not.toHaveBeenCalled()
- expect(f.composer.mock.calls[0]?.[0]?.launchContext?.initialRecipe?.agentId).toBe(
+ expect(f.composer.mock.calls[0]?.[0]?.launchContext?.initialSelection?.current.agentId).toBe(
saved ? 'codex' : undefined
)
}
diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx
index 263983fc314..0244878c569 100644
--- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx
@@ -1,5 +1,5 @@
import { useSavedSourceControlActionStart } from '../use-saved-source-control-action-start'
-import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai'
+import { captureSourceControlActionSelection } from '../../../../../shared/source-control-ai-operation-policy'
import {
checksPanelReviewStableKey,
clearPendingPRCommentAiAck,
@@ -69,7 +69,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) {
const savedStart = useSavedSourceControlActionStart()
const openOrStart = (composer: ChecksAgentComposerState): void => {
void savedStart.start(() => {
- const recipe = resolveSourceControlActionRecipe({
+ const selection = captureSourceControlActionSelection({
settings: model.settings,
repo,
actionId: composer.actionId
@@ -95,7 +95,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) {
)
}
const launchContext = {
- initialRecipe: recipe,
+ initialSelection: selection,
worktreeId: activeWorktreeId,
groupId: activeWorktreeId,
connectionId: model.activeConnectionId,
@@ -105,7 +105,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) {
}
return {
...launchContext,
- recipe,
+ selection,
basePrompt: composer.prompt,
settings: model.settings,
repo,
diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts
index 83eb13c2430..cfa9ab83011 100644
--- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts
+++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts
@@ -1,3 +1,5 @@
+import { sourceControlActionRecipeEntry } from '../../../../shared/source-control-action-selection'
+import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import type * as RuntimeGitStatusClientModule from '@/runtime/runtime-git-status-client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -41,12 +43,13 @@ import {
import type { Repo } from '../../../../shared/repo-types'
function buildArgs(
- overrides: Partial[0]> = {}
+ overrides: Partial[0], 'recipe'>> & {
+ recipe?: SourceControlActionRecipe
+ } = {}
): Parameters[0] {
return {
selectedAgent: 'codex',
trimmedCommandInput: 'Fix the bug',
- recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '--model gpt-5' },
saveTargetValue: 'none',
actionId: 'resolveComments',
repoId: null,
@@ -61,7 +64,14 @@ function buildArgs(
onSaveAgentDefault: mocks.onSaveAgentDefault,
onLaunched: mocks.onLaunched,
onClose: mocks.onClose,
- ...overrides
+ ...overrides,
+ recipe: sourceControlActionRecipeEntry(
+ overrides.recipe ?? {
+ agentId: 'codex',
+ commandInputTemplate: '{basePrompt}',
+ agentArgs: '--model gpt-5'
+ }
+ )
}
}
@@ -197,11 +207,11 @@ describe('runSourceControlAgentActionStart', () => {
})
const launchPromise = runSourceControlAgentActionStart(args)
- expect(onSaveAgentDefault).toHaveBeenCalledWith(
- { type: 'global' },
- 'resolveComments',
- args.recipe
- )
+ expect(onSaveAgentDefault).toHaveBeenCalledWith({ type: 'global' }, 'resolveComments', {
+ agentId: 'codex',
+ commandInputTemplate: '{basePrompt}',
+ agentArgs: '--model gpt-5'
+ })
expect(onSaveAgentDefault.mock.invocationCallOrder[0]).toBeLessThan(
mocks.launchAgentInNewTab.mock.invocationCallOrder[0]
)
@@ -492,11 +502,13 @@ it('executes the actual bound namespace while saving the literal default recipe'
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ sessionOptions: { model: 'chosen' } })
)
- expect(mocks.onSaveAgentDefault).toHaveBeenLastCalledWith(
- { type: 'global' },
- 'resolveComments',
- recipe
- )
+ expect(mocks.onSaveAgentDefault).toHaveBeenLastCalledWith({ type: 'global' }, 'resolveComments', {
+ ...recipe,
+ launchPreferences: {
+ agentId: 'codex',
+ modelSelection: { base: 'default', model: { agentId: 'codex', value: 'chosen' } }
+ }
+ })
})
it('confirms delivery and closes even while saving remains pending', async () => {
@@ -530,13 +542,12 @@ it.each([undefined, '', '--add-dir /extra'])(
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
agentArgs,
- sessionOptions: { model: 'saved' },
- sessionOptionsYieldToAgentArgs: true
+ sessionOptions: { model: 'saved' }
})
)
}
)
-// Saved to the repository, the copied choice owns the action there, as on every later launch.
+// Save keeps the repository's selected Args group 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) => {
@@ -570,10 +581,9 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
await runSourceControlAgentActionStart(buildArgs({ ...args, actionId: 'fixCommitFailure' }))
- expect(shown.launchPreferencesYieldToAgentArgs).toBe(true)
+ expect(shown.launchPreferencesYieldToAgentArgs).toBeUndefined()
expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).toMatchObject({
- agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}`,
- sessionOptions: { model: 'gpt-5.6-terra' }
+ agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}`
})
expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).not.toHaveProperty(
'sessionOptionsYieldToAgentArgs'
@@ -581,12 +591,11 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
expect(repo.sourceControlAi?.actionOverrides?.fixCommitFailure).toEqual({
agentId: null,
commandInputTemplate: '{basePrompt}',
- agentArgs,
- launchPreferences: terra
+ agentArgs
})
const next = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
const nextOptions = sourceControlActionLaunchOptions(next, 'codex')
- expect(nextOptions).toEqual({ sessionOptions: { model: terra.model } })
+ expect(nextOptions).toEqual({})
const command = resolveAgentLaunchCommand({
agent: 'codex',
cmdOverrides: {},
@@ -601,10 +610,11 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
yieldToAgentArgs: nextOptions.sessionOptionsYieldToAgentArgs
})
})
- expect(command).toMatchObject({ ok: true, appliedSessionOptions: { model: terra.model } })
- if (command.ok) {
- expect(command.command).not.toContain(agentArgs.split(' ')[1])
- }
+ expect(command).toMatchObject({
+ ok: true,
+ appliedSessionOptions: {},
+ command: `codex '--model' '${agentArgs.split(' ')[1]}'`
+ })
expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
await runSourceControlAgentActionStart(
buildArgs({ ...args, recipe: next, actionId: 'fixCommitFailure' })
@@ -612,3 +622,57 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
}
)
+
+it('saves a foreign resolved literal as current raw and starts from the canonical saved result', async () => {
+ vi.clearAllMocks()
+ const base = getDefaultSettings('/tmp')
+ const settings = {
+ ...base,
+ agentPermissionDefault: 'own' as const,
+ sourceControlAi: {
+ ...base.sourceControlAi!,
+ actions: {
+ fixChecks: {
+ agentId: 'codex' as const,
+ agentArgs: '',
+ argumentChoices: {
+ syntaxAgent: 'codex' as const,
+ nodes: [{ at: 0, text: '-c model=o3' }]
+ }
+ }
+ }
+ }
+ }
+ const repo = {
+ id: 'repo-1',
+ sourceControlAi: { actionOverrides: { fixChecks: { agentId: 'claude' as const } } }
+ }
+ const recipe = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixChecks' })
+ const onStart = vi.fn().mockResolvedValue(true)
+ await expect(
+ runSourceControlAgentActionStart(
+ buildArgs({
+ actionId: 'fixChecks',
+ recipe,
+ settings,
+ repo,
+ repoId: 'repo-1',
+ selectedAgent: 'claude',
+ saveTargetValue: 'repo',
+ onStart
+ })
+ )
+ ).resolves.toBe(true)
+ expect(mocks.onSaveAgentDefault).toHaveBeenCalledExactlyOnceWith(
+ { type: 'repo', repoId: 'repo-1' },
+ 'fixChecks',
+ {
+ agentId: 'claude',
+ commandInputTemplate: '{basePrompt}',
+ agentArgs: '-c model=o3'
+ }
+ )
+ expect(onStart).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ agent: 'claude', agentArgs: '-c model=o3' })
+ )
+})
diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts
index 1552457b290..2c73b3dadce 100644
--- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts
+++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts
@@ -1,3 +1,7 @@
+import {
+ sourceControlActionRecipeEntry,
+ type SelectedSourceControlActionRecipe
+} from '../../../../shared/source-control-action-selection'
import type { AgentLaunchPreferences } from '@/components/settings/use-host-agent-launch-preferences'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab'
@@ -17,12 +21,11 @@ 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 { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match'
import { resolveSourceControlAgentSaveTarget } from './source-control-agent-action-dialog-support'
-import { projectSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences'
+import { projectSelectedSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences'
import { admitSourceControlRecovery } from './source-control-recovery-admission'
export type RunSourceControlAgentActionStartArgs = {
@@ -30,7 +33,7 @@ export type RunSourceControlAgentActionStartArgs = {
trimmedCommandInput: string
/** The picker's choice: a deliberate surface stays explicit, a saved replay requests terminal only. */
launchChoice?: AgentLaunchChoice
- recipe: SourceControlActionRecipe
+ recipe: SelectedSourceControlActionRecipe
saveTargetValue: string
actionId: SourceControlLaunchActionId
repoId?: string | null
@@ -107,14 +110,13 @@ export async function runSourceControlAgentActionStart({
let launchFailureNotified = false
let launchAcceptedNotified = false
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.
+ // Complete only the submitted selection; later parent changes cannot rewrite this launch.
let launchRecipe = recipe
- let recipeToSave = recipe
- if (saveTarget && settings) {
+ let recipeToSave: SourceControlActionRecipe = recipe
+ if (saveTarget) {
const saved = saveSourceControlActionRecipe({
target: saveTarget,
- settings,
+ settings: settings ?? {},
repo,
actionId,
recipe
@@ -125,17 +127,9 @@ export async function runSourceControlAgentActionStart({
? 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
- })
+ launchRecipe = sourceControlActionRecipeEntry(recipeToSave)
}
- const projection = projectSourceControlActionLaunch(
+ const projection = projectSelectedSourceControlActionLaunch(
launchRecipe,
selectedAgent,
{ renderedPrompt: trimmedCommandInput, settings },
@@ -154,7 +148,7 @@ export async function runSourceControlAgentActionStart({
sourceControlActionRecipeSaveMatchesTarget({
actionId,
target: saveTarget,
- recipe: recipeToSave,
+ recipe: launchRecipe,
settings,
repo
})
diff --git a/src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx b/src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx
new file mode 100644
index 00000000000..4073517451a
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx
@@ -0,0 +1,205 @@
+import { terminalSessionOptionInputs } from '../../../../shared/tui-agent-launch-command'
+import { buildAgentStartupPlan } from '../../../../shared/tui-agent-startup'
+import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import {
+ SourceControlAgentActionDialog,
+ type SourceControlAgentActionDialogProps
+} from './SourceControlAgentActionDialog'
+import type { RunSourceControlAgentActionStartArgs } from './runSourceControlAgentActionStart'
+import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
+import type { Repo } from '../../../../shared/repo-types'
+import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
+import { saveSourceControlAiActionRecipeForTarget } from './source-control/ai/recipe-persistence'
+import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
+import { getDefaultSettings } from '../../../../shared/constants'
+import { useAppStore } from '@/store'
+import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
+import { AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY as SURFACE } from '../../../../shared/agent-launch-runtime-capability'
+import {
+ STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT,
+ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT
+} from '../../../../shared/protocol-version'
+import { normalizeSourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
+import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog'
+import {
+ render,
+ renderHook,
+ screen,
+ fireEvent,
+ waitFor,
+ type RenderResult,
+ type RenderHookResult
+} from '@testing-library/react'
+import { expect, vi, type Mock } from 'vitest'
+
+export type SaveAction = NonNullable
+
+type MountedSourceDialog = {
+ view: RenderResult
+ mount: () => RenderResult
+ persist: Mock
+ onStart: Mock>
+ props: SourceControlAgentActionDialogProps
+ repo: Repo
+}
+
+type RenderedSourceDialog = RenderHookResult<
+ ReturnType,
+ unknown
+> & {
+ onStart: Mock
+ onSaveAgentDefault: Mock
+ launchPreferences: NonNullable
+}
+
+export function mountSourceDialog(
+ global: SourceControlActionRecipe,
+ own: SourceControlActionRecipe,
+ save?: SaveAction,
+ onStart = vi
+ .fn>()
+ .mockResolvedValue(true),
+ scope: 'global' | 'repo' = 'repo'
+): MountedSourceDialog {
+ const settings = {
+ ...getDefaultSettings('/tmp/fc1-action'),
+ defaultTuiAgent: 'claude' as const,
+ experimentalNativeChat: true,
+ agentPermissionDefault: 'own' as const
+ }
+ settings.sourceControlAi!.actions = { resolveConflicts: global }
+ const repo = { ...TEST_REPO, sourceControlAi: { actionOverrides: { resolveConflicts: own } } }
+ useAppStore.setState({
+ settings,
+ repos: [repo],
+ detectedAgentIds: ['claude', 'codex'],
+ localDetectedAgentIdsByContext: { host: ['claude', 'codex'] }
+ })
+ setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, SURFACE])
+ const persist = vi.fn(
+ save ??
+ (async (target, actionId, recipe) => {
+ await saveSourceControlAiActionRecipeForTarget({
+ getStoreState: useAppStore.getState,
+ target,
+ actionId,
+ recipe,
+ updateSettings: async (patch) => {
+ useAppStore.setState((state) => ({ settings: { ...state.settings!, ...patch } }))
+ },
+ updateRepo: async (id, patch) => {
+ useAppStore.setState((state) => ({
+ repos: state.repos.map((row) =>
+ row.id === id
+ ? {
+ ...row,
+ sourceControlAi: normalizeRepoSourceControlAiOverrides(patch.sourceControlAi)
+ }
+ : row
+ )
+ }))
+ return true
+ }
+ })
+ })
+ )
+ const props = {
+ open: true,
+ onOpenChange: vi.fn(),
+ actionId: 'resolveConflicts' as const,
+ title: 'Resolve',
+ description: '',
+ baseCommandInput: 'fix',
+ launchSource: 'source_control_recovery' as const,
+ repoId: scope === 'repo' ? repo.id : undefined,
+ connectionId: null,
+ onStart,
+ onSaveAgentDefault: persist
+ }
+ const mount = () =>
+ render(, { wrapper: TooltipProvider })
+ return { view: mount(), mount, persist, onStart, props, repo }
+}
+
+export async function pickDialogAgent(label: string, terminal: boolean) {
+ await waitFor(() =>
+ expect(screen.getAllByRole('combobox')[0].hasAttribute('disabled')).toBe(false)
+ )
+ fireEvent.click(screen.getAllByRole('combobox')[0])
+ if (terminal) {
+ fireEvent.click(await screen.findByRole('button', { name: `Start ${label} in a terminal` }))
+ } else {
+ const row = screen.getAllByRole('option').find((item) => item.textContent?.startsWith(label))
+ if (!row) {
+ throw new Error(`Missing agent row ${label}`)
+ }
+ fireEvent.click(row)
+ }
+}
+
+export function renderDialog(
+ chat: boolean,
+ capable = true,
+ initialRecipe?: SourceControlActionRecipe
+): RenderedSourceDialog {
+ const settings = { ...getDefaultSettings('/tmp/pr-3'), defaultTuiAgent: 'claude' as const }
+ const launchPreferences = normalizeSourceControlActionRecipe(
+ { launchPreferences: { agentId: 'codex', model: 'chosen' } },
+ true
+ )!.launchPreferences!
+ settings.sourceControlAi!.actions = { resolveConflicts: { agentId: null, launchPreferences } }
+ useAppStore.setState({
+ settings: { ...settings, experimentalNativeChat: chat },
+ detectedAgentIds: ['claude', 'codex'],
+ localDetectedAgentIdsByContext: { host: ['claude', 'codex'] }
+ })
+ setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, ...(capable ? [SURFACE] : [])])
+ const onStart = vi.fn().mockResolvedValue(true)
+ const onSaveAgentDefault = vi.fn()
+ const view = renderHook(() =>
+ useSourceControlAgentActionDialog({
+ open: true,
+ initialRecipe,
+ onOpenChange: () => {},
+ actionId: 'resolveConflicts',
+ title: '',
+ description: '',
+ baseCommandInput: 'fix',
+ launchSource: 'source_control_recovery',
+ savedAgentId: null,
+ onStart,
+ onSaveAgentDefault
+ })
+ )
+ return { ...view, onStart, onSaveAgentDefault, launchPreferences }
+}
+
+export function freshDialogRecipe(repoId: string) {
+ const state = useAppStore.getState()
+ return resolveSourceControlActionRecipe({
+ settings: state.settings,
+ repo: state.repos.find((row) => row.id === repoId),
+ actionId: 'resolveConflicts'
+ })
+}
+
+export async function pickDialogSaveTarget(label: string) {
+ fireEvent.click(screen.getAllByRole('combobox')[1])
+ fireEvent.click(await screen.findByRole('option', { name: label }))
+}
+
+export function submittedActionStartupPlan(
+ submitted: Parameters>[0]
+) {
+ const { agent, agentArgs, sessionOptions } = submitted
+ return buildAgentStartupPlan({
+ agent,
+ prompt: 'fix',
+ platform: 'darwin',
+ shell: 'posix',
+ cmdOverrides: {},
+ agentArgs,
+ ...terminalSessionOptionInputs({ agent, agentArgs, sessionOptions, shell: 'posix' })
+ })
+}
diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts
index 1a17b749984..334642807ed 100644
--- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts
+++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts
@@ -3,6 +3,7 @@ import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
+import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
function settings(): GlobalSettings {
const base = getDefaultSettings('/tmp')
@@ -24,6 +25,28 @@ function settings(): GlobalSettings {
}
describe('sourceControlActionRecipeMatchesTarget', () => {
+ it('compares the same live document after trimming its residual and gaps', () => {
+ const current = settings()
+ const recipe = {
+ agentId: 'codex' as const,
+ agentArgs: '',
+ argumentChoices: { syntaxAgent: 'codex' as const, nodes: [{ at: 0, text: '-c model=o3' }] }
+ }
+ current.sourceControlAi!.actions!.resolveConflicts = {
+ ...recipe,
+ agentArgs: ' ',
+ argumentChoices: { ...recipe.argumentChoices, nodes: [{ at: 3, text: '-c model=o3' }] }
+ }
+ expect(
+ sourceControlActionRecipeMatchesTarget({
+ actionId: 'resolveConflicts',
+ target: { type: 'global' },
+ recipe,
+ settings: current
+ })
+ ).toBe(true)
+ })
+
it('returns true when the launch recipe matches the global saved recipe', () => {
expect(
sourceControlActionRecipeMatchesTarget({
@@ -190,4 +213,50 @@ it('distinguishes inheriting arguments from an explicit clear when deciding whet
recipe: { ...recipe, agentArgs: '' }
})
).toBe(false)
+ const repo = {
+ sourceControlAi: {
+ actionOverrides: {
+ fixChecks: {
+ launchPreferences: { agentId: 'codex' as const, permissionMode: 'ask' as const }
+ }
+ }
+ }
+ }
+ base.sourceControlAi!.actions = {
+ fixChecks: {
+ ...recipe,
+ launchPreferences: {
+ agentId: 'codex',
+ modelSelection: { base: 'default', model: { agentId: 'codex', value: 'o3' } }
+ }
+ }
+ }
+ const selected = resolveSourceControlActionRecipe({ settings: base, repo, actionId: 'fixChecks' })
+ const changed = {
+ ...base,
+ sourceControlAi: {
+ ...base.sourceControlAi!,
+ actions: {
+ fixChecks: {
+ ...recipe,
+ launchPreferences: {
+ agentId: 'codex' as const,
+ modelSelection: {
+ base: 'default' as const,
+ model: { agentId: 'codex' as const, value: 'gpt-5.4' }
+ }
+ }
+ }
+ }
+ }
+ }
+ expect(
+ sourceControlActionRecipeMatchesTarget({
+ actionId: 'fixChecks',
+ target: { type: 'repo', repoId: 'r' },
+ settings: changed,
+ repo,
+ recipe: selected
+ })
+ ).toBe(false)
})
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 c63b1da99f4..a6428b944bb 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
@@ -1,4 +1,6 @@
+import { sourceControlActionRecipeEntry } from '../../../../shared/source-control-action-selection'
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
+import { trimActionArgumentDocument } from '../../../../shared/source-control-action-argument-choices'
import {
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings,
@@ -21,6 +23,7 @@ type NormalizedSourceControlActionRecipe = {
commandInputTemplate: string
agentArgs: string | undefined
launchPreferences: string
+ argumentChoices: string
surface: string
}
@@ -28,31 +31,46 @@ type PersistedSourceControlActionRecipe = {
agentId?: SourceControlActionRecipe['agentId']
commandInputTemplate?: string | null
agentArgs?: string | null
+ argumentChoices?: SourceControlActionRecipe['argumentChoices']
launchPreferences?: SourceControlActionRecipe['launchPreferences']
surface?: SourceControlActionRecipe['surface']
+ resolvedChoiceSources?: SourceControlActionRecipe['resolvedChoiceSources']
}
function normalizeSourceControlActionRecipeForComparison(
actionId: SourceControlActionId,
recipe: PersistedSourceControlActionRecipe | null | undefined
): NormalizedSourceControlActionRecipe {
+ const document = trimActionArgumentDocument({
+ agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs : undefined,
+ argumentChoices: recipe?.argumentChoices
+ })
+ const { model: selection, permission } = sourceControlActionRecipeEntry({
+ ...recipe,
+ agentArgs: document.agentArgs,
+ argumentChoices: document.argumentChoices,
+ commandInputTemplate: recipe?.commandInputTemplate ?? undefined
+ }).resolvedChoiceSources
return {
agentId: recipe?.agentId ?? null,
commandInputTemplate:
typeof recipe?.commandInputTemplate === 'string'
? recipe.commandInputTemplate.trim()
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
- agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : undefined,
- 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
- ])
- : '',
+ agentArgs: document.agentArgs,
+ argumentChoices: JSON.stringify(document.argumentChoices ?? null),
+ launchPreferences:
+ permission || selection
+ ? JSON.stringify([
+ permission?.agentId ?? null,
+ selection?.base ?? null,
+ selection?.model ? [selection.model.agentId, selection.model.value] : null,
+ Object.entries(selection?.options ?? {})
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, entry]) => [key, entry.agentId, entry.value]),
+ permission?.permissionMode ?? null
+ ])
+ : '',
surface: recipe?.surface ?? ''
}
}
@@ -66,6 +84,7 @@ function sourceControlActionRecipesMatch(
left.commandInputTemplate === right.commandInputTemplate &&
left.agentArgs === right.agentArgs &&
left.launchPreferences === right.launchPreferences &&
+ left.argumentChoices === right.argumentChoices &&
left.surface === right.surface
)
}
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 45708a54da5..bc54d5c4ffa 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,7 +1,7 @@
+import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection'
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 { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
import type { AgentComboboxSurfaceRows } from '@/components/agent/agent-combobox-surface-items'
@@ -24,7 +24,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
+ recipe: SelectedSourceControlActionRecipe
commandTemplate: string
saveLaunchRecipe: boolean
saveTargetValue: string
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 844e2818504..2766a96e3e1 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
@@ -1,3 +1,4 @@
+import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control-action-selection'
import { ensureAgentLaunchPreferences } from '@/components/settings/use-host-agent-launch-preferences'
import {
AgentLaunchChoiceCancelled,
@@ -20,11 +21,8 @@ import {
readSourceControlLaunchRecipeAgentId
} from '../../../../../../shared/source-control-launch-agent-selection'
import { isTuiAgentEnabled } from '../../../../../../shared/tui-agent-selection'
-import { projectSourceControlActionLaunch } from '../../../../../../shared/source-control-action-launch-preferences'
-import type {
- SourceControlActionRecipe,
- SourceControlLaunchActionId
-} from '../../../../../../shared/source-control-ai-actions'
+import { projectSelectedSourceControlActionLaunch } from '../../../../../../shared/source-control-action-launch-preferences'
+import type { SourceControlLaunchActionId } from '../../../../../../shared/source-control-ai-actions'
import { buildSourceControlRecoveryAgentCommandInput } from '../../../../../../shared/source-control-recovery-agent-command'
import { translate } from '@/i18n/i18n'
import { agentChoiceMayOpenAsChat } from '@/lib/host-chat-program'
@@ -104,7 +102,9 @@ export async function launchSourceControlRecoveryAgentWithDefault({
actionId: SourceControlLaunchActionId
basePrompt: string | null
promptOverride?: string
- getLaunchActionRecipe: (actionId: SourceControlLaunchActionId) => SourceControlActionRecipe
+ getLaunchActionRecipe: (
+ actionId: SourceControlLaunchActionId
+ ) => SelectedSourceControlActionRecipe
getStoreState: () => SourceControlAiLaunchStoreSnapshot
copy: SourceControlRecoveryLaunchCopy
}): Promise {
@@ -116,6 +116,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({
return false
}
+ const savedRecipe = getLaunchActionRecipe(actionId)
const workspace = worktreeLaunchWorkspace(activeWorktreeId)
const inventory = await Promise.all([
acquireAgentLaunchChoices(workspace, () =>
@@ -137,7 +138,6 @@ export async function launchSourceControlRecoveryAgentWithDefault({
const [choices, preferences] = inventory
const detectedAgents = [...(choices.detected ?? [])]
const store = getStoreState()
- const savedRecipe = getLaunchActionRecipe(actionId)
if (!basePrompt) {
toast.error(copy.promptUnavailable)
return false
@@ -176,10 +176,14 @@ export async function launchSourceControlRecoveryAgentWithDefault({
toast.error(copy.noEnabledAgent)
return false
}
- const { commandInput, ...projection } = projectSourceControlActionLaunch(savedRecipe, agent, {
- renderedPrompt: prompt,
- settings: store.settings
- })
+ const { commandInput, ...projection } = projectSelectedSourceControlActionLaunch(
+ savedRecipe,
+ agent,
+ {
+ renderedPrompt: prompt,
+ settings: store.settings
+ }
+ )
const result = launchAgentInNewTab({
requestId: newAgentLaunchRequestId(),
agent,
diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx
index e7f74ab7295..72340e814a9 100644
--- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx
+++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx
@@ -1,3 +1,4 @@
+import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-control-action-selection'
import { runtimeStatusEntry } from '@/runtime/server-layout-frames.test-fixture'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key'
@@ -284,11 +285,12 @@ describe('AI operation recovery composer', () => {
sourceRepoConnectionId: null,
actionId: 'fixCommitFailure',
basePrompt: 'fix',
- getLaunchActionRecipe: () => ({
- agentId: null,
- agentArgs: '--model chosen',
- commandInputTemplate: 'Recovery: {basePrompt}'
- }),
+ getLaunchActionRecipe: () =>
+ sourceControlActionRecipeEntry({
+ agentId: null,
+ agentArgs: '--model chosen',
+ commandInputTemplate: 'Recovery: {basePrompt}'
+ }),
getStoreState: useAppStore.getState,
copy: getDefaultSourceControlRecoveryLaunchCopy('commit')
})
diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts
index 4d9d2e11c75..57d1c7e37ea 100644
--- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts
@@ -1,3 +1,5 @@
+import { captureSourceControlActionSelection } from '../../../../../../shared/source-control-ai-operation-policy'
+import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control-action-selection'
import { useSavedSourceControlActionStart } from '../../use-saved-source-control-action-start'
import type { SourceControlAgentActionDialogProps } from '../../SourceControlAgentActionDialog'
import { useCallback, useMemo, useState } from 'react'
@@ -108,7 +110,7 @@ export function useSourceControlAi({
}, [activeRepo, settings, sourceControlAiDiscoveryHostKey])
const getLaunchActionRecipe = useCallback(
- (actionId: SourceControlLaunchActionId): SourceControlActionRecipe =>
+ (actionId: SourceControlLaunchActionId): SelectedSourceControlActionRecipe =>
resolveSourceControlActionRecipe({ settings, repo: activeRepo, actionId }),
[activeRepo, settings]
)
@@ -171,7 +173,11 @@ export function useSourceControlAi({
worktreePath,
onAdmitted: async (recoveryContext, signal) => {
const editor = {
- initialRecipe: getLaunchActionRecipe('resolveConflicts'),
+ initialSelection: captureSourceControlActionSelection({
+ settings,
+ repo: activeRepo,
+ actionId: 'resolveConflicts'
+ }),
recoveryContext,
baseCommandInput: resolveConflictsPrompt,
worktreeId: activeWorktreeId,
@@ -181,7 +187,7 @@ export function useSourceControlAi({
launchPlatform: activeSourceControlLaunchPlatform
}
await savedStart.start({
- recipe: editor.initialRecipe,
+ selection: editor.initialSelection,
basePrompt: resolveConflictsPrompt,
...editor,
actionId: 'resolveConflicts',
diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts
index e9268ed12b6..47750626667 100644
--- a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts
@@ -1,8 +1,6 @@
+import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control-action-selection'
import { useCallback, useMemo, useState } from 'react'
-import type {
- SourceControlActionRecipe,
- SourceControlLaunchActionId
-} from '../../../../../../shared/source-control-ai-actions'
+import type { SourceControlLaunchActionId } from '../../../../../../shared/source-control-ai-actions'
import type { GitStatusEntry } from '../../../../../../shared/git-status-types'
import { buildFixCommitFailurePrompt } from './prompts'
import { summarizeCommitFailure } from '../commit/commit-failure-summary'
@@ -22,7 +20,9 @@ type SourceControlRecoveryAiParams = {
commitError: string | null
pushRecoveryPrompt: string | null
stagedEntries: Pick[]
- getLaunchActionRecipe: (actionId: SourceControlLaunchActionId) => SourceControlActionRecipe
+ getLaunchActionRecipe: (
+ actionId: SourceControlLaunchActionId
+ ) => SelectedSourceControlActionRecipe
getStoreState: () => SourceControlAiStoreSnapshot
}
diff --git a/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts b/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts
index d8c81bf71fe..2ef3c139e20 100644
--- a/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts
+++ b/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts
@@ -1,3 +1,7 @@
+import {
+ selectSourceControlActionRecipe,
+ type CurrentActionSelection
+} from '../../../../shared/source-control-action-selection'
import {
AgentLaunchChoiceCancelled,
acquireAgentLaunchChoices
@@ -13,7 +17,7 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { ensureDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
-import { projectSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences'
+import { projectSelectedSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences'
import { sourceControlLaunchAppliesAgentArgs } from './source-control-launch-agent-args-applicability'
import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan'
import {
@@ -24,8 +28,9 @@ import {
/** Primary events own saved starts; opening an editor never authorizes execution. */
type SavedStartArgs = Omit<
RunSourceControlAgentActionStartArgs,
- 'selectedAgent' | 'trimmedCommandInput' | 'saveTargetValue'
+ 'selectedAgent' | 'trimmedCommandInput' | 'saveTargetValue' | 'recipe'
> & {
+ selection: CurrentActionSelection
basePrompt: string
connectionId?: string | null
onFallback: () => void
@@ -52,13 +57,14 @@ export function useSavedSourceControlActionStart() {
}
signal?.throwIfAborted()
const store = useAppStore.getState()
- const { recipe, worktreeId } = args
+ const { worktreeId } = args
+ const recipe = selectSourceControlActionRecipe(args.selection)
const agent = recipe.agentId
if (!isTuiAgent(agent) || !worktreeId) {
onFallback()
return
}
- const projection = projectSourceControlActionLaunch(recipe, agent, {
+ const projection = projectSelectedSourceControlActionLaunch(recipe, agent, {
basePrompt: args.basePrompt,
settings: args.settings
})
@@ -107,6 +113,7 @@ export function useSavedSourceControlActionStart() {
}
const launched = await runSourceControlAgentActionStart({
...args,
+ recipe,
agentLaunchPreferences,
selectedAgent: agent,
trimmedCommandInput: projection.commandInput,
diff --git a/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts b/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts
index 0429e1ab390..c646984fa7d 100644
--- a/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts
+++ b/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts
@@ -8,7 +8,7 @@ import { useMemo } from 'react'
import { useAppStore } from '@/store'
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import { pickSourceControlLaunchAgent } from '../../../../shared/source-control-launch-agent-selection'
-import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
+import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -40,7 +40,7 @@ export function useSourceControlActionAgentChoice(args: {
worktreeId?: string | null
repoId?: string | null
repo: Pick | null
- recipe: SourceControlActionRecipe
+ recipe: SelectedSourceControlActionRecipe
/** This open's deliberate pick; absent replays the saved recipe. */
livePick: AgentLaunchSurface | undefined
settings: GlobalSettings | null
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx
index e6646bc3f8e..fca0380afc9 100644
--- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx
@@ -1,21 +1,37 @@
+// @vitest-environment happy-dom
+
+import {
+ commitSourceControlActionArguments,
+ editSourceControlActionArguments
+} from '../../../../shared/source-control-action-selection'
+import {
+ captureSourceControlActionSelection,
+ resolveSourceControlActionRecipe
+} from '../../../../shared/source-control-ai-operation-policy'
+import { projectSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences'
+import { structuredLaunchRequestedOptions } from '../../../../shared/native-chat/input/native-chat-session-option-defaults'
+import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import type { Repo } from '../../../../shared/repo-types'
import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings'
import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures'
import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key'
import { clearHostAgentMenuSettingsForTests } from '../settings/use-host-agent-launch-preferences'
import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
-import { AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY as SURFACE } from '../../../../shared/agent-launch-runtime-capability'
-// @vitest-environment happy-dom
-import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
-import {
- STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT,
- STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT
-} from '../../../../shared/protocol-version'
import { useAppStore } from '@/store'
import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities'
import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog'
+import {
+ mountSourceDialog,
+ freshDialogRecipe,
+ pickDialogSaveTarget,
+ submittedActionStartupPlan,
+ renderDialog,
+ pickDialogAgent,
+ type SaveAction
+} from './source-control-action-dialog.test-fixture'
const initialState = useAppStore.getState()
afterEach(() => {
@@ -25,36 +41,6 @@ afterEach(() => {
setLocalRuntimeCapabilitiesForTests(null)
})
-function renderDialog(chat: boolean, capable = true, initialRecipe?: SourceControlActionRecipe) {
- const settings = { ...getDefaultSettings('/tmp/pr-3'), defaultTuiAgent: 'claude' as const }
- const launchPreferences = { agentId: 'codex' as const, model: 'chosen' }
- settings.sourceControlAi!.actions = { resolveConflicts: { agentId: null, launchPreferences } }
- useAppStore.setState({
- settings: { ...settings, experimentalNativeChat: chat },
- detectedAgentIds: ['claude', 'codex'],
- localDetectedAgentIdsByContext: { host: ['claude', 'codex'] }
- })
- setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, ...(capable ? [SURFACE] : [])])
- const onStart = vi.fn().mockResolvedValue(true)
- const onSaveAgentDefault = vi.fn()
- const view = renderHook(() =>
- useSourceControlAgentActionDialog({
- open: true,
- initialRecipe,
- onOpenChange: () => {},
- actionId: 'resolveConflicts',
- title: '',
- description: '',
- baseCommandInput: 'fix',
- launchSource: 'source_control_recovery',
- savedAgentId: null,
- onStart,
- onSaveAgentDefault
- })
- )
- return { ...view, onStart, onSaveAgentDefault, launchPreferences }
-}
-
it('starts and saves a live pick from the unified picker, keeping the agent-bound model', async () => {
const { result, onStart, onSaveAgentDefault, launchPreferences } = renderDialog(true)
await waitFor(() => expect(result.current.canStart).toBe(true))
@@ -244,3 +230,580 @@ it.each(['codex', null] as const)(
expect(useAppStore.getState().settings).toBe(settings)
}
)
+
+it.each([
+ { saved: 'claude' as const, model: 'opus', terminal: true },
+ { saved: null, model: undefined, terminal: false }
+])(
+ 'keeps captured choices through actual same-agent picker dispatch, complete Save, Start and reopen (%s)',
+ async ({ saved, model, terminal }) => {
+ const global: SourceControlActionRecipe = {
+ agentId: 'claude',
+ agentArgs: '--verbose',
+ launchPreferences: {
+ agentId: 'claude',
+ ...(model ? { model } : {}),
+ options: { effort: 'high' }
+ }
+ }
+ const own: SourceControlActionRecipe = {
+ agentId: saved,
+ launchPreferences: { agentId: 'claude', permissionMode: 'ask' }
+ }
+ const f = mountSourceDialog(global, own)
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
+ ).toBe(false)
+ )
+ await pickDialogAgent('Claude', terminal)
+ if (terminal) {
+ expect(
+ screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value
+ ).toBe('--verbose')
+ } else {
+ expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull()
+ }
+ expect(screen.getByText(/High/).textContent).toContain('High')
+ if (model) {
+ expect(screen.getByText(/High/).textContent).toContain('Opus')
+ }
+ fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ const options = { ...(model ? { model } : {}), effort: 'high', permissionMode: 'ask' }
+ expect(f.onStart).toHaveBeenCalledWith(
+ expect.objectContaining({ agent: 'claude', agentArgs: '--verbose', sessionOptions: options })
+ )
+ expect(f.persist).toHaveBeenCalledOnce()
+ const savedRecipe = f.persist.mock.calls[0][2]
+ expect(savedRecipe.launchPreferences).toEqual({
+ agentId: 'claude',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'default',
+ ...(model ? { model: { agentId: 'claude', value: model } } : {}),
+ options: { effort: { agentId: 'claude', value: 'high' } }
+ }
+ })
+ expect(
+ projectSourceControlActionLaunch(freshDialogRecipe(f.repo.id), 'claude', {
+ basePrompt: 'fix'
+ })
+ ).toMatchObject({ agentArgs: '--verbose', sessionOptions: options })
+ f.view.unmount()
+ const reopened = f.mount()
+ expect(screen.getByText(/High/).textContent).toContain('High')
+ reopened.unmount()
+ }
+)
+
+it('distinguishes masked eligibility from owning reset and uses the captured parent through Save/Start', async () => {
+ const global: SourceControlActionRecipe = {
+ agentId: 'codex',
+ agentArgs: '',
+ launchPreferences: {
+ agentId: 'codex',
+ permissionMode: 'own',
+ modelSelection: {
+ base: 'default',
+ model: { agentId: 'codex', value: 'o3' }
+ }
+ }
+ }
+ const own: SourceControlActionRecipe = {
+ agentId: 'claude',
+ launchPreferences: {
+ agentId: 'claude',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'inherit',
+ model: { agentId: 'claude', value: 'opus' }
+ }
+ }
+ }
+ const f = mountSourceDialog(global, own)
+ const captured = resolveSourceControlActionRecipe({
+ settings: useAppStore.getState().settings,
+ repo: f.repo,
+ actionId: 'resolveConflicts'
+ })
+ expect(
+ projectSourceControlActionLaunch(captured, 'codex', {
+ basePrompt: 'fix'
+ }).sessionOptions
+ ).toBeUndefined()
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
+ ).toBe(false)
+ )
+ act(() =>
+ useAppStore.setState((state) => ({
+ settings: {
+ ...state.settings!,
+ sourceControlAi: {
+ ...state.settings!.sourceControlAi!,
+ actions: {
+ resolveConflicts: { ...global, launchPreferences: { agentId: 'codex', model: 'later' } }
+ }
+ }
+ }
+ }))
+ )
+ await pickDialogAgent('Codex', true)
+ expect(screen.getByText(/Model from Settings: o3/)).toBeDefined()
+ fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart).toHaveBeenCalledWith(
+ expect.objectContaining({ agent: 'codex', agentArgs: '', sessionOptions: { model: 'o3' } })
+ )
+ expect(f.persist.mock.calls[0][2]).toMatchObject({
+ agentId: 'codex',
+ agentArgs: '',
+ launchPreferences: {
+ agentId: 'codex',
+ modelSelection: {
+ base: 'default',
+ model: { agentId: 'codex', value: 'o3' }
+ }
+ }
+ })
+ expect(f.persist.mock.calls[0][2].launchPreferences).toEqual({
+ agentId: 'codex',
+ modelSelection: { base: 'default', model: { agentId: 'codex', value: 'o3' } }
+ })
+ expect(f.persist.mock.calls[0][2]).not.toHaveProperty('resolvedChoiceSources')
+ expect(
+ projectSourceControlActionLaunch(freshDialogRecipe(f.repo.id), 'codex', { basePrompt: 'fix' })
+ .sessionOptions
+ ).toEqual({ model: 'o3' })
+ await pickDialogAgent('Claude', false)
+ await pickDialogAgent('Codex', true)
+ expect(screen.queryByText(/Opus/)).toBeNull()
+ f.view.unmount()
+ const reopened = f.mount()
+ expect(screen.getByText(/Model from Settings: o3/)).toBeDefined()
+ reopened.unmount()
+ useAppStore.setState((state) => ({
+ repos: state.repos.map((row) =>
+ row.id === f.repo.id
+ ? {
+ ...row,
+ sourceControlAi: { actionOverrides: { resolveConflicts: { agentId: 'codex' } } }
+ }
+ : row
+ )
+ }))
+ const fresh = f.mount()
+ expect(screen.getByText(/Model from Settings: later/)).toBeDefined()
+ fresh.unmount()
+})
+
+it('retains newly eligible literal syntax, demotes obsolete syntax once and does not restore it after switching back', async () => {
+ const global = liftActionPermissionArgs({
+ agentId: 'claude' as const,
+ agentArgs: '--model opus --effort high --verbose'
+ })
+ const f = mountSourceDialog(global, {
+ agentId: 'codex',
+ launchPreferences: { agentId: 'codex', permissionMode: 'ask' }
+ })
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
+ ).toBe(false)
+ )
+ await pickDialogAgent('Claude', true)
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe(
+ '--verbose'
+ )
+ expect(screen.getByText(/Opus/).textContent).toContain('High')
+ fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart).toHaveBeenCalledWith(
+ expect.objectContaining({
+ agent: 'claude',
+ agentArgs: '--verbose',
+ sessionOptions: { model: 'opus', effort: 'high' }
+ })
+ )
+ const saved = f.persist.mock.calls[0][2]
+ expect(saved.argumentChoices).toEqual(global.argumentChoices)
+ expect(saved.launchPreferences).toBeUndefined()
+ await pickDialogAgent('Codex', true)
+ const text = screen.getByRole('textbox', { name: 'Extra CLI arguments' })
+ expect(text.value).toBe('--model opus --effort high --verbose')
+ await pickDialogAgent('Claude', true)
+ expect(screen.queryByText(/Model from Settings/)).toBeNull()
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe(
+ '--model opus --effort high --verbose'
+ )
+})
+
+it('keeps an owned empty-Args default barrier and submitted choices across failed Save, newer edits and Discard', async () => {
+ const done = Promise.withResolvers()
+ const save = vi.fn(() => done.promise)
+ const f = mountSourceDialog(
+ { agentId: 'codex', launchPreferences: { agentId: 'codex', model: 'o3' } },
+ {
+ agentId: 'claude',
+ agentArgs: '',
+ launchPreferences: {
+ agentId: 'claude',
+ modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } }
+ }
+ },
+ save
+ )
+ await waitFor(() =>
+ expect(
+ screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled')
+ ).toBe(false)
+ )
+ await pickDialogAgent('Codex', true)
+ expect(screen.queryByText(/Model from Settings/)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart.mock.calls[0][0]).toMatchObject({
+ agent: 'codex',
+ agentArgs: '',
+ commandInput: 'fix'
+ })
+ expect(f.onStart.mock.calls[0][0].sessionOptions).toBeUndefined()
+ fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), {
+ target: { value: '--newer' }
+ })
+ await act(async () => done.reject(new Error('write failed')))
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe(
+ '--newer'
+ )
+ expect(save.mock.calls[0][2].agentArgs).toBe('')
+ expect(f.onStart).toHaveBeenCalledOnce()
+ expect(useAppStore.getState().repos[0].sourceControlAi).toEqual(f.repo.sourceControlAi)
+ expect(freshDialogRecipe(f.repo.id).agentId).toBe('claude')
+ f.view.unmount()
+ const fresh = f.mount()
+ await pickDialogAgent('Claude', true)
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe(
+ ''
+ )
+ fresh.unmount()
+})
+
+it.each([
+ { agent: 'claude', pair: false, save: false, native: false, scope: 'global' },
+ { agent: 'claude', pair: true, save: true, native: false, scope: 'repo' },
+ { agent: 'claude', pair: false, save: true, native: false, scope: 'global' },
+ { agent: 'claude', pair: true, save: true, native: true, scope: 'repo' },
+ { agent: 'codex', pair: true, save: false, native: true, scope: 'repo' },
+ { agent: 'codex', pair: true, save: false, native: true, scope: 'repo', foreignDocument: true },
+ { agent: 'claude', pair: true, save: false, native: true, scope: 'repo', defaultGroup: true },
+ { agent: 'claude', pair: false, save: false, native: true, scope: 'repo', defaultGroup: true },
+ { agent: 'claude', pair: false, save: true, native: false, scope: 'repo', typedDefault: true },
+ { agent: 'claude', pair: false, save: true, native: true, scope: 'repo', typedDefault: true }
+] as const)(
+ 'commits edited $agent literals once through Start/save=$save/native=$native/$scope and fresh reopen',
+ async (row) => {
+ const { agent, pair, save, native, scope } = row
+ const original =
+ agent === 'claude'
+ ? `--model opus ${pair || save || 'defaultGroup' in row ? '--effort high ' : ''}--verbose`
+ : '-m gpt-5.4 -c model_reasoning_effort=high --verbose'
+ const replacement =
+ agent === 'claude'
+ ? `--model sonnet ${pair ? '--effort low ' : ''}--verbose`
+ : '-m gpt-5.5 -c model_reasoning_effort=low --verbose'
+ const global = liftActionPermissionArgs({
+ agentId: 'foreignDocument' in row ? 'claude' : agent,
+ surface: 'terminal',
+ agentArgs: 'foreignDocument' in row ? '--model opus --effort high --verbose' : original
+ })
+ const f = mountSourceDialog(
+ 'typedDefault' in row
+ ? {
+ ...global,
+ argumentChoices: undefined,
+ launchPreferences: {
+ agentId: agent,
+ modelSelection: {
+ base: 'default',
+ model: { agentId: agent, value: 'opus' },
+ options: { effort: { agentId: agent, value: 'high' } }
+ }
+ }
+ }
+ : global,
+ {
+ agentId: agent,
+ surface: 'terminal',
+ ...('defaultGroup' in row || 'typedDefault' in row
+ ? {
+ launchPreferences: { agentId: agent, modelSelection: { base: 'default' as const } }
+ }
+ : {})
+ },
+ undefined,
+ undefined,
+ scope
+ )
+ await waitFor(() =>
+ expect(
+ screen
+ .getByRole('button', {
+ name: /^(Save & start agent|Start agent)$/
+ })
+ .hasAttribute('disabled')
+ ).toBe(false)
+ )
+ if ('typedDefault' in row) {
+ const state = useAppStore.getState()
+ const captured = captureSourceControlActionSelection({
+ settings: state.settings,
+ repo: state.repos[0],
+ actionId: 'resolveConflicts'
+ })
+ const committed = commitSourceControlActionArguments(
+ editSourceControlActionArguments(captured, replacement),
+ agent
+ )
+ expect(committed.selection.source).toEqual(captured.source)
+ expect(committed.selection.source).toMatchObject({
+ scope: 'repo',
+ ownsArgs: false,
+ authoredPreferences: { agentId: agent, modelSelection: { base: 'default' } },
+ parentPreferences: {
+ modelSelection: { model: { value: 'opus' }, options: { effort: { value: 'high' } } }
+ }
+ })
+ expect(committed.recipe.resolvedChoiceSources).toMatchObject({
+ model: { base: 'default' },
+ literalKeys: ['model'],
+ permission: captured.source.authoredPreferences
+ })
+ }
+ fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), {
+ target: { value: replacement }
+ })
+ if (native) {
+ await pickDialogAgent(agent === 'claude' ? 'Claude' : 'Codex', false)
+ expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull()
+ }
+ if (save && scope === 'repo') {
+ await pickDialogSaveTarget('This repository')
+ }
+ if (!save) {
+ fireEvent.click(screen.getByRole('checkbox'))
+ }
+ // No blur: submission itself must settle the pending edit.
+ fireEvent.click(
+ screen.getByRole('button', { name: save ? 'Save & start agent' : 'Start agent' })
+ )
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ const options = {
+ model: agent === 'claude' ? 'sonnet' : 'gpt-5.5',
+ ...(pair ? { effort: 'low' } : {})
+ }
+ const submitted = f.onStart.mock.calls[0][0]
+ const residual =
+ 'foreignDocument' in row
+ ? '--model opus --effort high --verbose'
+ : 'defaultGroup' in row && !pair
+ ? '--effort high --verbose'
+ : '--verbose'
+ expect(submitted).toMatchObject({ agent, agentArgs: residual, sessionOptions: options })
+ expect(submitted.sessionOptions).toEqual(options)
+ const plan = submittedActionStartupPlan(submitted)
+ expect(plan?.launchCommand).toBe(
+ agent === 'claude'
+ ? `claude ${'defaultGroup' in row && !pair ? "'--effort' 'high' " : ''}'--verbose' '--model' 'sonnet'${pair ? " '--effort' 'low'" : ''} 'fix'`
+ : `codex ${'foreignDocument' in row ? "'--effort' 'high' " : ''}'--verbose' '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low' 'fix'`
+ )
+ if (native) {
+ expect(submitted.launchChoice?.requestedSurface).toBe('chat')
+ expect(
+ structuredLaunchRequestedOptions({
+ explicit: submitted.sessionOptions,
+ seed: { model: 'remembered', effort: 'high' },
+ held: {}
+ })
+ ).toEqual(options)
+ expect(
+ structuredLaunchRequestedOptions({
+ explicit: submitted.sessionOptions,
+ seed: { effort: 'high' },
+ held: { effort: 'medium' }
+ })
+ ).toEqual({ ...options, effort: 'medium' })
+ }
+ expect(f.persist).toHaveBeenCalledTimes(save ? 1 : 0)
+ if (save) {
+ const saved = f.persist.mock.calls[0][2]
+ expect(saved.agentArgs).toBe('--verbose')
+ expect(saved.argumentChoices?.nodes.map((node) => node.text)).toEqual([
+ '--model sonnet',
+ ...(pair ? ['--effort low'] : [])
+ ])
+ expect(saved).not.toHaveProperty('resolvedChoiceSources')
+ const state = useAppStore.getState()
+ const fresh = resolveSourceControlActionRecipe({
+ settings: state.settings,
+ repo: scope === 'repo' ? state.repos[0] : undefined,
+ actionId: 'resolveConflicts'
+ })
+ expect(projectSourceControlActionLaunch(fresh, agent, { basePrompt: 'fix' })).toMatchObject({
+ agentArgs: '--verbose',
+ sessionOptions: options
+ })
+ f.view.unmount()
+ const reopened = f.mount()
+ if (native) {
+ await waitFor(() =>
+ expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull()
+ )
+ } else {
+ expect(
+ screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value
+ ).toBe('--verbose')
+ }
+ reopened.unmount()
+ }
+ }
+)
+
+it.each([
+ { edit: undefined, raw: '--model sonnet --verbose' },
+ { edit: '--unrelated', raw: '--unrelated' },
+ { edit: '', raw: '' }
+])('preserves untouched conflict or untouched nodes for residual $edit', async ({ edit, raw }) => {
+ const global = liftActionPermissionArgs({
+ agentId: 'claude',
+ surface: 'terminal',
+ agentArgs: '--model opus --effort high --verbose'
+ })
+ const f = mountSourceDialog(
+ { ...global, agentArgs: edit === undefined ? raw : global.agentArgs },
+ {}
+ )
+ await waitFor(() => expect(screen.getByRole('checkbox')).toBeDefined())
+ if (edit !== undefined) {
+ fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), {
+ target: { value: edit }
+ })
+ }
+ fireEvent.click(screen.getByRole('checkbox'))
+ fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart.mock.calls[0][0]).toMatchObject({
+ agentArgs: raw,
+ sessionOptions: { model: 'opus', effort: 'high' }
+ })
+ expect(f.persist).not.toHaveBeenCalled()
+})
+
+it('replaces the selected captured parent key without flattening sparse ownership or permission', async () => {
+ const f = mountSourceDialog(
+ {
+ agentId: 'claude',
+ surface: 'terminal',
+ agentArgs: '--verbose',
+ launchPreferences: { agentId: 'claude', model: 'opus', options: { effort: 'high' } }
+ },
+ {
+ launchPreferences: {
+ agentId: 'claude',
+ permissionMode: 'ask',
+ modelSelection: {
+ base: 'inherit',
+ options: { fastMode: { agentId: 'codex', value: 'true' } }
+ }
+ }
+ }
+ )
+ await waitFor(() => expect(screen.getByRole('checkbox')).toBeDefined())
+ act(() =>
+ useAppStore.setState((state) => ({
+ settings: {
+ ...state.settings!,
+ sourceControlAi: {
+ ...state.settings!.sourceControlAi!,
+ actions: {
+ resolveConflicts: {
+ agentId: 'claude',
+ launchPreferences: { agentId: 'claude', model: 'later' }
+ }
+ }
+ }
+ }
+ }))
+ )
+ fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), {
+ target: { value: '--model sonnet --verbose' }
+ })
+ fireEvent.click(screen.getByRole('checkbox'))
+ fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart.mock.calls[0][0]).toMatchObject({
+ agentArgs: '--verbose',
+ sessionOptions: { model: 'sonnet', permissionMode: 'ask' }
+ })
+ expect(f.persist).not.toHaveBeenCalled()
+})
+
+it('settles pending replacement in its old grammar before a genuine agent demotion', async () => {
+ const f = mountSourceDialog(
+ liftActionPermissionArgs({
+ agentId: 'claude',
+ surface: 'terminal',
+ agentArgs: '--model opus --effort high --verbose'
+ }),
+ {}
+ )
+ await waitFor(() =>
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' })).toBeDefined()
+ )
+ fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), {
+ target: { value: '--model sonnet --verbose' }
+ })
+ await pickDialogAgent('Codex', true)
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe(
+ '--model sonnet --verbose'
+ )
+ await pickDialogAgent('Claude', true)
+ fireEvent.click(screen.getByRole('checkbox'))
+ fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart.mock.calls[0][0].sessionOptions).toBeUndefined()
+ expect(f.onStart.mock.calls[0][0].agentArgs).toBe('--model sonnet --verbose')
+})
+
+it.each([
+ { raw: 'acceptEdits', permission: 'accept-edits', residual: '--verbose' },
+ { raw: 'plan', permission: 'ask', residual: '--permission-mode plan --verbose' }
+] as const)(
+ 'keeps independent effort and separately selected permission for raw $raw',
+ async ({ raw, permission, residual }) => {
+ const f = mountSourceDialog(
+ {
+ agentId: 'claude',
+ surface: 'terminal',
+ agentArgs: '--verbose',
+ launchPreferences: { agentId: 'claude', options: { effort: 'high' }, permissionMode: 'ask' }
+ },
+ {}
+ )
+ await waitFor(() =>
+ expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' })).toBeDefined()
+ )
+ fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), {
+ target: { value: `--model sonnet --permission-mode ${raw} --verbose` }
+ })
+ fireEvent.click(screen.getByRole('checkbox'))
+ fireEvent.click(screen.getByRole('button', { name: 'Start agent' }))
+ await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce())
+ expect(f.onStart.mock.calls[0][0]).toMatchObject({
+ agentArgs: residual,
+ sessionOptions: { model: 'sonnet', effort: 'high', permissionMode: permission }
+ })
+ expect(f.persist).not.toHaveBeenCalled()
+ }
+)
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
index 061641b452a..f33b8ea1e00 100644
--- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts
@@ -1,9 +1,16 @@
+import {
+ canonicalSourceControlActionSelection,
+ commitSourceControlActionArguments,
+ editSourceControlActionArguments,
+ pickSourceControlActionAgent,
+ selectSourceControlActionRecipe,
+ type CurrentActionSelection
+} from '../../../../shared/source-control-action-selection'
+import { captureSourceControlActionSelection } from '../../../../shared/source-control-ai-operation-policy'
import { useCallback, useMemo, useState } from 'react'
import { 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 type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
@@ -23,11 +30,10 @@ const DEFAULT_SAVE_TARGET_VALUE = 'global'
export function useSourceControlAgentActionDialog({
open,
initialRecipe: capturedRecipe,
+ initialSelection,
onOpenChange,
actionId,
baseCommandInput,
- savedCommandInputTemplate,
- savedAgentArgs,
worktreeId,
groupId,
connectionId,
@@ -36,7 +42,6 @@ export function useSourceControlAgentActionDialog({
launchPlatform,
launchSource,
recoveryContext,
- savedAgentId,
onSaveAgentDefault,
onLaunchAccepted,
onLaunchAborted,
@@ -45,30 +50,18 @@ export function useSourceControlAgentActionDialog({
}: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult {
const settings = useAppStore((state) => state.settings)
const repo = useRepoById(repoId ?? null)
- const initialRecipe = useCallback((): SourceControlActionRecipe => {
- if (capturedRecipe) {
- return capturedRecipe
- }
- const { surface, ...resolved } = resolveSourceControlActionRecipe({ settings, repo, actionId })
- const agentId = savedAgentId ?? null
- return {
- ...resolved,
- // A saved chat-or-CLI pick belongs to the saved agent, like its model.
- ...(surface && resolved.agentId === agentId ? { surface } : {}),
- agentId,
- commandInputTemplate: savedCommandInputTemplate ?? '{basePrompt}',
- ...(savedAgentArgs != null ? { agentArgs: savedAgentArgs } : {})
- }
- }, [
- capturedRecipe,
- settings,
- repo,
- actionId,
- savedAgentId,
- savedCommandInputTemplate,
- savedAgentArgs
- ])
- const [recipe, setRecipe] = useState(initialRecipe)
+ const [currentSelection, setCurrentSelection] = useState(
+ () =>
+ initialSelection ??
+ (capturedRecipe
+ ? canonicalSourceControlActionSelection(capturedRecipe)
+ : captureSourceControlActionSelection({ settings, repo, actionId }))
+ )
+ const recipe = useMemo(
+ () => selectSourceControlActionRecipe(currentSelection),
+ [currentSelection]
+ )
+ const [argumentsEdited, setArgumentsEdited] = useState(false)
const [livePick, setLivePick] = useState(undefined)
const commandTemplate = recipe.commandInputTemplate ?? '{basePrompt}'
const agentArgs = recipe.agentArgs ?? ''
@@ -113,9 +106,16 @@ export function useSourceControlAgentActionDialog({
selectedAgent,
commandInput,
trimmedCommandInput,
- agentArgs: recipe.agentArgs,
agentArgsApply: choice.launchAppliesAgentArgs,
recipe: choice.canSaveSurface ? recipe : (({ surface: _surface, ...rest }) => rest)(recipe),
+ prepareRecipe: (agent) => {
+ const submitted = argumentsEdited
+ ? commitSourceControlActionArguments(currentSelection, agent).recipe
+ : recipe
+ return choice.canSaveSurface
+ ? submitted
+ : (({ surface: _surface, ...rest }) => rest)(submitted)
+ },
saveLaunchRecipe,
saveTargetValue,
actionId,
@@ -173,19 +173,30 @@ export function useSourceControlAgentActionDialog({
const onAgentPick = (agent: TuiAgent, surface?: AgentLaunchSurface): void => {
const saved = savedSurfaceForPick(choice.observation.model, agent, surface)
setLivePick(surface)
- setRecipe(({ launchPreferences, surface: _previous, ...current }) => ({
- ...current,
- agentId: agent,
- ...(launchPreferences?.agentId === agent ? { launchPreferences } : {}),
- ...(saved ? { surface: saved } : {})
- }))
+ setCurrentSelection((previous) =>
+ pickSourceControlActionAgent(
+ argumentsEdited && selectedAgent !== agent
+ ? commitSourceControlActionArguments(previous, selectedAgent ?? agent).selection
+ : previous,
+ agent,
+ selectedAgent,
+ saved
+ )
+ )
+ if (selectedAgent !== agent) {
+ setArgumentsEdited(false)
+ }
resetDeliveryPlan()
}
- const onAgentArgsChange = resetPlanAfter((agentArgs: string) =>
- setRecipe((current) => ({ ...current, agentArgs }))
- )
+ const onAgentArgsChange = resetPlanAfter((agentArgs: string) => {
+ setArgumentsEdited(true)
+ setCurrentSelection((current) => editSourceControlActionArguments(current, agentArgs))
+ })
const onCommandTemplateChange = resetPlanAfter((commandInputTemplate: string) =>
- setRecipe((current) => ({ ...current, commandInputTemplate }))
+ setCurrentSelection((selection) => ({
+ ...selection,
+ current: { ...selection.current, commandInputTemplate }
+ }))
)
const onSaveLaunchRecipeChange = useMemo(
() => resetPlanAfter(setSaveLaunchRecipe),
diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts
index 856339fc31c..2b9f04c963f 100644
--- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts
+++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts
@@ -1,10 +1,12 @@
+import { projectSelectedSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences'
+import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection'
import type { AgentLaunchPreferences } from '@/components/settings/use-host-agent-launch-preferences'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RuntimeGitContext } from '@/runtime/runtime-git-client-context'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import type {
- SourceControlActionRecipe,
- SourceControlLaunchActionId
+ SourceControlLaunchActionId,
+ SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -25,10 +27,10 @@ type UseSourceControlAgentActionStartArgs = {
selectedAgent: TuiAgent | null
commandInput: string
trimmedCommandInput: string
- agentArgs?: string
/** False when the launch would be structured native chat, which reads no CLI arguments. */
agentArgsApply: boolean
- recipe: SourceControlActionRecipe
+ prepareRecipe?: (agent: TuiAgent) => SelectedSourceControlActionRecipe
+ recipe: SelectedSourceControlActionRecipe
saveLaunchRecipe: boolean
saveTargetValue: string
actionId: SourceControlLaunchActionId
@@ -82,9 +84,9 @@ export function useSourceControlAgentActionStart({
selectedAgent,
commandInput,
trimmedCommandInput,
- agentArgs,
agentArgsApply,
recipe,
+ prepareRecipe,
saveLaunchRecipe,
saveTargetValue,
actionId,
@@ -123,14 +125,23 @@ export function useSourceControlAgentActionStart({
useEffect(() => cancelStart, [open, worktreeId, connectionId, actionId, cancelStart])
const buildPlan = useCallback(
- (currentDetectedAgents: TuiAgent[]): SourceControlAgentActionDeliveryPlanState =>
+ (
+ currentDetectedAgents: TuiAgent[],
+ submitted: SelectedSourceControlActionRecipe
+ ): SourceControlAgentActionDeliveryPlanState =>
buildSourceControlAgentDeliveryPlan({
settings,
agentLaunchPreferences,
selectedAgent,
commandInput,
// Why: the check must judge the arguments the launch will really apply.
- agentArgs: agentArgsApply ? agentArgs : undefined,
+ agentArgs:
+ agentArgsApply && selectedAgent
+ ? projectSelectedSourceControlActionLaunch(submitted, selectedAgent, {
+ renderedPrompt: commandInput,
+ settings
+ }).agentArgs
+ : undefined,
detectedAgents: currentDetectedAgents,
connectionUnavailable,
launchPlatform,
@@ -139,7 +150,6 @@ export function useSourceControlAgentActionStart({
[
settings,
agentLaunchPreferences,
- agentArgs,
agentArgsApply,
commandInput,
connectionUnavailable,
@@ -164,7 +174,8 @@ export function useSourceControlAgentActionStart({
attemptRef.current = attempt
setIsStarting(true)
try {
- const nextPlan = buildPlan(nextAgents)
+ const submitted = prepareRecipe ? prepareRecipe(selectedAgent) : recipe
+ const nextPlan = buildPlan(nextAgents, submitted)
if (nextPlan.status === 'error') {
setDeliveryPlan(nextPlan)
return false
@@ -174,7 +185,7 @@ export function useSourceControlAgentActionStart({
selectedAgent,
trimmedCommandInput,
launchChoice,
- recipe,
+ recipe: submitted,
saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none',
actionId,
repoId,
@@ -211,6 +222,7 @@ export function useSourceControlAgentActionStart({
buildPlan,
launchChoice,
recipe,
+ prepareRecipe,
connectionUnavailable,
groupId,
launchSource,
diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx
index 57c3933563e..2d53497924e 100644
--- a/src/renderer/src/components/settings/AgentCatalogRow.tsx
+++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx
@@ -297,17 +297,20 @@ export function AgentCatalogRow({
defaultArgs={defaultArgs}
argsOverride={argsOverride}
onSaveArgs={onSaveArgs}
- permissionChip={
+ choiceChips={
permissionChoice?.flag && !permissionChoice.argsSetPermission
- ? {
- label,
- flag: permissionChoice.flag,
- removeRecipe: (text) => {
- const lifted = liftAgentPermissionArgs(agentId, text)
- return lifted.choice !== null ? lifted.args : null
- },
- onRemove: () => permissionChoice.onSelect('own')
- }
+ ? [
+ {
+ key: 'permissionMode',
+ label,
+ flag: permissionChoice.flag,
+ onRemove: (text) => {
+ const lifted = liftAgentPermissionArgs(agentId, text)
+ permissionChoice.onSelect('own')
+ return lifted.choice !== null ? lifted.args : text
+ }
+ }
+ ]
: undefined
}
note={
diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx
index d96c66bb090..88f792001e4 100644
--- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx
+++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx
@@ -16,14 +16,16 @@ it.each(['', '--default'])(
defaultArgs={defaultArgs}
argsOverride=""
onSaveArgs={vi.fn()}
- permissionChip={
+ choiceChips={
withChip
- ? {
- label: 'Claude',
- flag: '--permission-mode default',
- removeRecipe: () => null,
- onRemove: vi.fn()
- }
+ ? [
+ {
+ key: 'permissionMode',
+ label: 'Claude',
+ flag: '--permission-mode default',
+ onRemove: (text) => text
+ }
+ ]
: undefined
}
/>
diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx
index 085d8d626ae..58e75ba8607 100644
--- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx
+++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx
@@ -9,7 +9,7 @@ import {
type AgentDefaultEnvDraftInvalidReason
} from './agent-default-env-draft'
import { useAgentLaunchFieldWrite } from './use-agent-launch-field-write'
-import { AgentPermissionArgsChip } from './AgentPermissionChoiceControls'
+import { AgentArgsChoiceChips, type AgentArgsChoice } from './AgentPermissionChoiceControls'
export function AgentCommandOverrideInput({
defaultCmd,
@@ -87,20 +87,19 @@ export function AgentDefaultArgsInput({
defaultArgs,
argsOverride,
onSaveArgs,
- permissionChip,
+ choiceChips,
note,
disabled = false,
- onDraftChange
+ onDraftChange,
+ placeholder,
+ onResetArgs
}: {
defaultArgs: string
+ placeholder?: string
+ onResetArgs?: () => void | Promise
argsOverride: string
onSaveArgs: (value: string) => void | Promise
- permissionChip?: {
- label: string
- flag: string
- removeRecipe: (text: string) => string | null
- onRemove: () => void
- }
+ choiceChips?: readonly AgentArgsChoice[]
note?: string
disabled?: boolean
onDraftChange?: (value: string) => void
@@ -111,6 +110,7 @@ export function AgentDefaultArgsInput({
const chipButtonRef = useRef(null)
const argsDraft = onDraftChange ? argsOverride : (draft ?? argsOverride)
const { pending, write } = useAgentLaunchFieldWrite()
+ const chipFlags = choiceChips?.map((chip) => chip.flag).join(' ')
useLayoutEffect(() => {
const input = inputRef.current
const chip = chipRef.current
@@ -128,7 +128,7 @@ export function AgentDefaultArgsInput({
const observer = new ResizeObserver(measure)
observer.observe(chip)
return () => observer.disconnect()
- }, [permissionChip?.flag])
+ }, [chipFlags])
const commitArgs = (): void => {
if (disabled || pending || (!onDraftChange && draft === null)) {
return
@@ -164,26 +164,24 @@ export function AgentDefaultArgsInput({
}
}}
>
- {permissionChip && (
- {
+ onRemove={(chip) => {
if (disabled || pending) {
return
}
- if (onDraftChange) {
- onDraftChange(permissionChip.removeRecipe(argsDraft) ?? argsDraft)
- } else if (draft !== null) {
- setDraft(permissionChip.removeRecipe(draft) ?? draft)
+ const next = chip.onRemove(argsDraft)
+ if (!onDraftChange && draft !== null) {
+ setDraft(next)
}
inputRef.current?.focus()
- permissionChip.onRemove()
}}
/>
- )}
+ ) : null}
- {(argsOverride !== defaultArgs || argsDraft !== defaultArgs) && (
+ {(argsOverride !== defaultArgs ||
+ argsDraft !== defaultArgs ||
+ Boolean(onResetArgs && choiceChips?.length)) && (