From 85a1d7ba049ce6ff04a57cef05f5c38d820d28ec Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 29 Oct 2026 00:23:23 -0700
Subject: [PATCH] Keep action model and effort choices out of Arguments
(#34275)
* Unify dedicated agent launch choices and argument badges
* Enable action model and effort menus with argument migration
* Bound argument choice edit range processing
* Preserve independent action effort through launch and migration
* Preserve literal config arguments and explicit launch permissions
* Respect repository action argument groups and share launch controls
* Remove duplicate action launch command coverage
* Unify Source Control action choices and repository overrides
* Use precise types in action choice contracts
* Consolidate action choice test imports
* Correct action choice types and integration test boundaries
* Complete prepared terminal test result contract
* Align action integration expectations with canonical choices
* test(mobile): type the file preview retry test helpers so the test typecheck passes (#35431)
(cherry picked from commit 0774fc9373125a554358b94ee7f558022c4a0ae1)
* Commit edited action arguments before planning and launch
Reconcile deliberate raw edits once into the submitted action recipe so replacement model and effort flags reach Save and Start together. Preserve captured sources and untouched load conflicts.
* Name portable Source Control dialog fixture results
---
config/vitest.config.ts | 12 +-
.../src/session/pr-ai-triage-launch.test.ts | 14 +-
mobile/src/session/pr-ai-triage-launch.ts | 3 +-
...stence-source-control-ai-migration.test.ts | 18 +-
.../prepare-loaded-terminal-settings.ts | 20 +-
...persistence-load-repair-durability.test.ts | 277 +++++++-
.../rpc/methods/repo-rpc.test-fixture.ts | 18 +
...repo-source-control-ai-publication.test.ts | 63 ++
src/main/runtime/rpc/methods/repo.test.ts | 16 +-
...settings-source-control-projection.spec.ts | 27 +-
...ime-project-host-setup-publication.test.ts | 34 +-
.../SourceControlAgentActionDialog.tsx | 8 +-
...ourceControlAgentActionDialogForm.test.tsx | 46 +-
.../SourceControlAgentActionDialogForm.tsx | 16 +-
.../checks-panel/active-content.tsx | 31 -
.../saved-action-delivery.test.tsx | 2 +-
.../use-checks-panel-ai-queue.tsx | 8 +-
.../runSourceControlAgentActionStart.test.ts | 116 +++-
.../runSourceControlAgentActionStart.ts | 32 +-
...rce-control-action-dialog.test-fixture.tsx | 205 ++++++
...source-control-action-recipe-match.test.ts | 69 ++
.../source-control-action-recipe-match.ts | 41 +-
...urce-control-agent-action-dialog-result.ts | 4 +-
.../source-control/ai/recovery-launch.ts | 26 +-
.../ai/use-ai.operation-recovery.test.tsx | 12 +-
.../right-sidebar/source-control/ai/use-ai.ts | 12 +-
.../source-control/ai/use-recovery-ai.ts | 10 +-
.../use-saved-source-control-action-start.ts | 15 +-
.../use-source-control-action-agent-choice.ts | 4 +-
...useSourceControlAgentActionDialog.test.tsx | 637 +++++++++++++++++-
.../useSourceControlAgentActionDialog.ts | 91 +--
.../useSourceControlAgentActionStart.ts | 32 +-
.../components/settings/AgentCatalogRow.tsx | 23 +-
.../AgentLaunchDefaultsEditor.test.tsx | 16 +-
.../settings/AgentLaunchDefaultsEditor.tsx | 51 +-
.../AgentPermissionChoiceControls.tsx | 83 ++-
...itorySourceControlAiActionSaveControls.tsx | 65 ++
.../settings/SourceControlActionArguments.tsx | 178 ++++-
.../SourceControlActionModelPicker.tsx | 201 +++---
.../settings/SourceControlActionRecipeRow.tsx | 22 +-
.../SourceControlAiActionRecipeDefaults.tsx | 8 +-
...ry-source-control-ai-action-agent-draft.ts | 22 +-
...repository-source-control-ai-text-draft.ts | 43 +-
.../RepositorySourceControlAiActionRows.tsx | 91 +--
.../RepositorySourceControlAiSection.test.ts | 19 +-
...ory-source-control-ai-action-rows-props.ts | 5 +-
.../repository-source-control-ai-draft.ts | 16 +-
...sitory-source-control-ai-global-ux.test.ts | 139 +++-
.../repository-source-control-ai-labels.ts | 42 +-
...source-control-action-card-layout.test.tsx | 152 ++++-
.../source-control-action-model-copy.ts | 25 +-
...e-control-action-permission-draft.test.tsx | 432 +++++++++++-
...ource-control-action-recipe-draft-state.ts | 37 +-
.../source-control-ai-action-recipe-draft.ts | 12 +-
...e-control-ai-settings-persistence.test.tsx | 7 +-
src/renderer/src/i18n/locales/en.json | 3 +
...sion-launch-unconfirmed-or-retried.test.ts | 3 +-
.../src/lib/fix-checks-agent-launch.ts | 1 -
.../launch-agent-in-new-tab-placement.test.ts | 4 +-
.../src/lib/launch-agent-web-host-tab.ts | 4 +-
.../lib/runtime-agent-background-create.ts | 4 +-
...ssion-launch-action-effort.test-fixture.ts | 73 ++
...gent-session-launch-create-message.test.ts | 3 +
...red-agent-session-launch-create-message.ts | 10 +-
src/shared/agent-choice-args.ts | 175 +++++
src/shared/agent-choice-cli-apply.ts | 42 ++
src/shared/agent-choice-layers.ts | 76 +++
src/shared/agent-choice-reconciliation.ts | 153 +++++
src/shared/agent-launch-choices.test.ts | 624 +++++++++++++++++
src/shared/agent-launch-choices.ts | 47 ++
src/shared/agent-launch-preferences.ts | 10 +-
src/shared/agent-launch-profile.ts | 85 +++
src/shared/agent-permission-args.ts | 163 ++---
...gent-session-option-catalog-antigravity.ts | 28 +-
...ent-session-option-catalog-claude-codex.ts | 72 +-
.../agent-session-option-catalog-codebuddy.ts | 28 +-
...nt-session-option-catalog-gemini-cursor.ts | 28 +-
.../agent-session-option-catalog-grok.ts | 24 +-
.../agent-session-option-catalog-muse.ts | 28 +-
.../agent-session-option-catalog-omp.ts | 15 +-
.../agent-session-option-catalog-opencode.ts | 17 +-
.../agent-session-option-catalog-types.ts | 2 +
src/shared/agent-session-option-catalog.ts | 7 +
...ssion-option-launch-legacy.test-fixture.ts | 112 +++
src/shared/agent-session-option-launch.ts | 15 +-
src/shared/rpc-contract/git-params.ts | 23 +-
.../source-control-action-argument-choices.ts | 300 +++++++++
...source-control-action-argument-document.ts | 247 +++++++
...ource-control-action-launch-preferences.ts | 202 +++++-
.../source-control-action-model-selection.ts | 148 ++++
.../source-control-action-permission-lift.ts | 42 +-
src/shared/source-control-action-selection.ts | 307 +++++++++
.../source-control-ai-action-recipes.test.ts | 384 ++++++++++-
src/shared/source-control-ai-actions.test.ts | 18 +-
src/shared/source-control-ai-actions.ts | 120 ++--
.../source-control-ai-operation-policy.ts | 78 ++-
.../source-control-ai-recipe-save.test.ts | 36 +
src/shared/source-control-ai-recipe-save.ts | 43 +-
src/shared/source-control-ai-repo-settings.ts | 22 +-
src/shared/source-control-ai-types.ts | 5 +-
src/shared/tui-agent-launch-defaults.ts | 39 +-
.../tui-agent-permission-choice.test.ts | 125 +++-
src/shared/tui-agent-permission-lift.ts | 26 +-
...ent-background-session-remote.unit.test.ts | 121 +++-
.../prepared-action-launch.test-fixture.ts | 123 ++++
...e-control-action-phone-launch.unit.test.ts | 283 ++++++++
...ssion-terminal-host-authority.unit.test.ts | 206 ++++--
107 files changed, 7259 insertions(+), 1103 deletions(-)
create mode 100644 src/main/runtime/rpc/methods/repo-rpc.test-fixture.ts
create mode 100644 src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts
create mode 100644 src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx
create mode 100644 src/renderer/src/components/settings/RepositorySourceControlAiActionSaveControls.tsx
create mode 100644 src/renderer/src/lib/structured-agent-session-launch-action-effort.test-fixture.ts
create mode 100644 src/shared/agent-choice-args.ts
create mode 100644 src/shared/agent-choice-cli-apply.ts
create mode 100644 src/shared/agent-choice-layers.ts
create mode 100644 src/shared/agent-choice-reconciliation.ts
create mode 100644 src/shared/agent-launch-choices.test.ts
create mode 100644 src/shared/agent-launch-choices.ts
create mode 100644 src/shared/agent-launch-profile.ts
create mode 100644 src/shared/agent-session-option-launch-legacy.test-fixture.ts
create mode 100644 src/shared/source-control-action-argument-choices.ts
create mode 100644 src/shared/source-control-action-argument-document.ts
create mode 100644 src/shared/source-control-action-model-selection.ts
create mode 100644 src/shared/source-control-action-selection.ts
rename src/renderer/src/lib/launch-agent-background-session-remote.test.ts => tests/e2e/launch-agent-background-session-remote.unit.test.ts (78%)
create mode 100644 tests/e2e/prepared-action-launch.test-fixture.ts
create mode 100644 tests/e2e/source-control-action-phone-launch.unit.test.ts
rename src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts => tests/e2e/web-runtime-session-terminal-host-authority.unit.test.ts (75%)
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)) && (