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 0774fc9373)

* 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
This commit is contained in:
Brennan Benson
2026-10-29 00:23:23 -07:00
committed by GitHub
parent 25f4b45dc4
commit 85a1d7ba04
107 changed files with 7259 additions and 1103 deletions
+2 -10
View File
@@ -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 })
@@ -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)
})
+1 -2
View File
@@ -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
@@ -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
)
@@ -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) {
@@ -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<typeof createStore>) => {
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<string, unknown>).rightSidebarExplorerView
@@ -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<OrcaRuntimeService, 'getRuntimeId'> & Partial<OrcaRuntimeService>
) {
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
})
}
@@ -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<OrcaRuntimeService['updateRepo']>().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 } }
})
})
})
+1 -15
View File
@@ -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<OrcaRuntimeService, 'getRuntimeId'> & Partial<OrcaRuntimeService>
) {
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)
@@ -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' }] }
})
})
@@ -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([])
@@ -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}
@@ -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<React.ComponentProps<typeof SourceControlAgentActionDialogForm>> = {}
overrides: Partial<
Omit<React.ComponentProps<typeof SourceControlAgentActionDialogForm>, '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',
@@ -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({
<TriangleAlert className="mt-px size-3 shrink-0" />
<span>{statusCopy}</span>
</p>
) : selectedAgent && selectedRecipe.launchPreferences?.agentId === selectedAgent ? (
) : selectedAgent && selectedPreferences ? (
<p className="text-[11px] text-muted-foreground">
{describeSourceControlActionModel(selectedAgent, selectedRecipe.launchPreferences)}
{describeSourceControlActionModel(selectedAgent, selectedPreferences)}
</p>
) : null}
</div>
@@ -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}
/>
@@ -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
)
}
@@ -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,
@@ -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<Parameters<typeof runSourceControlAgentActionStart>[0]> = {}
overrides: Partial<Omit<Parameters<typeof runSourceControlAgentActionStart>[0], 'recipe'>> & {
recipe?: SourceControlActionRecipe
} = {}
): Parameters<typeof runSourceControlAgentActionStart>[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' })
)
})
@@ -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
})
@@ -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<SourceControlAgentActionDialogProps['onSaveAgentDefault']>
type MountedSourceDialog = {
view: RenderResult
mount: () => RenderResult
persist: Mock<SaveAction>
onStart: Mock<NonNullable<RunSourceControlAgentActionStartArgs['onStart']>>
props: SourceControlAgentActionDialogProps
repo: Repo
}
type RenderedSourceDialog = RenderHookResult<
ReturnType<typeof useSourceControlAgentActionDialog>,
unknown
> & {
onStart: Mock
onSaveAgentDefault: Mock
launchPreferences: NonNullable<SourceControlActionRecipe['launchPreferences']>
}
export function mountSourceDialog(
global: SourceControlActionRecipe,
own: SourceControlActionRecipe,
save?: SaveAction,
onStart = vi
.fn<NonNullable<RunSourceControlAgentActionStartArgs['onStart']>>()
.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<SaveAction>(
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(<SourceControlAgentActionDialog {...props} />, { 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<NonNullable<RunSourceControlAgentActionStartArgs['onStart']>>[0]
) {
const { agent, agentArgs, sessionOptions } = submitted
return buildAgentStartupPlan({
agent,
prompt: 'fix',
platform: 'darwin',
shell: 'posix',
cmdOverrides: {},
agentArgs,
...terminalSessionOptionInputs({ agent, agentArgs, sessionOptions, shell: 'posix' })
})
}
@@ -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)
})
@@ -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
)
}
@@ -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
@@ -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<boolean> {
@@ -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,
@@ -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')
})
@@ -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',
@@ -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<GitStatusEntry, 'path' | 'status' | 'area'>[]
getLaunchActionRecipe: (actionId: SourceControlLaunchActionId) => SourceControlActionRecipe
getLaunchActionRecipe: (
actionId: SourceControlLaunchActionId
) => SelectedSourceControlActionRecipe
getStoreState: () => SourceControlAiStoreSnapshot
}
@@ -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,
@@ -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<Repo, 'id' | 'kind' | 'connectionId' | 'executionHostId'> | null
recipe: SourceControlActionRecipe
recipe: SelectedSourceControlActionRecipe
/** This open's deliberate pick; absent replays the saved recipe. */
livePick: AgentLaunchSurface | undefined
settings: GlobalSettings | null
@@ -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<HTMLInputElement>('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<SourceControlActionRecipe>({
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<void>()
const save = vi.fn<SaveAction>(() => 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<HTMLInputElement>('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<HTMLInputElement>('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<SourceControlActionRecipe>({
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<HTMLInputElement>('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<SourceControlActionRecipe>({
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<SourceControlActionRecipe>({
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<HTMLInputElement>('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()
}
)
@@ -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<CurrentActionSelection>(
() =>
initialSelection ??
(capturedRecipe
? canonicalSourceControlActionSelection(capturedRecipe)
: captureSourceControlActionSelection({ settings, repo, actionId }))
)
const recipe = useMemo(
() => selectSourceControlActionRecipe(currentSelection),
[currentSelection]
)
const [argumentsEdited, setArgumentsEdited] = useState(false)
const [livePick, setLivePick] = useState<AgentLaunchSurface | undefined>(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),
@@ -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,
@@ -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={
@@ -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
}
/>
@@ -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<void>
argsOverride: string
onSaveArgs: (value: string) => void | Promise<void>
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<HTMLButtonElement>(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 && (
<AgentPermissionArgsChip
{...permissionChip}
{choiceChips?.length ? (
<AgentArgsChoiceChips
chips={choiceChips}
chipRef={chipRef}
buttonRef={chipButtonRef}
disabled={disabled || pending}
onRemove={() => {
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}
<Textarea
variant="composite"
ref={inputRef}
@@ -200,7 +198,7 @@ export function AgentDefaultArgsInput({
return
}
if (
permissionChip &&
choiceChips?.length &&
event.key === 'Backspace' &&
!event.repeat &&
!event.altKey &&
@@ -222,9 +220,10 @@ export function AgentDefaultArgsInput({
}
}}
placeholder={
permissionChip
choiceChips?.length
? undefined
: defaultArgs ||
: placeholder ||
defaultArgs ||
translate(
'auto.components.settings.AgentsPane.6f99bf5dd0',
'No default arguments'
@@ -234,7 +233,9 @@ export function AgentDefaultArgsInput({
className="block min-h-5 [field-sizing:content]"
/>
</div>
{(argsOverride !== defaultArgs || argsDraft !== defaultArgs) && (
{(argsOverride !== defaultArgs ||
argsDraft !== defaultArgs ||
Boolean(onResetArgs && choiceChips?.length)) && (
<Button
type="button"
variant="ghost"
@@ -246,7 +247,7 @@ export function AgentDefaultArgsInput({
}
write(
defaultArgs,
() => onSaveArgs(defaultArgs),
() => (onResetArgs ? onResetArgs() : onSaveArgs(defaultArgs)),
() => setDraft((current) => (current === argsDraft ? null : current))
)
}}
@@ -276,13 +276,14 @@ export function AgentPermissionMenu({
)
}
export function AgentPermissionArgsChip({
function AgentArgsChoiceChip({
label,
flag,
onRemove,
disabled,
chipRef,
buttonRef
buttonRef,
choiceKey
}: {
label: string
flag: string
@@ -290,13 +291,14 @@ export function AgentPermissionArgsChip({
disabled?: boolean
chipRef?: Ref<HTMLSpanElement>
buttonRef?: Ref<HTMLButtonElement>
choiceKey: string
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
ref={chipRef}
className="relative z-10 -mb-5 flex h-5 w-fit max-w-[60%] items-center gap-1 rounded bg-muted px-1.5 font-mono text-xs leading-5 text-foreground/80 focus-within:ring-2 focus-within:ring-ring"
className="flex h-5 min-w-0 max-w-full items-center gap-1 rounded bg-muted px-1.5 font-mono text-xs leading-5 text-foreground/80 focus-within:ring-2 focus-within:ring-ring"
>
<span className="truncate">{flag}</span>
<button
@@ -318,11 +320,15 @@ export function AgentPermissionArgsChip({
}
}
}}
aria-label={translate(
'settings.agents.permissionChipRemove',
"Remove {{flag}} and use {{agent}}'s own settings",
{ flag, agent: label }
)}
aria-label={
choiceKey !== 'permissionMode'
? translate('settings.agents.choiceChipRemove', 'Remove {{flag}}', { flag })
: translate(
'settings.agents.permissionChipRemove',
"Remove {{flag}} and use {{agent}}'s own settings",
{ flag, agent: label }
)
}
className="rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
>
<X className="size-3" />
@@ -330,12 +336,63 @@ export function AgentPermissionArgsChip({
</span>
</TooltipTrigger>
<TooltipContent>
{translate(
'settings.agents.permissionChipTooltip',
'Added by Permissions. Remove it to use {{agent}}’s own settings.',
{ agent: label }
)}
{choiceKey !== 'permissionMode'
? translate(
'settings.agents.choiceChipTooltip',
'Added by {{choice}}. Remove it to use the default.',
{
choice:
choiceKey === 'model'
? translate('settings.sourceControl.actionModel.model', 'Model')
: translate('settings.sourceControl.actionModel.effort', 'Effort')
}
)
: translate(
'settings.agents.permissionChipTooltip',
'Added by Permissions. Remove it to use {{agent}}’s own settings.',
{ agent: label }
)}
</TooltipContent>
</Tooltip>
)
}
export type AgentArgsChoice = {
key: string
label: string
flag: string
onRemove: (text: string) => string
}
export function AgentArgsChoiceChips({
chips,
disabled,
chipRef,
buttonRef,
onRemove
}: {
chips: readonly AgentArgsChoice[]
disabled?: boolean
chipRef?: Ref<HTMLSpanElement>
buttonRef?: Ref<HTMLButtonElement>
onRemove: (chip: AgentArgsChoice) => void
}): React.JSX.Element {
return (
<span
ref={chipRef}
className="relative z-10 -mb-5 flex h-5 w-fit max-w-full items-center gap-1"
>
{chips.map((chip, index) => (
<AgentArgsChoiceChip
key={chip.key}
choiceKey={chip.key}
label={chip.label}
flag={chip.flag}
disabled={disabled}
buttonRef={index === chips.length - 1 ? buttonRef : undefined}
onRemove={() => onRemove(chip)}
/>
))}
</span>
)
}
@@ -0,0 +1,65 @@
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
type RepositorySourceControlAiActionSaveControlsProps = {
actionDirty: boolean
isSavingAction: boolean
onDiscard: () => void
onSave: () => void
}
export function RepositorySourceControlAiActionSaveControls({
actionDirty,
isSavingAction,
onDiscard,
onSave
}: RepositorySourceControlAiActionSaveControlsProps) {
return (
<div className="mt-3 flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{actionDirty
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
'Unsaved changes'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
'Saved'
)}
</p>
<div className="flex items-center gap-2">
{actionDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={onDiscard}
disabled={isSavingAction}
>
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
'Discard'
)}
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={onSave}
disabled={!actionDirty || isSavingAction}
>
{isSavingAction
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
'Saving...'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
'Save'
)}
</Button>
</div>
</div>
)
}
@@ -1,11 +1,22 @@
import type { SourceControlActionLaunchPreferences } from '../../../../shared/source-control-ai-actions'
import type {
SourceControlActionLaunchPreferences,
SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import {
agentPermissionChoices,
agentPermissionArgs,
resolveAgentPermission
} from '../../../../shared/tui-agent-permission-choice'
import { agentArgsSetPermission } from '../../../../shared/tui-agent-permission-lift'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import {
editActionArgumentChoices,
type ActionArgumentChoices
} from '../../../../shared/source-control-action-argument-choices'
import type { AgentChoicePatch } from '../../../../shared/agent-launch-choices'
import { composeAgentLaunchModel } from '../../../../shared/agent-session-option-launch'
import { projectAgentLaunchProfile } from '../../../../shared/agent-launch-profile'
import { sourceControlActionOptions } from '../../../../shared/source-control-action-launch-preferences'
import { SourceControlActionModelPicker } from './SourceControlActionModelPicker'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { useAppStore } from '@/store'
@@ -17,6 +28,11 @@ export function SourceControlActionArguments({
agent,
value,
preferences,
authoredPreferences,
authoredAgent,
modelSelectionBase = 'default',
argumentChoices,
placeholder,
disabled,
showArguments = true,
onArgsChange,
@@ -25,10 +41,19 @@ export function SourceControlActionArguments({
agent: TuiAgent | null | undefined
value: string | null | undefined
preferences?: SourceControlActionLaunchPreferences
authoredPreferences?: SourceControlActionLaunchPreferences | null
authoredAgent?: SourceControlActionRecipe['agentId']
modelSelectionBase?: 'inherit' | 'default'
argumentChoices?: ActionArgumentChoices
placeholder?: string
disabled?: boolean
showArguments?: boolean
onArgsChange: (value: string, commit?: boolean) => void
onPreferencesChange: (value: SourceControlActionLaunchPreferences | undefined) => void
onPreferencesChange: (
value: SourceControlActionLaunchPreferences | undefined,
userArgs?: string,
argumentChoices?: ActionArgumentChoices | null
) => void
}): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const selected = preferences?.agentId === agent ? preferences : undefined
@@ -37,15 +62,85 @@ export function SourceControlActionArguments({
const label = agent ? (AGENT_CATALOG.find((entry) => entry.id === agent)?.label ?? agent) : ''
const flag = agent && selected?.permissionMode ? agentPermissionArgs(agent, choice) : ''
const loose = agent ? agentArgsSetPermission(agent, value ?? '') : false
const select = (next: typeof choice | 'default'): void => {
const commit = (patch: AgentChoicePatch, text = value ?? ''): string => {
if (!agent || disabled) {
return
return text
}
const { permissionMode: _previous, ...rest } = selected ?? { agentId: agent }
onPreferencesChange(
next !== 'default' ? { ...rest, permissionMode: next } : rest.model ? rest : undefined
const changed =
(patch.model !== undefined && (patch.model ?? undefined) !== selected?.model) ||
Object.entries(patch.options ?? {}).some(
([key, next]) => (next ?? undefined) !== selected?.options?.[key]
) ||
(patch.permissionMode !== undefined &&
(patch.permissionMode === 'default' ? undefined : patch.permissionMode) !==
selected?.permissionMode)
if (!changed) {
return text
}
const result = editActionArgumentChoices(
{
agentId: authoredAgent ?? argumentChoices?.syntaxAgent,
agentArgs: value ?? undefined,
argumentChoices,
launchPreferences:
authoredPreferences === undefined ? preferences : (authoredPreferences ?? undefined)
},
agent,
selected ?? {},
patch,
text,
value === undefined ? modelSelectionBase : 'default'
)
onPreferencesChange(
result.launchPreferences,
result.agentArgs !== (value ?? '') ? result.agentArgs : undefined,
result.argumentChoices ?? null
)
return result.agentArgs ?? text
}
const select = (permissionMode: typeof choice | 'default'): void => {
commit({ permissionMode })
}
const chips = agent
? Object.entries({ model: selected?.model, ...selected?.options }).flatMap(([key, chosen]) => {
if (!chosen) {
return []
}
const option = sourceControlActionOptions(agent, selected?.model ?? '').find(
({ option }) => option.id === key
)?.option
const flag = option?.apply.composedIntoModel
? `${option.label} ${chosen}`
: projectAgentLaunchProfile(
agent,
{ agentPermissionDefault: 'own' },
{
target: 'legacy-permission',
userArgs: '',
choices:
key === 'model'
? {
model: composeAgentLaunchModel(agent, {
...selected?.options,
model: chosen
})
}
: { options: { [key]: chosen } }
}
).args
return flag
? [
{
key,
label,
flag,
onRemove: (text: string) =>
commit(key === 'model' ? { model: null } : { options: { [key]: null } }, text)
}
]
: []
})
: []
return (
<>
{agent ? (
@@ -74,10 +169,50 @@ export function SourceControlActionArguments({
/>
</div>
) : null}
{agent ? (
<SourceControlActionModelPicker
agent={agent}
value={selected}
disabled={disabled}
layout="contents"
onChange={(next) =>
commit({
...(next?.model !== selected?.model ? { model: next?.model ?? null } : {}),
options: Object.fromEntries(
[
...new Set([
...Object.keys(selected?.options ?? {}),
...Object.keys(next?.options ?? {})
])
]
.filter(
(key) =>
next?.model === selected?.model &&
next?.options?.[key] !== selected?.options?.[key]
)
.map((key) => [key, next?.options?.[key] ?? null])
)
})
}
/>
) : null}
{showArguments ? (
<div className="w-full min-w-0">
<AgentDefaultArgsInput
defaultArgs=""
placeholder={placeholder}
onResetArgs={
argumentChoices
? () =>
onPreferencesChange(
authoredPreferences === undefined
? preferences
: (authoredPreferences ?? undefined),
'',
null
)
: undefined
}
argsOverride={value ?? ''}
disabled={disabled}
onDraftChange={(text) => onArgsChange(text)}
@@ -87,20 +222,19 @@ export function SourceControlActionArguments({
onArgsChange(text, true)
}
}}
permissionChip={
flag && !loose
? {
label,
flag,
removeRecipe: (text) =>
agent
? (liftActionPermissionArgs({ agentId: agent, agentArgs: text }, true)
.agentArgs ?? null)
: null,
onRemove: () => select('own')
}
: undefined
}
choiceChips={[
...(flag && !loose
? [
{
key: 'permissionMode',
label,
flag,
onRemove: (text: string) => commit({ permissionMode: 'own' }, text)
}
]
: []),
...chips
]}
note={
loose
? translate(
@@ -1,6 +1,11 @@
import { reconcileAgentChoiceArgs } from '../../../../shared/agent-choice-reconciliation'
import {
normalizeAgentModelOptionChoices,
type AgentDedicatedChoices
} from '../../../../shared/agent-launch-choices'
import {
findCatalogModel,
getAgentSessionOptionCatalog
getAgentSessionOptionLaunchCatalog
} from '../../../../shared/agent-session-option-catalog'
import type { SourceControlActionLaunchPreferences } from '../../../../shared/source-control-ai-actions'
import {
@@ -19,46 +24,38 @@ import { translate } from '@/i18n/i18n'
const AGENT_DEFAULT_VALUE = '__agent_default__'
type SourceControlActionModelPickerProps = {
export type AgentChoiceControlsProps = {
agent: TuiAgent | null | undefined
value: SourceControlActionLaunchPreferences | undefined
value: AgentDedicatedChoices | undefined
disabled?: boolean
layout?: 'stacked' | 'contents'
onChange: (value: SourceControlActionLaunchPreferences | undefined) => void
onChange: (value: AgentDedicatedChoices | undefined) => void
}
export function SourceControlActionModelPicker({
export function AgentChoiceControls({
agent,
value,
disabled,
layout = 'stacked',
onChange
}: SourceControlActionModelPickerProps): React.JSX.Element | null {
}: AgentChoiceControlsProps): React.JSX.Element | null {
const picksModel = sourceControlActionAgentPicksModel(agent)
if (!agent || !picksModel) {
return null
}
const catalog = picksModel ? getAgentSessionOptionCatalog(agent) : null
const selected = value?.agentId === agent ? value : undefined
const catalog = picksModel ? getAgentSessionOptionLaunchCatalog(agent) : null
const selected = value
// Model and permission mode are saved independently; a model change keeps the mode.
const save = (next: Omit<SourceControlActionLaunchPreferences, 'agentId'>): void =>
onChange(
next.model || next.permissionMode
? {
agentId: agent,
...(next.model
? { model: next.model, ...(next.options ? { options: next.options } : {}) }
: {}),
...(next.permissionMode ? { permissionMode: next.permissionMode } : {})
}
: undefined
)
const { permissionMode } = selected ?? {}
const save = (next: AgentDedicatedChoices): void =>
onChange({
...normalizeAgentModelOptionChoices(next),
...(next.permissionMode ? { permissionMode: next.permissionMode } : {})
})
const unlistedModel =
catalog && selected?.model && !findCatalogModel(catalog, selected.model)
? selected.model
: undefined
const options = selected?.model ? sourceControlActionOptions(agent, selected.model) : []
const options = sourceControlActionOptions(agent, selected?.model ?? '')
const defaultLabel = translate('settings.sourceControl.actionModel.effortDefault', 'Default')
return (
<div className={layout === 'contents' ? 'contents' : 'grid min-w-0 grid-cols-1 gap-2'}>
@@ -70,7 +67,16 @@ export function SourceControlActionModelPicker({
value={selected?.model ?? AGENT_DEFAULT_VALUE}
disabled={disabled}
onValueChange={(model) =>
save({ ...(model === AGENT_DEFAULT_VALUE ? {} : { model }), permissionMode })
onChange(
reconcileAgentChoiceArgs({
agent,
choices: selected ?? {},
userArgs: '',
mode: 'choice-edit',
keys: new Set(['model']),
patch: { model: model === AGENT_DEFAULT_VALUE ? null : model }
}).choices
)
}
>
<SourceControlActionOptionTrigger
@@ -97,71 +103,90 @@ export function SourceControlActionModelPicker({
</Select>
</SourceControlActionOptionField>
) : null}
{selected?.model
? options.map(({ option, route }) => {
const choices =
option.kind.type === 'select'
? option.kind.choices
: [
{
value: 'true',
label: translate('settings.sourceControl.actionModel.on', 'On')
},
{
value: 'false',
label: translate('settings.sourceControl.actionModel.off', 'Off')
}
]
const current = selected.options?.[option.id]
return (
<SourceControlActionOptionField
key={option.id}
label={option.label}
hint={sourceControlActionRouteHint(route)}
>
<Select
value={current ?? AGENT_DEFAULT_VALUE}
disabled={disabled}
onValueChange={(next) => {
const { [option.id]: _old, ...rest } = selected.options ?? {}
const encoded =
next === AGENT_DEFAULT_VALUE
? null
: encodeCatalogOptionValue(
option.kind,
option.kind.type === 'boolean' ? next === 'true' : next
)
const values = {
...rest,
...(encoded === null ? {} : { [option.id]: encoded })
}
onChange({ ...selected, options: values })
}}
>
<SourceControlActionOptionTrigger
disabled={disabled}
label={
current
? (choices.find((choice) => choice.value === current)?.label ?? current)
: defaultLabel
}
/>
<SelectContent>
<SelectItem value={AGENT_DEFAULT_VALUE}>{defaultLabel}</SelectItem>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label}
</SelectItem>
))}
{current && !choices.some((choice) => choice.value === current) ? (
<SelectItem value={current}>{current}</SelectItem>
) : null}
</SelectContent>
</Select>
</SourceControlActionOptionField>
)
})
: null}
{options.map(({ option, route }) => {
const choices =
option.kind.type === 'select'
? option.kind.choices
: [
{
value: 'true',
label: translate('settings.sourceControl.actionModel.on', 'On')
},
{
value: 'false',
label: translate('settings.sourceControl.actionModel.off', 'Off')
}
]
const current = selected?.options?.[option.id]
return (
<SourceControlActionOptionField
key={option.id}
label={option.label}
hint={sourceControlActionRouteHint(route)}
>
<Select
value={current ?? AGENT_DEFAULT_VALUE}
disabled={disabled}
onValueChange={(next) => {
const { [option.id]: _old, ...rest } = selected?.options ?? {}
const encoded =
next === AGENT_DEFAULT_VALUE
? null
: encodeCatalogOptionValue(
option.kind,
option.kind.type === 'boolean' ? next === 'true' : next
)
const values = {
...rest,
...(encoded === null ? {} : { [option.id]: encoded })
}
save({ ...selected, options: values })
}}
>
<SourceControlActionOptionTrigger
disabled={disabled}
label={
current
? (choices.find((choice) => choice.value === current)?.label ?? current)
: defaultLabel
}
/>
<SelectContent>
<SelectItem value={AGENT_DEFAULT_VALUE}>{defaultLabel}</SelectItem>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label}
</SelectItem>
))}
{current && !choices.some((choice) => choice.value === current) ? (
<SelectItem value={current}>{current}</SelectItem>
) : null}
</SelectContent>
</Select>
</SourceControlActionOptionField>
)
})}
</div>
)
}
export function SourceControlActionModelPicker(
props: Omit<AgentChoiceControlsProps, 'value' | 'onChange'> & {
value: SourceControlActionLaunchPreferences | undefined
onChange: (value: SourceControlActionLaunchPreferences | undefined) => void
}
): React.JSX.Element {
return (
<AgentChoiceControls
{...props}
value={props.value?.agentId === props.agent ? props.value : undefined}
onChange={(value) =>
props.onChange(
props.agent && value && Object.keys(value).length
? { agentId: props.agent, ...value }
: undefined
)
}
/>
)
}
@@ -1,3 +1,5 @@
import { sourceControlActionSelectedPreferences } from '../../../../shared/source-control-action-launch-preferences'
import type { ActionArgumentChoices } from '../../../../shared/source-control-action-argument-choices'
import { SourceControlActionAgentPicker } from './SourceControlActionAgentPicker'
import { SourceControlActionArguments } from './SourceControlActionArguments'
import { Terminal } from 'lucide-react'
@@ -55,7 +57,9 @@ type SourceControlActionRecipeRowProps<ActionId extends AiActionId> = {
launchPreferences?: SourceControlActionLaunchPreferences
onLaunchPreferencesChange?: (
actionId: ActionId,
value: SourceControlActionLaunchPreferences | undefined
value: SourceControlActionLaunchPreferences | undefined,
userArgs?: string,
argumentChoices?: ActionArgumentChoices | null
) => void
onTemplateChange: (actionId: ActionId, value: string) => void
onAgentArgsChange: (actionId: ActionId, value: string, commit?: boolean) => void
@@ -190,10 +194,22 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
showArguments={showArguments}
agent={pickerAgent}
value={draftValue.agentArgs}
preferences={launchPreferences}
preferences={
pickerAgent
? sourceControlActionSelectedPreferences(
{ ...draftValue, launchPreferences },
pickerAgent
)
: undefined
}
authoredAgent={selectedAgent}
authoredPreferences={launchPreferences ?? null}
argumentChoices={draftValue.argumentChoices}
disabled={actual.status === 'pending' || isSavingTemplate}
onArgsChange={(value, commit) => onAgentArgsChange(actionId, value, commit)}
onPreferencesChange={(value) => onLaunchPreferencesChange?.(actionId, value)}
onPreferencesChange={(value, userArgs, nodes) =>
onLaunchPreferencesChange?.(actionId, value, userArgs, nodes)
}
/>
</div>
) : (
@@ -123,6 +123,7 @@ export function SourceControlAiActionRecipeDefaults({
onActionTemplateChange,
onActionAgentArgsChange,
onActionLaunchPreferencesChange,
retireActionAgentDocument,
saveActionTemplateDraft,
discardActionTemplateDraft,
appendVariable
@@ -175,6 +176,9 @@ export function SourceControlAiActionRecipeDefaults({
}
return next
})
if (saved && changed) {
retireActionAgentDocument(actionId, agentId)
}
if (saved && changed && captured) {
onActionLaunchPreferencesChange(actionId, (current) =>
retireActionLaunchPreferences(current, captured, agentId)
@@ -237,8 +241,8 @@ export function SourceControlAiActionRecipeDefaults({
surface={recipe?.surface}
onAgentChange={(id, value, choice) => void onActionAgentChange(id, value, choice)}
launchPreferences={actionRecipeDraftState.values[actionId].launchPreferences}
onLaunchPreferencesChange={(id, value) =>
void onActionLaunchPreferencesChange(id, value)
onLaunchPreferencesChange={(id, value, userArgs, nodes) =>
void onActionLaunchPreferencesChange(id, value, userArgs, nodes)
}
onTemplateChange={onActionTemplateChange}
onAgentArgsChange={onActionAgentArgsChange}
@@ -1,3 +1,4 @@
import { demoteActionArgumentChoices } from '../../../../shared/source-control-action-argument-choices'
import type {
SourceControlActionRecipe,
SourceControlActionId,
@@ -7,7 +8,6 @@ import type { RepoSourceControlAiOverrides } from '../../../../shared/source-con
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
normalizeRepoAiDraft,
readCompleteRecipeForDraft,
setActionOverride
} from './repository/source-control/repository-source-control-ai-draft'
@@ -17,15 +17,21 @@ type RepoActionRecipe = NonNullable<
export function withRepoAiActionAgent(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
_settings: GlobalSettings | null,
actionId: SourceControlActionId,
agentId: RepoActionRecipe['agentId'],
choice?: Pick<SourceControlActionRecipe, 'surface'>
): RepoSourceControlAiOverrides {
const currentRecipe = readCompleteRecipeForDraft(base, settings, actionId)
const currentRecipe = base.actionOverrides?.[actionId] ?? {}
// A model, permission mode and chat-or-CLI pick are one agent's choices; none survives a change.
const { launchPreferences, surface, ...rest } = currentRecipe
const sameAgent = currentRecipe.agentId === agentId
const { launchPreferences, surface, ...rest } =
sameAgent || !currentRecipe.argumentChoices
? currentRecipe
: demoteActionArgumentChoices({
...currentRecipe,
agentArgs: currentRecipe.agentArgs ?? undefined
})
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...rest,
@@ -44,15 +50,11 @@ export function withRepoAiActionAgent(
export function withRepoAiActionLaunchPreferences(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
_settings: GlobalSettings | null,
actionId: SourceControlActionId,
launchPreferences: SourceControlActionLaunchPreferences | undefined
): RepoSourceControlAiOverrides {
const { launchPreferences: _previous, ...currentRecipe } = readCompleteRecipeForDraft(
base,
settings,
actionId
)
const { launchPreferences: _previous, ...currentRecipe } = base.actionOverrides?.[actionId] ?? {}
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...currentRecipe,
@@ -1,3 +1,8 @@
import {
transportActionArgumentChoices,
demoteActionArgumentChoices,
type ActionArgumentChoices
} from '../../../../shared/source-control-action-argument-choices'
import type { Dispatch, SetStateAction } from 'react'
import type {
SourceControlActionId,
@@ -25,13 +30,23 @@ export function resetActionDraftLaunchPreferences(
const draft = current[actionId]
if (
!draft ||
retireActionLaunchPreferences(draft.launchPreferences, captured, agentId) ===
draft.launchPreferences
((!draft.argumentChoices || draft.argumentChoices.syntaxAgent === agentId) &&
retireActionLaunchPreferences(draft.launchPreferences, captured, agentId) ===
draft.launchPreferences)
) {
return current
}
const { launchPreferences: _oldAgent, ...text } = draft
return { ...current, [actionId]: text }
const text =
draft.argumentChoices && draft.argumentChoices.syntaxAgent !== agentId
? demoteActionArgumentChoices({ ...draft, agentArgs: draft.agentArgs ?? undefined })
: draft
return {
...current,
[actionId]: {
...text,
launchPreferences: retireActionLaunchPreferences(draft.launchPreferences, captured, agentId)
}
}
}
export function patchActionTextDraft(
@@ -58,11 +73,15 @@ export function createRepoActionTextDraftEditors(
) {
const updateActionLaunchPreferences = (
actionId: SourceControlActionId,
value: SourceControlActionLaunchPreferences | undefined
value: SourceControlActionLaunchPreferences | undefined,
userArgs?: string,
argumentChoices?: ActionArgumentChoices | null
): void => {
setActionTextDrafts((current) =>
patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
launchPreferences: value
launchPreferences: value,
...(argumentChoices !== undefined ? { argumentChoices: argumentChoices ?? undefined } : {}),
...(userArgs !== undefined ? { agentArgs: userArgs } : {})
})
)
}
@@ -82,7 +101,17 @@ export function createRepoActionTextDraftEditors(
): void => {
setActionTextDrafts((current) => {
const patched = patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
agentArgs: value
agentArgs: value,
argumentChoices: transportActionArgumentChoices(
{
...(current[actionId] ??
readActionRecipeTextDraft(immediateRepoAiRef.current, actionId)),
agentArgs:
(current[actionId] ?? readActionRecipeTextDraft(immediateRepoAiRef.current, actionId))
.agentArgs ?? undefined
},
value
)
})
return commit
? {
@@ -1,4 +1,8 @@
import type { RepositorySourceControlAiActionRowsProps } from './repository-source-control-ai-action-rows-props'
import {
sourceControlActionSelectedPreferences,
sourceControlActionExecutionArgs
} from '../../../../../../shared/source-control-action-launch-preferences'
import { useAppStore } from '@/store/index'
import { useAgentMenuObservation } from '@/components/tab-bar/use-new-tab-agent-menu-rows'
import { sourceControlRepoAgentRows } from '@/lib/source-control-agent-picker'
@@ -22,7 +26,7 @@ import {
} from '../../../../../../shared/source-control-ai-actions'
import { Input } from '../../../ui/input'
import { Label } from '../../../ui/label'
import { Button } from '../../../ui/button'
import { RepositorySourceControlAiActionSaveControls } from '../../RepositorySourceControlAiActionSaveControls'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../../../source-control/SourceControlActionVariableChips'
@@ -126,7 +130,9 @@ export function RepositorySourceControlAiActionRows({
const agentArgsPlaceholder =
hasOverride && agentArgsValue
? ''
: inheritedAgentArgs ||
: (pickerAgent
? sourceControlActionExecutionArgs(recipe, pickerAgent)
: inheritedAgentArgs) ||
(isLaunchAction
? ''
: getSourceControlAgentArgsPlaceholder(
@@ -163,13 +169,7 @@ export function RepositorySourceControlAiActionRows({
<span>
{commandTemplateStateLabel({ hasOverride, inheritedTemplate, actionId })}
</span>
<span>
{agentArgsStateLabel({
hasOverride: override?.agentArgs !== undefined,
inheritedAgentArgs,
repoAgentArgs: agentArgsValue
})}
</span>
<span>{agentArgsStateLabel({ recipe, agent: pickerAgent, override })}</span>
</div>
</div>
<Select
@@ -195,7 +195,7 @@ export function RepositorySourceControlAiActionRows({
</SelectContent>
</Select>
</div>
<div className="grid items-start gap-3 sm:grid-cols-[180px_1fr]">
<div className="grid min-w-0 grid-cols-1 items-start gap-3">
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">
{translate(
@@ -258,6 +258,8 @@ export function RepositorySourceControlAiActionRows({
) : agentSupportText ? (
<p className="text-[11px] text-muted-foreground">{agentSupportText}</p>
) : null}
</div>
<div className="flex min-w-0 flex-wrap items-start gap-3">
{isLaunchAction ? (
<SourceControlActionArguments
showArguments={shouldShowSourceControlActionArguments({
@@ -268,13 +270,24 @@ export function RepositorySourceControlAiActionRows({
})}
agent={pickerAgent}
value={override?.agentArgs}
preferences={recipe.launchPreferences}
placeholder={
override?.agentArgs === undefined ? agentArgsPlaceholder : undefined
}
preferences={
pickerAgent
? sourceControlActionSelectedPreferences(recipe, pickerAgent)
: undefined
}
argumentChoices={override?.argumentChoices}
authoredAgent={override?.agentId}
modelSelectionBase="inherit"
authoredPreferences={override?.launchPreferences ?? null}
disabled={!hasOverride || actual.status === 'pending' || isSavingAction}
onArgsChange={(value, commit) =>
onActionAgentArgsChange(actionId, value, commit)
}
onPreferencesChange={(value) =>
onActionLaunchPreferencesChange(actionId, value)
onPreferencesChange={(value, userArgs, nodes) =>
onActionLaunchPreferencesChange(actionId, value, userArgs, nodes)
}
/>
) : (
@@ -316,52 +329,12 @@ export function RepositorySourceControlAiActionRows({
</div>
</div>
{hasOverride ? (
<div className="mt-3 flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{actionDirty
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e',
'Unsaved changes'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8',
'Saved'
)}
</p>
<div className="flex items-center gap-2">
{actionDirty ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onActionDiscard(actionId)}
disabled={isSavingAction}
>
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc',
'Discard'
)}
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onActionSave(actionId)}
disabled={!actionDirty || isSavingAction}
>
{isSavingAction
? translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8',
'Saving...'
)
: translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12',
'Save'
)}
</Button>
</div>
</div>
<RepositorySourceControlAiActionSaveControls
actionDirty={actionDirty}
isSavingAction={isSavingAction}
onDiscard={() => onActionDiscard(actionId)}
onSave={() => onActionSave(actionId)}
/>
) : null}
</div>
)
@@ -185,10 +185,10 @@ describe('RepositorySourceControlAiActionRows', () => {
expect(ensure).toHaveBeenCalledTimes(2)
expect(detectAgents).toHaveBeenCalledTimes(1)
for (const card of cards) {
expect(card.textContent).not.toContain('Model')
expect(card.textContent).toContain('Model')
expect(card.textContent).toContain('Permissions')
expect(card.textContent).toContain('Ask')
expect(card.querySelector('button[aria-label="saved"]')).toBeNull()
expect(card.querySelector('button[aria-label="saved"]')).not.toBeNull()
const controls = Array.from(
card.querySelectorAll<HTMLButtonElement>(
'button[role="combobox"].min-w-0, button[aria-haspopup="menu"]'
@@ -209,8 +209,8 @@ describe('RepositorySourceControlAiActionRows', () => {
)
expect(controls.length).toBeGreaterThan(0)
expect(controls.every((control) => !control.disabled)).toBe(true)
expect(card.textContent).not.toContain('Model')
expect(card.textContent).not.toContain('Effort')
expect(card.textContent).toContain('Model')
expect(card.textContent?.includes('Reasoning effort')).toBe(agent === 'codex')
expect(card.textContent).toContain('Permissions')
expect(
card.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value
@@ -268,7 +268,7 @@ describe('dropRepoLegacyInstructionForAction', () => {
})
it.each(['', '--model raw'])(
'a repository surface edit saves the complete target and keeps its arguments (%s)',
'a repository agent/surface edit keeps its owned Arguments group without pinning global choices (%s)',
(agentArgs) => {
const settings = useAppStore.getState().settings!
const launchPreferences = {
@@ -293,16 +293,17 @@ it.each(['', '--model raw'])(
expect(edited.actionOverrides?.fixChecks).toMatchObject({
agentId: 'codex',
agentArgs,
launchPreferences,
surface: 'terminal'
})
expect(edited.actionOverrides?.fixChecks?.launchPreferences).toBeUndefined()
const payload = toSourceControlAiRepoUpdate(edited).sourceControlAi
const replay = resolveSourceControlActionRecipe({
settings: sourceSettings,
repo: { sourceControlAi: payload ?? undefined },
actionId: 'fixChecks'
})
expect(replay).toMatchObject({ agentArgs, surface: 'terminal', launchPreferences })
expect(replay).toMatchObject({ agentArgs, surface: 'terminal' })
expect(replay.launchPreferences).toBeUndefined()
expect(replay.launchPreferencesYieldToAgentArgs).toBeUndefined()
const changedAgent = withRepoAiActionAgent(edited, sourceSettings, 'fixChecks', 'claude', {
surface: 'chat'
@@ -431,7 +432,9 @@ it.each([1, 10])(
fireEvent.click(view.getByRole('menuitemradio', { name: /^Ask/ }))
expect(edit).toHaveBeenCalledWith(
'fixCommitFailure',
expect.objectContaining({ agentId: 'codex' })
expect.objectContaining({ agentId: 'codex' }),
undefined,
null
)
expect(gets()).toHaveLength(1)
view.rerender(tree(1))
@@ -1,3 +1,4 @@
import type { ActionArgumentChoices } from '../../../../../../shared/source-control-action-argument-choices'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import type {
RepoSourceControlAiOverrides,
@@ -22,7 +23,9 @@ export type RepositorySourceControlAiActionRowsProps = {
) => void
onActionLaunchPreferencesChange: (
actionId: SourceControlActionId,
value: SourceControlActionLaunchPreferences | undefined
value: SourceControlActionLaunchPreferences | undefined,
userArgs?: string,
argumentChoices?: ActionArgumentChoices | null
) => void
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
onActionAgentArgsChange: (
@@ -158,15 +158,16 @@ export function withRepoAiActionMode(
export function withRepoAiActionRecipeText(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
_settings: GlobalSettings | null,
actionId: SourceControlActionId,
text: ActionRecipeTextDraft
): RepoSourceControlAiOverrides {
const currentRecipe = readCompleteRecipeForDraft(base, settings, actionId)
const preferencesEdited = !sameDraftLaunchPreferences(
text,
readActionRecipeTextDraft(base, actionId)
)
const own = base.actionOverrides?.[actionId]
const currentRecipe = own ?? {}
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...currentRecipe,
@@ -174,6 +175,7 @@ export function withRepoAiActionRecipeText(
? { commandInputTemplate: text.commandInputTemplate }
: {}),
...(text.agentArgs !== undefined ? { agentArgs: text.agentArgs } : {}),
argumentChoices: text.argumentChoices,
...(preferencesEdited ? { launchPreferences: text.launchPreferences } : {})
})
)
@@ -183,7 +185,7 @@ export function withRepoAiActionRecipeText(
* show it as empty. */
export type ActionRecipeTextDraft = Pick<
RepoActionRecipe,
'commandInputTemplate' | 'agentArgs' | 'launchPreferences'
'commandInputTemplate' | 'agentArgs' | 'launchPreferences' | 'argumentChoices'
>
// An own undefined is a deliberate clear; an absent preference leaves inheritance untouched.
@@ -198,6 +200,7 @@ function sameActionRecipeText(a: ActionRecipeTextDraft, b: ActionRecipeTextDraft
return (
a.commandInputTemplate === b.commandInputTemplate &&
a.agentArgs === b.agentArgs &&
JSON.stringify(a.argumentChoices) === JSON.stringify(b.argumentChoices) &&
sameDraftLaunchPreferences(a, b)
)
}
@@ -210,6 +213,7 @@ export function readActionRecipeTextDraft(
return {
commandInputTemplate: recipe?.commandInputTemplate,
agentArgs: recipe?.agentArgs,
argumentChoices: recipe?.argumentChoices,
...(recipe?.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {})
}
}
@@ -218,7 +222,7 @@ export function composeDisplayRepoAi(
immediate: RepoSourceControlAiOverrides,
customCommandDraft: string | null,
actionTextDrafts: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>,
settings: GlobalSettings | null = null
_settings: GlobalSettings | null = null
): RepoSourceControlAiOverrides {
let next =
customCommandDraft === null ? immediate : withRepoAiCustomCommand(immediate, customCommandDraft)
@@ -228,9 +232,6 @@ export function composeDisplayRepoAi(
if (!draft || !hasOwnActionOverride(next.actionOverrides, actionId) || !currentRecipe) {
continue
}
if (!sameDraftLaunchPreferences(draft, readActionRecipeTextDraft(next, actionId))) {
currentRecipe = readCompleteRecipeForDraft(next, settings, actionId)
}
next = {
...next,
actionOverrides: {
@@ -239,6 +240,7 @@ export function composeDisplayRepoAi(
...currentRecipe,
commandInputTemplate: draft.commandInputTemplate,
agentArgs: draft.agentArgs,
argumentChoices: draft.argumentChoices,
launchPreferences: draft.launchPreferences
}
}
@@ -14,6 +14,8 @@ import {
import { toSourceControlAiRepoUpdate } from '../../../../../../shared/source-control-ai-recipe-save'
import type { SourceControlActionId } from '../../../../../../shared/source-control-ai-actions'
import { getDefaultSettings } from '../../../../../../shared/constants'
import { sourceControlActionSelectedPreferences } from '../../../../../../shared/source-control-action-launch-preferences'
import { liftActionPermissionArgs } from '../../../../../../shared/source-control-action-permission-lift'
import { resolveSourceControlActionRecipe } from '../../../../../../shared/source-control-ai-operation-policy'
const source = normalizeSourceControlAiSettings(undefined, undefined)
@@ -44,8 +46,17 @@ const withRecipe = (commandInputTemplate: string): RepoSourceControlAiOverrides
describe('useRepositorySourceControlAiGlobalUx', () => {
it('keeps dirty preferences after failed or same-agent saves and clears the old namespace after a successful switch', async () => {
const preferences = { agentId: 'claude', model: 'sonnet', permissionMode: 'ask' } as const
const dirty = { ...preferences, model: 'opus', permissionMode: 'full-access' } as const
const preferences = {
agentId: 'codex',
modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } },
permissionMode: 'ask'
} as const
const dirty = {
...preferences,
modelSelection: { base: 'inherit', model: { agentId: 'claude', value: 'opus' } },
permissionMode: 'full-access'
} as const
const nodes = { syntaxAgent: 'claude' as const, nodes: [{ at: 0, text: '--effort high' }] }
const updateRepo = vi.fn().mockResolvedValue(true)
const { result } = setup(
{
@@ -54,7 +65,8 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
agentId: 'claude',
agentArgs: '',
commandInputTemplate: 'original',
launchPreferences: preferences
launchPreferences: preferences,
argumentChoices: nodes
}
}
},
@@ -70,6 +82,7 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
expect(result.current.displayRepoAi.actionOverrides?.fixChecks?.launchPreferences).toEqual(
dirty
)
expect(result.current.displayRepoAi.actionOverrides?.fixChecks?.argumentChoices).toEqual(nodes)
await act(async () => result.current.updateActionAgent('fixChecks', 'claude'))
expect(result.current.displayRepoAi.actionOverrides?.fixChecks?.launchPreferences).toEqual(
dirty
@@ -87,16 +100,21 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
})
expect(reopened).toMatchObject({
agentId: 'codex',
agentArgs: '--verbose',
agentArgs: '--effort high --verbose',
commandInputTemplate: 'edited'
})
expect(reopened.launchPreferences).toBeUndefined()
expect(reopened.argumentChoices).toBeUndefined()
})
it.each(['grok', 'claude', 'identical', 'clear'] as const)(
'two queued switches preserve a newly authored %s value and unrelated text',
async (edit) => {
const old = { agentId: 'claude', model: 'sonnet', permissionMode: 'ask' } as const
const old = {
agentId: 'claude',
modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } },
permissionMode: 'ask'
} as const
const finishes: ((ok: boolean) => void)[] = []
const updateRepo = vi.fn<
(repoId: string, updates: SourceControlAiRepoUpdate) => Promise<boolean>
@@ -116,7 +134,17 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
const authored =
edit === 'clear'
? undefined
: { ...old, agentId: edit === 'grok' ? ('grok' as const) : ('claude' as const) }
: {
...old,
agentId: edit === 'grok' ? ('grok' as const) : ('claude' as const),
modelSelection: {
base: 'inherit' as const,
model: {
agentId: edit === 'grok' ? ('grok' as const) : ('claude' as const),
value: null
}
}
}
act(() => {
result.current.updateActionLaunchPreferences('fixChecks', authored)
result.current.updateActionAgentArgs('fixChecks', '--verbose')
@@ -245,7 +273,18 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
it('leaves an action recipe row dirty and retryable when its save fails', async () => {
const updateRepo = vi.fn().mockResolvedValue(false)
const { result } = setup(withRecipe('orig'), updateRepo)
const { result } = setup(
{
actionOverrides: {
fixCommitFailure: liftActionPermissionArgs({
agentId: 'claude' as const,
agentArgs: '--model opus --verbose',
commandInputTemplate: 'orig'
})
}
},
updateRepo
)
act(() => {
result.current.updateActionTemplate('fixCommitFailure', 'edited')
})
@@ -255,6 +294,16 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
})
expect(result.current.actionDirtyById.fixCommitFailure).toBe(true)
expect(result.current.saveError).toBe('Failed to save Source Control AI settings.')
expect(
result.current.displayRepoAi.actionOverrides?.fixCommitFailure?.argumentChoices?.nodes
).toEqual([{ at: 0, text: '--model opus' }])
updateRepo.mockResolvedValue(true)
await act(async () => result.current.saveActionRecipeText('fixCommitFailure'))
expect(result.current.actionDirtyById.fixCommitFailure).toBe(false)
expect(
updateRepo.mock.lastCall?.[1].sourceControlAi?.actionOverrides?.fixCommitFailure
?.argumentChoices?.nodes
).toEqual([{ at: 0, text: '--model opus' }])
})
it('preserves keystrokes typed while an action save is in flight', async () => {
@@ -264,7 +313,18 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
resolveUpdate = resolve
})
)
const { result } = setup(withRecipe('orig'), updateRepo)
const { result } = setup(
{
actionOverrides: {
fixCommitFailure: liftActionPermissionArgs({
agentId: 'claude' as const,
agentArgs: '--model opus --verbose',
commandInputTemplate: 'orig'
})
}
},
updateRepo
)
act(() => {
result.current.updateActionTemplate('fixCommitFailure', 'first')
})
@@ -278,6 +338,7 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
expect(updateRepo).toHaveBeenCalledTimes(1)
act(() => {
result.current.updateActionTemplate('fixCommitFailure', 'first-and-more')
result.current.updateActionAgentArgs('fixCommitFailure', '--effort low --verbose', true)
})
await act(async () => {
resolveUpdate(true)
@@ -287,6 +348,11 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
result.current.displayRepoAi.actionOverrides?.fixCommitFailure?.commandInputTemplate
).toBe('first-and-more')
expect(result.current.actionDirtyById.fixCommitFailure).toBe(true)
expect(
result.current.displayRepoAi.actionOverrides?.fixCommitFailure?.argumentChoices?.nodes.map(
(node) => node.text
)
).toEqual(['--model opus', '--effort low'])
})
it('resets drafts and saving state when the selected repo changes', async () => {
@@ -412,7 +478,10 @@ describe('editing one repository action', () => {
rerender({ ...legacy, enabled: true })
expect(result.current.actionDirtyById.fixCommitFailure).toBe(true)
act(() => result.current.discardActionRecipeText('fixCommitFailure'))
expect(effective().launchPreferences).toEqual(inherited)
expect(sourceControlActionSelectedPreferences(effective(), 'codex')).toEqual({
agentId: 'codex',
permissionMode: 'ask'
})
act(() => result.current.updateActionLaunchPreferences('fixCommitFailure', undefined))
await act(async () => result.current.saveActionRecipeText('fixCommitFailure'))
const saved = updateRepo.mock.lastCall?.[1].sourceControlAi
@@ -438,11 +507,61 @@ describe('editing one repository action', () => {
'fixCommitFailure'
)
const luna = { ...terra, model: 'gpt-5.6-luna' }
expect(set(luna)).toMatchObject({ agentId: 'codex', agentArgs: '', launchPreferences: luna })
expect(set(luna)).toMatchObject({
agentId: 'codex',
agentArgs: '',
launchPreferences: {
agentId: 'codex',
modelSelection: { base: 'default', model: { agentId: 'codex', value: 'gpt-5.6-luna' } }
}
})
expect(set()).toMatchObject({ agentId: 'codex', agentArgs: '' })
expect(set().launchPreferences).toBeUndefined()
})
it('keeps a prompt-only edit in the repository Args group without copying a global model', () => {
const legacy = { actionOverrides: { fixCommitFailure: { agentArgs: '--model gpt-5.6-sol' } } }
const stored = {
settings,
repo: { sourceControlAi: legacy },
actionId: 'fixCommitFailure'
} as const
expect(resolveSourceControlActionRecipe(stored).launchPreferences).toBeUndefined()
const edited = reload(editPrompt(legacy, 'fixCommitFailure'), 'fixCommitFailure')
expect(edited).toMatchObject({ commandInputTemplate: 'mine', agentArgs: '--model gpt-5.6-sol' })
expect(edited.launchPreferences).toBeUndefined()
expect(edited.launchPreferencesYieldToAgentArgs).toBeUndefined()
const permissionSettings = {
...settings,
sourceControlAi: {
...settings.sourceControlAi,
actions: {
...actions,
fixCommitFailure: {
...global,
launchPreferences: { ...terra, permissionMode: 'ask' as const }
}
}
}
}
const promptOnly = withRepoAiActionRecipeText(legacy, permissionSettings, 'fixCommitFailure', {
...readActionRecipeTextDraft(legacy, 'fixCommitFailure'),
commandInputTemplate: 'mine'
})
expect(promptOnly.actionOverrides?.fixCommitFailure?.launchPreferences).toBeUndefined()
expect(promptOnly.actionOverrides?.fixCommitFailure?.agentId).toBeUndefined()
expect(
sourceControlActionSelectedPreferences(
resolveSourceControlActionRecipe({
settings: permissionSettings,
repo: { sourceControlAi: promptOnly },
actionId: 'fixCommitFailure'
}),
'codex'
)
).toEqual({ agentId: 'codex', permissionMode: 'ask' })
})
it('saves a prompt edit without authoring args the user never typed or touching a sibling', async () => {
const sibling = { agentArgs: '--repo' }
const cleared = {
@@ -1,4 +1,13 @@
import {
lowerActionArgumentChoices,
demoteActionArgumentChoices
} from '../../../../../../shared/source-control-action-argument-choices'
import {
sourceControlActionSelectedPreferences,
sourceControlActionExecutionArgs
} from '../../../../../../shared/source-control-action-launch-preferences'
import type { TuiAgent } from '../../../../../../shared/tui-agent'
import { readActionModelSelection } from '../../../../../../shared/source-control-action-model-selection'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
resolveSourceControlActionCommandTemplate,
@@ -29,7 +38,7 @@ export function readInheritedAgentArgs(
source: SourceControlAiSettings,
actionId: SourceControlActionId
): string {
return source.actions?.[actionId]?.agentArgs?.trim() ?? ''
return lowerActionArgumentChoices(source.actions?.[actionId] ?? {}) ?? ''
}
export function actionAgentSelectValue(
@@ -57,6 +66,9 @@ export function completeRepoActionRecipe(
): NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
> {
if (recipe.argumentChoices && recipe.argumentChoices.syntaxAgent !== recipe.agentId) {
recipe = demoteActionArgumentChoices(recipe)
}
const commandInputTemplate =
typeof recipe.commandInputTemplate === 'string'
? recipe.commandInputTemplate
@@ -65,6 +77,7 @@ export function completeRepoActionRecipe(
agentId: recipe.agentId ?? null,
commandInputTemplate,
...(typeof recipe.agentArgs === 'string' ? { agentArgs: recipe.agentArgs } : {}),
...(recipe.argumentChoices ? { argumentChoices: recipe.argumentChoices } : {}),
...(recipe.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {}),
...(recipe.surface ? { surface: recipe.surface } : {})
}
@@ -88,12 +101,27 @@ export function commandTemplateStateLabel(args: {
}
export function agentArgsStateLabel(args: {
hasOverride: boolean
inheritedAgentArgs: string
repoAgentArgs: string
recipe: SourceControlActionRecipe
agent: TuiAgent | null
override: NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
}): string {
if (args.hasOverride) {
return args.repoAgentArgs.trim() ? 'Repository custom args' : 'No args'
const choices = args.agent
? sourceControlActionSelectedPreferences(args.recipe, args.agent)
: undefined
const hasChoices =
choices?.agentId === args.agent &&
Boolean(choices?.model || Object.keys(choices?.options ?? {}).length)
if (
!(
args.agent ? sourceControlActionExecutionArgs(args.recipe, args.agent) : args.recipe.agentArgs
)?.trim() &&
!hasChoices
) {
return 'No args'
}
return args.inheritedAgentArgs.trim() ? 'Global custom args' : 'No args'
const ownChoices = args.override?.launchPreferences
const ownsChoices = Boolean(readActionModelSelection(ownChoices))
return args.override?.agentArgs !== undefined || ownsChoices
? 'Repository custom args'
: 'Global custom args'
}
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { shouldShowSourceControlActionArguments } from './source-control-action-arguments-visibility'
import { useState } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import './__fixtures__/settings-persistence-test-fixture'
import { useAppStore } from '@/store'
@@ -16,6 +17,12 @@ import {
resetHostStructuredAgentsForTests
} from '@/runtime/host-structured-agents'
import { TooltipProvider } from '../ui/tooltip'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import {
getDefaultSourceControlAiSettings,
normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
import { RepositorySourceControlAiActionRows } from './repository/source-control/RepositorySourceControlAiActionRows'
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
vi.mock('@/runtime/runtime-rpc-client', () => ({
@@ -66,7 +73,28 @@ beforeEach(() => {
})
afterEach(resetHostStructuredAgentsForTests)
it('keeps the draft and chip while Model/Effort controls stay hidden', () => {
it.each([
['chat on', 'claude', true, undefined, false],
['chat off', 'claude', false, undefined, true],
['provider off', 'claude', true, { claude: false }, true],
['no chat support', 'goose', true, undefined, true]
] as const)('%s: shows Arguments only for the CLI', (_name, agent, chat, providers, visible) => {
configure({ experimentalNativeChat: chat, nativeChatProviders: providers })
render(<Row agent={agent} />)
expect(Boolean(screen.queryByRole('textbox', { name: 'Arguments' }))).toBe(visible)
expect(screen.getByText('Command template')).toBeDefined()
})
it('hides an unresolved launch even with chat off, but always shows text action Arguments', () => {
configure({ experimentalNativeChat: false })
useAppStore.setState({ localDetectedAgentIdsByContext: {} })
const view = render(<Row />)
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
view.rerender(<Row actionId="commitMessage" />)
expect(screen.getByDisplayValue('--saved')).toBeDefined()
})
it('keeps the draft while Arguments hide and renders the model and effort beside Permissions', () => {
configure({ experimentalNativeChat: false })
render(<Row />)
fireEvent.change(screen.getByRole('textbox', { name: 'Arguments' }), {
@@ -85,8 +113,8 @@ it('keeps the draft and chip while Model/Effort controls stay hidden', () => {
expect(
screen.getByRole('button', { name: /Remove --dangerously-skip-permissions/ })
).toBeDefined()
expect(screen.queryByRole('combobox', { name: 'Sonnet' })).toBeNull()
expect(screen.queryByRole('combobox', { name: 'High' })).toBeNull()
expect(screen.getByRole('combobox', { name: 'Sonnet' })).toBeDefined()
expect(screen.getByRole('combobox', { name: 'High' })).toBeDefined()
expect(unused).not.toHaveBeenCalled()
})
@@ -102,3 +130,119 @@ it('reacts to the real host registry when another agent gains chat support', asy
)
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
})
it('honors an explicit future surface only after an agent resolves', () => {
const input = {
agent: 'claude',
settings: { experimentalNativeChat: true },
chatAgents: ['claude']
} as const
expect(shouldShowSourceControlActionArguments({ ...input, launchKind: 'terminal' })).toBe(true)
expect(
shouldShowSourceControlActionArguments({
...input,
settings: { experimentalNativeChat: false },
launchKind: 'chat'
})
).toBe(false)
expect(
shouldShowSourceControlActionArguments({ ...input, agent: null, launchKind: 'terminal' })
).toBe(false)
})
it.each(['', null, '--verbose', undefined])(
'repository Args %s uses the shared controls, source and wrapping layout',
(agentArgs) => {
configure({ experimentalNativeChat: false })
useAppStore.setState({
repos: [{ id: 'repo', path: '/repo', displayName: 'repo', badgeColor: '', addedAt: 0 }]
})
const source = normalizeSourceControlAiSettings({
...getDefaultSourceControlAiSettings(),
actions: {
fixChecks: {
agentId: 'claude',
launchPreferences: { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } }
}
}
})
const repoAi: RepoSourceControlAiOverrides = {
actionOverrides: {
fixChecks: {
commandInputTemplate: 'mine',
...(agentArgs !== undefined ? { agentArgs } : {})
}
}
}
render(
<TooltipProvider>
<RepositorySourceControlAiActionRows
repoId="repo"
repoAi={repoAi}
source={source}
defaultTuiAgent="claude"
onActionModeChange={unused}
onActionAgentChange={unused}
onActionLaunchPreferencesChange={unused}
onActionTemplateChange={unused}
onActionAgentArgsChange={unused}
onAppendVariable={unused}
savingActionIds={{}}
actionDirtyById={{
commitMessage: false,
pullRequest: false,
branchName: false,
fixCommitFailure: false,
fixPushFailure: false,
fixChecks: false,
resolveConflicts: false,
resolveComments: false
}}
onActionDiscard={unused}
onActionSave={unused}
/>
</TooltipProvider>
)
const card = screen.getByText('Broken checks fixes').closest('[data-settings-section]')
if (!(card instanceof HTMLElement)) {
throw new Error('Missing action card')
}
const controls = within(card)
const model = controls.getByRole('combobox', {
name: agentArgs === undefined ? 'Sonnet' : 'Agent default'
})
expect(
controls.getByRole('combobox', { name: agentArgs === undefined ? 'High' : 'Default' })
).toBeDefined()
expect(
controls.getByText(
agentArgs === undefined
? 'Global custom args'
: agentArgs
? 'Repository custom args'
: 'No args'
)
).toBeDefined()
const row = model.closest('.flex-wrap')
expect(row?.contains(controls.getByRole('button', { name: 'Claude permissions' }))).toBe(true)
const input = controls.getByRole('textbox', { name: 'Arguments' })
expect(row?.querySelector(':scope > .w-full')?.contains(input)).toBe(true)
expect(row?.parentElement?.className).toContain('grid-cols-1')
expect(
row?.parentElement?.lastElementChild?.contains(controls.getByText('Command template'))
).toBe(true)
expect(Boolean(controls.queryByRole('button', { name: 'Remove --model sonnet' }))).toBe(
agentArgs === undefined
)
act(() => configure({ experimentalNativeChat: true }))
expect(controls.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
expect(
controls.getByRole('combobox', { name: agentArgs === undefined ? 'Sonnet' : 'Agent default' })
).toBeDefined()
act(() => configure({ experimentalNativeChat: false }))
expect(controls.getByRole('textbox', { name: 'Arguments' })).toHaveProperty(
'value',
agentArgs ?? ''
)
}
)
@@ -6,6 +6,7 @@ import type { SourceControlActionLaunchPreferences } from '../../../../shared/so
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
sourceControlActionOptions,
sourceControlActionSelectedPreferences,
sourceControlActionPermissionModeRoute
} from '../../../../shared/source-control-action-launch-preferences'
import { translate } from '@/i18n/i18n'
@@ -18,19 +19,21 @@ export function describeSourceControlActionModel(
agent: TuiAgent,
preferences: SourceControlActionLaunchPreferences
): string {
preferences = sourceControlActionSelectedPreferences(
{ launchPreferences: preferences },
agent
) ?? { agentId: agent }
const { model: modelId, permissionMode } = preferences
const catalog = getAgentSessionOptionCatalog(agent) ?? { models: [], modelApply: {} }
const labels = modelId
? [
findCatalogModel(catalog, modelId)?.label ?? modelId,
...sourceControlActionOptions(agent, modelId).flatMap(({ option }) => {
const value = preferences.options?.[option.id]
return value && option.kind.type === 'select'
? [option.kind.choices.find((choice) => choice.value === value)?.label ?? value]
: []
})
]
: []
const labels = [
...(modelId ? [findCatalogModel(catalog, modelId)?.label ?? modelId] : []),
...sourceControlActionOptions(agent, modelId ?? '').flatMap(({ option }) => {
const value = preferences.options?.[option.id]
return value && option.kind.type === 'select'
? [option.kind.choices.find((choice) => choice.value === value)?.label ?? value]
: []
})
]
if (!permissionMode) {
return translate(
'settings.sourceControl.actionModel.savedModel',
@@ -1,6 +1,12 @@
import {
sourceControlActionSelectedPreferences,
sourceControlActionAgentArgs
} from '../../../../shared/source-control-action-launch-preferences'
// @vitest-environment happy-dom
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { getDefaultSettings } from '../../../../shared/constants'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as lifts from '../../../../shared/source-control-action-permission-lift'
import * as shell from '../../../../shared/tui-agent-startup-shell'
@@ -10,29 +16,51 @@ import {
getDefaultSourceControlAiSettings,
normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import { sourceControlActionAgentArgs } from '../../../../shared/source-control-action-launch-preferences'
import {
normalizeSourceControlActionRecipe,
type SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import { agentPermissionArgsForLaunch } from '../../../../shared/tui-agent-launch-defaults'
import { SourceControlActionArguments } from './SourceControlActionArguments'
import { useRepositorySourceControlAiGlobalUx } from './repository/source-control/repository-source-control-ai-global-ux'
import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state'
import { TooltipProvider } from '../ui/tooltip'
const model = { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } } as const
const completeModel = {
agentId: 'claude',
modelSelection: {
base: 'default',
model: { agentId: 'claude', value: 'sonnet' },
options: { effort: { agentId: 'claude', value: 'high' } }
}
} as const
const initial = normalizeSourceControlAiSettings({
...getDefaultSourceControlAiSettings(),
enabled: true,
actions: { fixChecks: { agentId: 'claude', agentArgs: '', launchPreferences: model } }
})
const write = vi.fn()
const edit = vi.fn()
function RepoHarness({ saved }: { saved: { value: RepoSourceControlAiOverrides } }) {
function RepoHarness({
saved,
source = initial
}: {
saved: { value: RepoSourceControlAiOverrides }
source?: SourceControlAiSettings
}) {
const [persisted, setPersisted] = useState(saved.value)
const draft = useRepositorySourceControlAiGlobalUx({
repoId: 'repo',
persistedRepoAi: persisted,
settings: null,
source: initial,
settings: { ...getDefaultSettings('/tmp'), sourceControlAi: source },
source,
updateRepo: async (_id, update) => {
saved.value = update.sourceControlAi ?? {}
write(update)
@@ -45,12 +73,24 @@ function RepoHarness({ saved }: { saved: { value: RepoSourceControlAiOverrides }
<TooltipProvider>
<SourceControlActionArguments
agent="claude"
value={value?.agentArgs ?? ''}
preferences={value?.launchPreferences}
value={value?.agentArgs}
preferences={sourceControlActionSelectedPreferences(
resolveSourceControlActionRecipe({
settings: { sourceControlAi: source },
repo: { sourceControlAi: draft.displayRepoAi },
actionId: 'fixChecks'
}),
'claude'
)}
modelSelectionBase="inherit"
authoredAgent={value?.agentId}
authoredPreferences={value?.launchPreferences ?? null}
argumentChoices={value?.argumentChoices}
onArgsChange={(text, commit) => draft.updateActionAgentArgs('fixChecks', text, commit)}
onPreferencesChange={(preferences) =>
draft.updateActionLaunchPreferences('fixChecks', preferences)
}
onPreferencesChange={(preferences, userArgs, nodes) => {
edit(preferences, userArgs)
draft.updateActionLaunchPreferences('fixChecks', preferences, userArgs, nodes)
}}
/>
<button onClick={() => void draft.saveActionRecipeText('fixChecks')}>Save</button>
<button onClick={() => draft.discardActionRecipeText('fixChecks')}>Discard</button>
@@ -58,6 +98,167 @@ function RepoHarness({ saved }: { saved: { value: RepoSourceControlAiOverrides }
)
}
function GlobalHarness({ saved }: { saved: { value: SourceControlAiSettings } }) {
const [config, setConfig] = useState(saved.value)
const draft = useSourceControlActionRecipeDraftState({
config,
writeConfig: async (patch) => {
saved.value = {
...saved.value,
...(typeof patch === 'function' ? patch(saved.value) : patch)
}
write(saved.value)
setConfig(saved.value)
}
})
const value = draft.actionRecipeDraftState.values.fixChecks
return (
<TooltipProvider>
<SourceControlActionArguments
agent="claude"
value={value.agentArgs}
preferences={sourceControlActionSelectedPreferences(value, 'claude')}
authoredAgent={config.actions?.fixChecks?.agentId}
authoredPreferences={value.launchPreferences ?? null}
argumentChoices={value.argumentChoices}
onArgsChange={(text, commit) => draft.onActionAgentArgsChange('fixChecks', text, commit)}
onPreferencesChange={(preferences, userArgs, nodes) => {
edit(preferences, userArgs)
draft.onActionLaunchPreferencesChange('fixChecks', preferences, userArgs, nodes)
}}
/>
<button onClick={() => void draft.saveActionTemplateDraft('fixChecks')}>Save</button>
<button onClick={() => draft.discardActionTemplateDraft('fixChecks')}>Discard</button>
</TooltipProvider>
)
}
it.each(['global', 'repo'] as const)(
'%s: edits dirty choices atomically and saves/discards the badge collection',
async (scope) => {
write.mockClear()
edit.mockClear()
const global = { value: initial }
const repo: { value: RepoSourceControlAiOverrides } = {
value: { actionOverrides: { fixChecks: initial.actions?.fixChecks } }
}
const view = () =>
scope === 'global' ? <GlobalHarness saved={global} /> : <RepoHarness saved={repo} />
const saved = () =>
scope === 'global' ? global.value.actions?.fixChecks : repo.value.actionOverrides?.fixChecks
const rendered = render(view())
const input = screen.getByRole('textbox', { name: 'Arguments' })
fireEvent.change(input, { target: { value: '--model fable --verbose' } })
await userEvent.click(screen.getByRole('combobox', { name: 'Sonnet' }))
await userEvent.click(screen.getByRole('option', { name: 'Haiku' }))
expect(edit).toHaveBeenCalledExactlyOnceWith(
{
agentId: 'claude',
modelSelection: {
base: 'default',
model: { agentId: 'claude', value: 'haiku' },
options: { effort: { agentId: 'claude', value: null } }
}
},
'--verbose'
)
fireEvent.click(screen.getByText('Discard'))
edit.mockClear()
fireEvent.change(input, { target: { value: '--model fable --effort low --verbose' } })
await userEvent.click(screen.getByRole('combobox', { name: 'High' }))
await userEvent.click(screen.getByRole('option', { name: 'Medium' }))
expect(edit).toHaveBeenCalledTimes(1)
expect(edit).toHaveBeenLastCalledWith(
{
agentId: 'claude',
modelSelection: {
base: 'default',
options: { effort: { agentId: 'claude', value: 'medium' } }
}
},
'--verbose'
)
expect(input).toHaveProperty('value', '--verbose')
expect(write).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(1))
expect(saved()?.agentArgs).toBe('--verbose')
expect(saved()?.launchPreferences).toEqual({
agentId: 'claude',
modelSelection: {
base: 'default',
options: { effort: { agentId: 'claude', value: 'medium' } }
}
})
rendered.unmount()
render(view())
expect(saved()?.argumentChoices).toEqual({
syntaxAgent: 'claude',
nodes: [{ at: 0, text: '--model fable' }]
})
expect(
sourceControlActionSelectedPreferences(
normalizeSourceControlActionRecipe(saved()) ?? {},
'claude'
)?.model
).toBe('fable')
const reopened = screen.getByRole('textbox', { name: 'Arguments' })
fireEvent.change(reopened, { target: { value: '--model fable --effort medium --verbose' } })
edit.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Remove --model fable' }))
expect(edit).toHaveBeenCalledExactlyOnceWith(
{
agentId: 'claude',
modelSelection: {
base: 'default',
model: { agentId: 'claude', value: null }
}
},
'--verbose'
)
expect(screen.getByRole('combobox', { name: 'Agent default' })).toBeDefined()
fireEvent.click(screen.getByText('Discard'))
expect(screen.getByRole('combobox', { name: 'Fable' })).toBeDefined()
expect(reopened).toHaveProperty('value', '--verbose')
fireEvent.focus(reopened)
if (!(reopened instanceof HTMLTextAreaElement)) {
throw new Error('Missing Arguments')
}
reopened.setSelectionRange(0, 0)
edit.mockClear()
fireEvent.keyDown(reopened, { key: 'Backspace' })
const effort = screen.getByRole('button', { name: 'Remove --effort medium' })
expect(document.activeElement).toBe(effort)
expect(edit).not.toHaveBeenCalled()
fireEvent.keyDown(effort, { key: 'Backspace' })
expect(edit).toHaveBeenCalledExactlyOnceWith(
{
agentId: 'claude',
modelSelection: {
base: 'default',
options: { effort: { agentId: 'claude', value: null } }
}
},
undefined
)
expect(screen.getByRole('combobox', { name: 'Default' })).toBeDefined()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(2))
expect(saved()?.launchPreferences).toEqual({
agentId: 'claude',
modelSelection: {
base: 'default',
options: { effort: { agentId: 'claude', value: null } }
}
})
expect(saved()?.argumentChoices).toEqual({
syntaxAgent: 'claude',
nodes: [{ at: 0, text: '--model fable' }]
})
expect(saved()?.agentArgs).toBe('--verbose')
}
)
it('repo: commits typed permission, drafts own removal and saves/discards args and model together', async () => {
write.mockClear()
const repo: { value: RepoSourceControlAiOverrides } = {
@@ -105,7 +306,10 @@ it('repo: commits typed permission, drafts own removal and saves/discards args a
fireEvent.change(input, { target: { value: '--dangerously-skip-permissions --verbose' } })
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(savedRecipe()?.launchPreferences?.permissionMode).toBe('full-access'))
expect(savedRecipe()?.launchPreferences).toEqual({ ...model, permissionMode: 'full-access' })
expect(savedRecipe()?.launchPreferences).toEqual({
...completeModel,
permissionMode: 'full-access'
})
expect(savedRecipe()?.agentArgs).toBe('--verbose')
expect(write).toHaveBeenCalledTimes(1)
rendered.unmount()
@@ -121,11 +325,12 @@ it('repo: commits typed permission, drafts own removal and saves/discards args a
}
args.setSelectionRange(0, 0)
fireEvent.keyDown(args, { key: 'Backspace' })
expect(document.activeElement).toBe(screen.getByRole('button', { name: /Remove --dangerously/ }))
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Remove --effort high' }))
screen.getByRole('button', { name: /Remove --dangerously/ }).focus()
fireEvent.keyDown(document.activeElement ?? args, { key: 'Backspace' })
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(savedRecipe()?.launchPreferences?.permissionMode).toBe('own'))
expect(savedRecipe()?.launchPreferences).toEqual({ ...model, permissionMode: 'own' })
expect(savedRecipe()?.launchPreferences).toEqual({ ...completeModel, permissionMode: 'own' })
expect(savedRecipe()?.agentArgs).toBe('--verbose')
reopened.unmount()
render(view())
@@ -165,7 +370,11 @@ it('disables the menu, input, reset and chip keyboard path while preserving geom
await userEvent.click(screen.getByRole('button', { name: 'Claude permissions' }))
expect(screen.getByRole('menuitemradio', { name: /Same as Claude ·/ })).toBeDefined()
await userEvent.click(screen.getByRole('menuitemradio', { name: /Use Claude's own settings/ }))
expect(preferences).toHaveBeenLastCalledWith({ ...model, permissionMode: 'own' })
expect(preferences).toHaveBeenLastCalledWith(
{ ...completeModel, permissionMode: 'own' },
undefined,
null
)
})
it.each([undefined, null])('keeps untouched %s arguments unowned until the user edits', (value) => {
@@ -224,3 +433,196 @@ it('keeps the old Codex workspace-write Ask literal and explains that terminal t
)
).toBeDefined()
})
it('edits and saves a sparse repository model without authoring the inherited permission', async () => {
write.mockClear()
const source = {
...initial,
actions: {
fixChecks: {
agentId: 'claude' as const,
launchPreferences: { ...model, permissionMode: 'ask' as const }
}
}
}
const saved = { value: { actionOverrides: { fixChecks: { agentArgs: '--verbose' } } } }
const view = () => <RepoHarness saved={saved} source={source} />
const rendered = render(view())
expect(screen.getByRole('combobox', { name: 'Agent default' })).toBeDefined()
expect(screen.getByRole('combobox', { name: 'Default' })).toBeDefined()
await userEvent.click(screen.getByRole('combobox', { name: 'Agent default' }))
await userEvent.click(screen.getByRole('option', { name: 'Haiku' }))
fireEvent.click(screen.getByText('Discard'))
expect(screen.getByRole('combobox', { name: 'Agent default' })).toBeDefined()
await userEvent.click(screen.getByRole('combobox', { name: 'Agent default' }))
await userEvent.click(screen.getByRole('option', { name: 'Haiku' }))
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(1))
expect(saved.value.actionOverrides.fixChecks).toEqual({
agentArgs: '--verbose',
launchPreferences: {
agentId: 'claude',
modelSelection: { base: 'default', model: { agentId: 'claude', value: 'haiku' } }
}
})
rendered.unmount()
render(view())
expect(screen.getByRole('combobox', { name: 'Haiku' })).toBeDefined()
expect(screen.queryByRole('button', { name: /Remove --permission-mode/ })).toBeNull()
fireEvent.change(screen.getByRole('textbox', { name: 'Arguments' }), {
target: { value: '--model fable --verbose' }
})
await userEvent.click(screen.getByRole('button', { name: 'Claude permissions' }))
await userEvent.click(screen.getByRole('menuitemradio', { name: /^Full access/ }))
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(2))
expect(saved.value.actionOverrides.fixChecks).toEqual({
agentArgs: '--model fable --verbose',
launchPreferences: {
agentId: 'claude',
permissionMode: 'full-access',
modelSelection: { base: 'default' }
}
})
})
it.each(['global', 'repo'] as const)(
'%s live residual edits preserve nodes, permission changes and permanent reset',
async (scope) => {
write.mockClear()
const recipe = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude' as const,
agentArgs: '--model opus --effort high --append-system-prompt keep'
})
const global = { value: { ...initial, actions: { fixChecks: recipe } } }
const repo = { value: { actionOverrides: { fixChecks: recipe } } }
const saved = () =>
scope === 'global' ? global.value.actions.fixChecks : repo.value.actionOverrides.fixChecks
render(scope === 'global' ? <GlobalHarness saved={global} /> : <RepoHarness saved={repo} />)
const input = screen.getByRole('textbox', { name: 'Arguments' })
fireEvent.change(input, { target: { value: '--append-system-prompt changed' } })
fireEvent.blur(input)
expect(screen.getByRole('combobox', { name: 'Opus' })).toBeDefined()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(1))
expect(saved().argumentChoices?.nodes.map((node) => node.text)).toEqual([
'--model opus',
'--effort high'
])
fireEvent.change(input, { target: { value: '--effort low --append-system-prompt changed' } })
fireEvent.blur(input)
expect(screen.getByRole('combobox', { name: 'Low' })).toBeDefined()
fireEvent.click(screen.getByText('Discard'))
expect(screen.getByRole('combobox', { name: 'High' })).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
expect(screen.getByRole('combobox', { name: 'Agent default' })).toBeDefined()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(2))
expect(saved().agentArgs).toBe('')
expect(saved().argumentChoices).toBeUndefined()
}
)
it('keeps the global document retryable and preserves a newer document across a pending Save', async () => {
const recipe = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude' as const,
agentArgs: '--model opus --effort high'
})
const config = { ...initial, actions: { fixChecks: recipe } }
const writeConfig = vi.fn().mockRejectedValueOnce(new Error('disk refused'))
const { result } = renderHook(() =>
useSourceControlActionRecipeDraftState({ config, writeConfig })
)
act(() => result.current.onActionTemplateChange('fixChecks', 'mine'))
await act(async () => {
await expect(result.current.saveActionTemplateDraft('fixChecks')).rejects.toThrow(
'disk refused'
)
})
expect(result.current.actionRecipeDraftState.values.fixChecks.argumentChoices).toEqual(
recipe.argumentChoices
)
let finish = () => {}
writeConfig.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve
})
)
let save: Promise<void> = Promise.resolve()
act(() => {
save = result.current.saveActionTemplateDraft('fixChecks')
})
act(() => result.current.onActionAgentArgsChange('fixChecks', '--effort low', true))
await act(async () => {
finish()
await save
})
expect(
result.current.actionRecipeDraftState.values.fixChecks.argumentChoices?.nodes.map(
(node) => node.text
)
).toEqual(['--model opus', '--effort low'])
expect(result.current.actionRecipeDraftState.baseValues.fixChecks.argumentChoices).toEqual(
recipe.argumentChoices
)
writeConfig.mockResolvedValue(undefined)
await act(async () => result.current.saveActionTemplateDraft('fixChecks'))
expect(result.current.actionRecipeDraftState.values.fixChecks).toEqual(
result.current.actionRecipeDraftState.baseValues.fixChecks
)
})
it('removes inherited keys without pinning untouched choices across Save, reopen and later global edits', async () => {
write.mockClear()
const saved: { value: RepoSourceControlAiOverrides } = {
value: { actionOverrides: { fixChecks: { commandInputTemplate: 'mine' } } }
}
const view = render(<RepoHarness saved={saved} />)
fireEvent.click(screen.getByRole('button', { name: 'Remove --model sonnet' }))
expect(screen.getByRole('combobox', { name: 'Agent default' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Remove --effort high' })).toBeDefined()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(1))
expect(saved.value.actionOverrides?.fixChecks).toEqual({
commandInputTemplate: 'mine',
launchPreferences: {
agentId: 'claude',
modelSelection: {
base: 'inherit',
model: { agentId: 'claude', value: null }
}
}
})
view.unmount()
const reopened = render(<RepoHarness saved={saved} />)
expect(screen.getByRole('combobox', { name: 'Agent default' })).toBeDefined()
const source = normalizeSourceControlAiSettings({
...initial,
actions: {
fixChecks: {
agentId: 'claude',
agentArgs: '',
launchPreferences: {
agentId: 'claude',
model: 'opus',
options: { effort: 'medium' }
}
}
}
})
reopened.rerender(<RepoHarness saved={saved} source={source} />)
expect(screen.getByRole('button', { name: 'Remove --effort medium' })).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Remove --effort medium' }))
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(write).toHaveBeenCalledTimes(2))
expect(saved.value.actionOverrides?.fixChecks?.launchPreferences?.modelSelection).toEqual({
base: 'inherit',
model: { agentId: 'claude', value: null },
options: { effort: { agentId: 'claude', value: null } }
})
reopened.rerender(<RepoHarness saved={saved} />)
expect(screen.queryByRole('button', { name: /Remove --(model|effort)/ })).toBeNull()
fireEvent.click(screen.getByText('Save'))
expect(write).toHaveBeenCalledTimes(2)
})
@@ -1,3 +1,8 @@
import {
transportActionArgumentChoices,
demoteActionArgumentChoices,
type ActionArgumentChoices
} from '../../../../shared/source-control-action-argument-choices'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import { useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'
import type {
@@ -116,6 +121,7 @@ export function useSourceControlActionRecipeDraftState({
{
...current.values[actionId],
agentId: config.actions?.[actionId]?.agentId,
argumentChoices: transportActionArgumentChoices(current.values[actionId], value),
agentArgs: value
},
true,
@@ -124,6 +130,7 @@ export function useSourceControlActionRecipeDraftState({
: {
...current.values[actionId],
agentId: config.actions?.[actionId]?.agentId,
argumentChoices: transportActionArgumentChoices(current.values[actionId], value),
agentArgs: value
}
return { ...current, values: { ...current.values, [actionId]: valueDraft } }
@@ -132,7 +139,9 @@ export function useSourceControlActionRecipeDraftState({
const onActionLaunchPreferencesChange = (
actionId: SourceControlActionId,
update: SetStateAction<SourceControlActionLaunchPreferences | undefined>
update: SetStateAction<SourceControlActionLaunchPreferences | undefined>,
userArgs?: string,
argumentChoices?: ActionArgumentChoices | null
): void => {
setActionRecipeDraftState((current) => {
const latest = current.values[actionId].launchPreferences
@@ -145,12 +154,32 @@ export function useSourceControlActionRecipeDraftState({
...current,
values: {
...current.values,
[actionId]: { ...current.values[actionId], launchPreferences: next }
[actionId]: {
...current.values[actionId],
launchPreferences: next,
...(argumentChoices !== undefined
? { argumentChoices: argumentChoices ?? undefined }
: {}),
...(userArgs !== undefined ? { agentArgs: userArgs } : {})
}
}
}
})
}
const retireActionAgentDocument = (actionId: SourceControlActionId, agentId: unknown): void => {
setActionRecipeDraftState((current) => {
const value = current.values[actionId]
if (!value.argumentChoices || value.argumentChoices.syntaxAgent === agentId) {
return current
}
return {
...current,
values: { ...current.values, [actionId]: demoteActionArgumentChoices(value) }
}
})
}
const saveActionTemplateDraft = async (actionId: SourceControlActionId): Promise<void> => {
const submitted = actionRecipeDraftState.values[actionId]
const { agentId: _binding, ...nextValue } = liftActionPermissionArgs(
@@ -171,7 +200,8 @@ export function useSourceControlActionRecipeDraftState({
actions: setSourceControlActionDefault(current.actions, actionId, {
commandInputTemplate: nextValue.commandInputTemplate,
agentArgs: nextValue.agentArgs,
launchPreferences: nextValue.launchPreferences
launchPreferences: nextValue.launchPreferences,
argumentChoices: nextValue.argumentChoices
})
}
})
@@ -220,6 +250,7 @@ export function useSourceControlActionRecipeDraftState({
onActionTemplateChange,
onActionAgentArgsChange,
onActionLaunchPreferencesChange,
retireActionAgentDocument,
saveActionTemplateDraft,
discardActionTemplateDraft,
appendVariable
@@ -1,4 +1,5 @@
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import { readActionModelSelection } from '../../../../shared/source-control-action-model-selection'
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
SOURCE_CONTROL_ACTION_IDS,
@@ -10,6 +11,7 @@ import {
export type ActionRecipeDraftValue = {
commandInputTemplate: string
agentArgs?: string
argumentChoices?: SourceControlActionRecipe['argumentChoices']
launchPreferences?: SourceControlActionLaunchPreferences
}
@@ -29,6 +31,7 @@ function readActionRecipeInputValue(
commandInputTemplate:
typeof value === 'string' ? value : DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
agentArgs: recipe?.agentArgs,
argumentChoices: recipe?.argumentChoices,
...(recipe?.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {})
}
}
@@ -56,5 +59,12 @@ export function retireActionLaunchPreferences(
captured: SourceControlActionLaunchPreferences | undefined,
selected: SourceControlActionRecipe['agentId']
): SourceControlActionLaunchPreferences | undefined {
return captured && latest === captured && captured.agentId !== selected ? undefined : latest
if (!captured || latest !== captured) {
return latest
}
const selection = readActionModelSelection(captured)
const obsolete = [selection?.model, ...Object.values(selection?.options ?? {})].some(
(entry) => entry && entry.agentId !== selected
)
return captured.agentId !== selected || obsolete ? undefined : latest
}
@@ -226,8 +226,11 @@ it('global owner: bounded controlled edits, failed/same selection and no-op save
agentArgs: '--verbose newer',
launchPreferences: {
agentId: 'claude',
model: 'sonnet',
options: { effort: 'high' },
modelSelection: {
base: 'default',
model: { agentId: 'claude', value: 'sonnet' },
options: { effort: { agentId: 'claude', value: 'high' } }
},
permissionMode: 'full-access'
}
})
+3
View File
@@ -704,6 +704,8 @@
"permissionWithoutAuto": "Agents without Approve for me",
"permissionSurfaceDifference": "Terminals use the agent’s own settings; new chats use {{mode}}.",
"permissionSameAsAll": "Same as all agents",
"choiceChipRemove": "Remove {{flag}}",
"choiceChipTooltip": "Added by {{choice}}. Remove it to use the default.",
"permissionChipRemove": "Remove {{flag}} and use {{agent}}'s own settings",
"permissionChipTooltip": "Added by Permissions. Remove it to use {{agent}}’s own settings.",
"permissionOverridesKept_one": "{{count}} agent still uses their own permission setting:",
@@ -740,6 +742,7 @@
"off": "Off",
"invalidArgs": "The CLI arguments for {{value0}} are invalid. Fix them in Settings → Source Control AI.",
"model": "Model",
"effort": "Effort",
"agentDefault": "Agent default",
"effortDefault": "Default",
"savedModel": "Model from Settings: {{value0}}",
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { sourceControlActionRecipeEntry } from '../../../../../shared/source-control-action-selection'
import type * as LaunchErrors from '@/lib/structured-agent-session-launch-errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-session-contracts'
@@ -230,7 +231,7 @@ describe('a new start beside an unconfirmed or retried chat', () => {
const started = await runSourceControlAgentActionStart({
selectedAgent: 'codex',
trimmedCommandInput: 'Fix the failing check',
recipe: { commandInputTemplate: '{basePrompt}' },
recipe: sourceControlActionRecipeEntry({ commandInputTemplate: '{basePrompt}' }),
saveTargetValue: 'none',
actionId: 'resolveComments',
settings: null,
@@ -295,7 +295,6 @@ async function launchFixChecksAgent(args: StartFixChecksAgentArgs): Promise<bool
resolveAgentArgs: (agent) =>
sourceControlActionAgentArgs(recipe(), agent, useAppStore.getState().settings),
resolveSessionOptions: (agent) => resolveSourceControlActionSessionOptions(recipe(), agent),
sessionOptionsYieldToAgentArgs: recipe().launchPreferencesYieldToAgentArgs,
...(agentOverride.kind === 'agent'
? {
agentOverride: agentOverride.agent,
@@ -169,10 +169,10 @@ describe('launchAgentInNewTab terminal tab activation', () => {
expect(repoChoice.command).toContain("'-m' 'gpt-5.5'")
expect(repoChoice.command?.match(/dangerously-bypass/g)).toHaveLength(1)
expect(repoChoice.command).not.toContain('old')
// N1: a legacy override's own raw model beats the inherited choice on terminals only.
// Own repository Args select the whole model/effort group on both surfaces.
const legacy = launch({ agentArgs: '--model mine' })
expect(legacy.command).toContain("'--model' 'mine'")
expect(legacy.command).not.toMatch(/gpt-5\.6-terra|model_reasoning_effort/)
expect(legacy.chat).toMatchObject({ model: 'gpt-5.6-terra', permissionMode: 'ask' })
expect(legacy.chat).toEqual({ permissionMode: 'ask' })
})
})
@@ -15,7 +15,7 @@ import { translate } from '@/i18n/i18n'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import type { AgentTerminalLaunchCompletion } from './structured-agent-session-launch-admission'
import {
agentPermissionNeedsPreparedCommand,
agentLaunchNeedsPreparedCommand,
toAgentLaunchPreferences
} from '../../../shared/agent-launch-preferences'
@@ -67,7 +67,7 @@ export function launchAgentInWebHostTab(args: {
agentSessionKind: 'fresh',
// Why: with or without a prompt, the desktop's launch settings decide args (#31986).
launchAgent: agent,
preparedAgentCommand: agentPermissionNeedsPreparedCommand(agent, startupPlan.sessionOptions),
preparedAgentCommand: agentLaunchNeedsPreparedCommand(agent, startupPlan.sessionOptions),
command: startupPlan.launchCommand,
...(startupPlan.env ? { env: startupPlan.env } : {}),
launchConfig: startupPlan.launchConfig,
@@ -6,7 +6,7 @@ import type { SessionOptionValue } from '../../../shared/native-chat/input/nativ
import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
agentPermissionNeedsPreparedCommand,
agentLaunchNeedsPreparedCommand,
toAgentLaunchPreferences
} from '../../../shared/agent-launch-preferences'
import {
@@ -40,7 +40,7 @@ export async function createRuntimeAgentBackgroundTerminal(args: {
const launchPreferences = toAgentLaunchPreferences(args.sessionOptions, args.agent)
return await runRemoteAgentSessionLaunch({
environmentId: args.environmentId,
hostAuthority: agentPermissionNeedsPreparedCommand(args.agent, args.sessionOptions)
hostAuthority: agentLaunchNeedsPreparedCommand(args.agent, args.sessionOptions)
? undefined
: () =>
operation.run(async (clientOperationId) =>
@@ -0,0 +1,73 @@
import { expect, it, type Mock } from 'vitest'
import { CreateIntentParams } from '../../../shared/rpc-contract/structured-agent-session-params'
import { resolveSourceControlActionSessionOptions } from '../../../shared/source-control-action-launch-preferences'
import { startStructuredAgentLaunch } from './structured-agent-session-launch'
import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options'
import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots'
import { newAgentLaunchRequestId } from './agent-launch-request-id'
export function registerActionEffortCreateTests(
mocks: {
settings: Record<string, unknown>
call: Mock<(target: unknown, method: string, params: unknown) => Promise<unknown>>
},
created: () => unknown
): void {
const target = { kind: 'local' } as const
it.each(['codex', 'claude'] as const)(
'%s action effort overlays remembered effort in create and yields to a held pick',
async (agent) => {
const model = agent === 'claude' ? 'claude-opus-5-5[1m]' : 'gpt-5.4'
mocks.settings = {
nativeChatSessionOptions: {
[agent]: { model, valuesByModel: { [model]: { effort: 'low' } } }
}
}
recordHostModelCatalogSnapshot(target, agent, null, {
origin: 'probe',
fetchedAt: 1,
models: [
{
id: model,
label: model,
isDefault: true,
efforts: ['low', 'medium', 'high'].map((value) => ({ value, label: value }))
}
]
})
mocks.call.mockImplementation(async () => created())
try {
for (const held of [undefined, 'medium']) {
const launch = startStructuredAgentLaunch('workspace-1', agent, {
requestId: newAgentLaunchRequestId(),
executionHostId: 'local',
prompt: 'opening text',
launchOptions: resolveSourceControlActionSessionOptions(
{
agentId: agent,
launchPreferences: { agentId: agent, options: { effort: 'high' } }
},
agent
)
})
if (held) {
holdStructuredAgentSessionLaunchOption(launch.sessionId, 'effort', held)
}
await launch.launchResult
const params = CreateIntentParams.parse(
mocks.call.mock.calls.findLast(([, method]) => method === 'agentSession.create')?.[2]
)
expect(params.firstMessage?.body.blocks).toEqual([{ type: 'text', text: 'opening text' }])
expect(params.optionsMode).toBeUndefined()
expect(params.options).toMatchObject({ effort: held ?? 'high' })
if (agent === 'claude') {
expect(params.options?.model).toBe(model)
}
mocks.call.mockClear()
}
} finally {
mocks.settings = {}
}
}
)
}
@@ -7,6 +7,7 @@ import {
SendParams
} from '../../../shared/rpc-contract/structured-agent-session-params'
import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation'
import { registerActionEffortCreateTests } from './structured-agent-session-launch-action-effort.test-fixture'
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
import type { HostCreateSupport } from './structured-agent-session-host-admission'
@@ -280,6 +281,8 @@ describe('the first message owned by create', () => {
}
)
registerActionEffortCreateTests(mocks, created)
it.each(['codex', 'claude'] as const)(
'creates %s on an unlisted explicit model without importing saved effort',
async (agent) => {
@@ -39,7 +39,7 @@ export function prepareStructuredLaunchCreateMessage(
if (!hostTakesExplicitOptions) {
omitStructuredLaunchPermissionMode(state)
}
// A permission mode alone overlays the host's remembered model selection.
// Options without a model overlay the host's remembered model selection.
const optionsMode: StructuredAgentSessionCreateParams['optionsMode'] =
hostTakesExplicitOptions && state.selection.explicit?.model !== undefined
? 'replace-model-selection'
@@ -89,8 +89,7 @@ function omitStructuredLaunchPermissionMode(state: StructuredLaunchState): void
state.selection = { ...state.selection, explicit: kept }
}
/** Picks held so far, the launch's own permission mode, and the chat's own model and effort: a
* first message starts its turn on them, as a later send does. */
/** The first turn uses held picks over the action's choices over the chat's shown selection. */
function structuredLaunchCreateOptions(
state: StructuredLaunchState
): Readonly<Record<string, string>> | undefined {
@@ -118,8 +117,9 @@ function structuredLaunchCreateOptions(
return requested.model === selection.held.model ? { ...requested, ...held } : requested
}
const options = {
...(requested ? { ...requested, ...held } : held),
...structuredAgentSessionSendSelection(view)
...structuredAgentSessionSendSelection(view),
...requested,
...held
}
return Object.keys(options).length ? options : undefined
}
+175
View File
@@ -0,0 +1,175 @@
import {
agentChoiceCliForms,
permissionOptionInventory,
analyzeAgentChoiceWords,
optionName,
PERMISSION_LAUNCH_GRAMMARS
} from './agent-permission-args'
import { agentPermissionArgs } from './tui-agent-permission-choice'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
import { agentChoiceConfigValue } from './agent-choice-cli-apply'
type ChoiceOccurrence = {
key: string
value: string
start: number
end: number
lift: boolean
owned: boolean
}
/** All candidates and cuts refer to the same original text in every execution grammar. */
export function analyzeAgentChoiceArgs(agent: TuiAgent, text: string) {
const forms = agentChoiceCliForms(agent)
const inventory = permissionOptionInventory(agent)
const originals = PERMISSION_LAUNCH_GRAMMARS.map((shell) => {
const parsed = tokenizeStartupCommand(text, shell)
return parsed.ok
? {
parsed,
analysis: analyzeAgentChoiceWords(agent, parsed.tokens, inventory),
offsets: new Map(parsed.spans.map((span, index) => [span.start, index]))
}
: null
})
const first = originals[0]
const occurrences: ChoiceOccurrence[] = []
const seen = new Set<string>()
if (first && originals.every((original) => original !== null)) {
for (let index = 0; index < first.parsed.tokens.length; index++) {
const token = first.parsed.tokens[index]
if (
first.analysis.roles[index]?.startsWith('value:') ||
first.analysis.roles[index] === 'positional' ||
first.analysis.roles[index] === 'terminator'
) {
continue
}
for (const { key, form } of forms) {
let count = 1
let value = ''
let lift = false
if (form.kind === 'recipe') {
count = form.words.length
if (!form.words.every((word, offset) => first.parsed.tokens[index + offset] === word)) {
continue
}
value = form.value
const recipe = agentPermissionArgs(agent, form.value)
const parsedRecipe = tokenizeStartupCommand(recipe, 'posix')
lift =
parsedRecipe.ok &&
parsedRecipe.spans.every(
(span, offset) =>
text.slice(
first.parsed.spans[index + offset].start,
first.parsed.spans[index + offset].end
) === recipe.slice(span.start, span.end)
)
} else {
const name = optionName(agent, token)
if (!form.names.includes(name)) {
continue
}
const inline = token !== name
value = inline
? token.slice(name.length + (token[name.length] === '=' ? 1 : 0))
: (first.parsed.tokens[index + 1] ?? '')
count = !inline && first.analysis.roles[index + 1] === `value:${name}` ? 2 : 1
lift =
form.lift !== 'never' &&
(!inline || name.startsWith('--')) &&
!!value &&
!value.startsWith('-')
if (form.kind === 'config') {
const configValue = agentChoiceConfigValue(value, form.key)
if (configValue === undefined) {
continue
}
value = configValue
lift &&=
/^[A-Za-z_][A-Za-z0-9_./:-]*$/.test(value) && !/^(true|false|inf|nan)$/i.test(value)
}
}
const spans = first.parsed.spans.slice(index, index + count)
const start = spans[0].start
const end = spans.at(-1)?.end ?? start
const agreed = originals.every((original) => {
if (!original) {
return false
}
const at = original.offsets.get(start) ?? -1
return (
at >= 0 &&
original.parsed.spans[at + count - 1]?.end === end &&
spans.every(
(_, offset) =>
!original.parsed.spans[at + offset]?.divergesFromShell &&
original.parsed.tokens[at + offset] === first.parsed.tokens[index + offset]
) &&
(original.analysis.optionIndices.has(at) || (key === 'permissionMode' && at === 0)) &&
(key !== 'permissionMode' ||
original.analysis.carrierIndices.every(
(carrier) => carrier >= at && carrier < at + count
))
)
})
if (!seen.has(`${key}:${start}`)) {
seen.add(`${key}:${start}`)
occurrences.push({ key, value, start, end, lift: lift && agreed, owned: agreed })
}
}
}
}
const cut = (selected: readonly ChoiceOccurrence[]): string | null => {
if (!first || !selected.length) {
return selected.length ? null : text
}
const ranges = [...selected].sort((a, b) => a.start - b.start)
if (ranges.some((range, index) => index > 0 && range.start < ranges[index - 1].end)) {
return null
}
const chunks: string[] = []
let cursor = 0
for (const { start, end } of ranges) {
chunks.push(text.slice(cursor, start))
cursor = end
}
chunks.push(text.slice(cursor))
const args = chunks.join('').trim()
const proven = originals.every((original, grammarIndex) => {
if (!original) {
return false
}
let rangeIndex = 0
const removed = original.parsed.spans.map((span) => {
while (rangeIndex < ranges.length && ranges[rangeIndex].end <= span.start) {
rangeIndex++
}
const range = ranges[rangeIndex]
return !!range && span.start >= range.start && span.end <= range.end
})
const kept = original.parsed.tokens.filter((_, index) => !removed[index])
const parsed = tokenizeStartupCommand(args, PERMISSION_LAUNCH_GRAMMARS[grammarIndex])
if (
!parsed.ok ||
kept.length !== parsed.tokens.length ||
!kept.every((token, index) => token === parsed.tokens[index])
) {
return false
}
const prefix = original.parsed.tokens.filter((_, index) => removed[index])
const composed = analyzeAgentChoiceWords(agent, [...prefix, ...kept], inventory)
const roles = original.analysis.roles.filter((_, index) => !removed[index])
const leading = removed.slice(0, removed.lastIndexOf(true) + 1).every(Boolean)
return (
leading ||
(!original.analysis.roles.includes('uncertain') &&
roles.every((role, index) => role === composed.roles[prefix.length + index]))
)
})
return proven ? args : null
}
return { occurrences, cut }
}
+42
View File
@@ -0,0 +1,42 @@
import type { AgentType } from './agent-status-types'
import type { AgentChoiceCliForm } from './agent-launch-choices'
import type { CatalogOptionApply } from './agent-session-option-catalog-types'
import { removeAgentArgOption } from './agent-session-option-agent-args'
type ChoiceCliArgumentForm = Exclude<AgentChoiceCliForm, { kind: 'recipe' }>
export function agentChoiceConfigValue(value: string, key: string): string | undefined {
const separator = value.indexOf('=')
return separator !== -1 && value.slice(0, separator).trim() === key
? value.slice(separator + 1)
: undefined
}
/** The declared grammar owns both forward spelling and conflicting argument removal. */
export function agentChoiceCliApply(
agent: AgentType,
forms: readonly [ChoiceCliArgumentForm, ...ChoiceCliArgumentForm[]],
emitted: string
): CatalogOptionApply {
const encoder = forms.find((form) => form.names.includes(emitted)) ?? forms[0]
return {
cli: forms,
launchArgs: (value) =>
encoder.kind === 'config'
? [emitted, `${encoder.key}=${String(value)}`]
: [emitted, String(value)],
removeAgentArgs: (tokens) =>
forms.reduce(
(kept, form) =>
removeAgentArgOption(
agent,
kept,
form.names,
form.kind === 'config'
? (value) => agentChoiceConfigValue(value ?? '', form.key) !== undefined
: undefined
),
[...tokens]
)
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { AgentDedicatedChoices } from './agent-launch-choices'
import { normalizeAgentModelOptionChoices } from './agent-launch-choices'
import { getAgentSessionOptionLaunchCatalog } from './agent-session-option-catalog'
import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
export type AgentChoiceLayersInput = {
agent: TuiAgent
defaults?: AgentDedicatedChoices
action?: AgentDedicatedChoices
explicit?: AgentDedicatedChoices
remembered?: AgentDedicatedChoices
userArgs?: string | null
argumentOwner: 'defaults' | 'launch'
launchPreferencesYieldToAgentArgs?: boolean
shell?: AgentStartupShell
}
export function resolveAgentChoiceLayers(input: AgentChoiceLayersInput): AgentDedicatedChoices {
const { agent } = input
const catalog = getAgentSessionOptionLaunchCatalog(agent)
let tokens: readonly string[] | undefined
const layers = [input.remembered, input.defaults, input.action, input.explicit]
let result: AgentDedicatedChoices = {}
for (const [index, layer] of layers.entries()) {
if (!layer) {
continue
}
const rawWins =
(index === 1 && input.argumentOwner === 'launch') ||
(index === 2 && input.launchPreferencesYieldToAgentArgs)
const hasRaw = (remove: ((tokens: readonly string[]) => string[]) | undefined) => {
if (!rawWins || !remove) {
return false
}
if (!tokens) {
const parsed = tokenizeStartupCommand(input.userArgs ?? '', input.shell ?? 'posix')
tokens = parsed.ok ? parsed.tokens : []
}
return remove(tokens).length < tokens.length
}
const rawModel = Boolean(
(layer.model ||
result.model ||
Object.keys(layer.options ?? {}).length ||
Object.keys(result.options ?? {}).length) &&
hasRaw(catalog?.modelApply.removeAgentArgs)
)
if (rawModel) {
result = result.permissionMode ? { permissionMode: result.permissionMode } : {}
}
const model = rawModel ? undefined : layer.model
const changingModel = model !== undefined && model !== result.model
const options = Object.fromEntries(
Object.entries({ ...(changingModel ? {} : result.options), ...layer.options }).filter(
([id]) => {
const option = (
catalog?.models.find((entry) => entry.id === (model ?? result.model))?.options ??
catalog?.unknownModelOptions
)?.find((entry) => entry.id === id)
return !rawModel && !hasRaw(option?.apply.removeAgentArgs)
}
)
)
result = {
...result,
...(model ? { model } : {}),
options,
...(layer.permissionMode ? { permissionMode: layer.permissionMode } : {})
}
}
return {
...normalizeAgentModelOptionChoices(result),
...(result.permissionMode ? { permissionMode: result.permissionMode } : {})
}
}
+153
View File
@@ -0,0 +1,153 @@
import { composeAgentLaunchModel } from './agent-session-option-launch'
import { analyzeAgentChoiceArgs } from './agent-choice-args'
import {
findCatalogModel,
getAgentSessionOptionLaunchCatalog
} from './agent-session-option-catalog'
import {
normalizeAgentModelOptionChoices,
type AgentChoicePatch,
type AgentDedicatedChoices
} from './agent-launch-choices'
import { agentPermissionChoices } from './tui-agent-permission-choice'
import type { TuiAgent } from './tui-agent'
export type AgentChoiceArgsInput = {
agent: TuiAgent
userArgs: string
choices: AgentDedicatedChoices
mode: 'load' | 'arguments-edit' | 'choice-edit'
keys: ReadonlySet<string>
patch?: AgentChoicePatch
}
export function reconcileAgentChoiceArgs(input: AgentChoiceArgsInput): {
userArgs: string
choices: AgentDedicatedChoices
} {
const { userArgs, choices } = reconcileAgentChoiceArgsDetailed(input)
return { userArgs, choices }
}
export function reconcileAgentChoiceArgsDetailed(input: AgentChoiceArgsInput) {
const { agent, userArgs, keys, mode, patch } = input
const analysis = analyzeAgentChoiceArgs(agent, userArgs)
const catalog = getAgentSessionOptionLaunchCatalog(agent)
const choices = { ...input.choices, options: { ...input.choices.options } }
const patched = (key: string) =>
key === 'model'
? patch?.model
: key === 'permissionMode'
? patch?.permissionMode
: patch?.options?.[key]
const read = (key: string) =>
key === 'model'
? choices.model
: key === 'permissionMode'
? choices.permissionMode
: choices.options[key]
const write = (key: string, value: string | null) => {
if (key === 'model') {
if (value !== choices.model) {
const independent = getAgentSessionOptionLaunchCatalog(agent)?.unknownModelOptions ?? []
choices.options = Object.fromEntries(
Object.entries(choices.options).filter(
([id]) =>
(value === null || choices.model === undefined) &&
independent.some((option) => option.id === id && option.apply.launchArgs)
)
)
}
choices.model = value ?? undefined
} else if (key === 'permissionMode') {
choices.permissionMode = agentPermissionChoices(agent).find((choice) => choice === value)
} else if (value === null) {
delete choices.options[key]
} else {
choices.options[key] = value
}
}
const composedModel = composeAgentLaunchModel(agent, {
...input.choices.options,
model: input.choices.model
})
const composedPatch =
mode === 'choice-edit' &&
catalog?.models
.find((model) => model.id === input.choices.model)
?.options.some((option) => option.apply.composedIntoModel && patched(option.id) !== undefined)
const occurrences = analysis.occurrences.map((entry) =>
entry.key === 'model' && input.choices.model && entry.value === composedModel
? { ...entry, value: input.choices.model, composed: true }
: { ...entry, composed: false }
)
const counts = new Map<string, number>()
occurrences.forEach((entry) => counts.set(entry.key, (counts.get(entry.key) ?? 0) + 1))
const selected = occurrences.filter((entry, _, all) => {
if (!keys.has(entry.key)) {
return false
}
const next = mode === 'choice-edit' ? patched(entry.key) : undefined
if (next !== undefined) {
const removing =
next === null || next === 'default' || (entry.key === 'permissionMode' && next === 'own')
return entry.owned && (!removing || entry.value === read(entry.key))
}
if (entry.composed && composedPatch) {
return entry.owned
}
if (!entry.lift || counts.get(entry.key) !== 1) {
return false
}
if (entry.key !== 'model' && entry.key !== 'permissionMode') {
const model =
mode === 'choice-edit' && patch?.model !== undefined
? (patch.model ?? undefined)
: (all.find((other) => other.key === 'model' && other.lift && counts.get('model') === 1)
?.value ?? choices.model)
const option = (
findCatalogModel(catalog ?? { models: [], modelApply: {} }, model ?? '')?.options ??
catalog?.unknownModelOptions
)?.find((option) => option.id === entry.key)
if (
option?.kind.type !== 'select' ||
!option.apply.launchArgs ||
!option.kind.choices.some((choice) => choice.value === entry.value)
) {
return false
}
}
if (
!normalizeAgentModelOptionChoices({ model: entry.value }) ||
entry.value.trim() !== entry.value
) {
return false
}
const previous = read(entry.key)
return mode !== 'load' || previous === undefined || previous === entry.value
})
const cut = analysis.cut(selected)
const lifted =
cut === null
? []
: selected.filter((entry) => mode !== 'choice-edit' || patched(entry.key) === undefined)
const model = lifted.find((entry) => entry.key === 'model')
if (model) {
write('model', model.value)
}
if (mode === 'choice-edit' && patch?.model !== undefined) {
write('model', patch.model)
}
lifted.filter((entry) => entry.key !== 'model').forEach((entry) => write(entry.key, entry.value))
if (patch && mode === 'choice-edit') {
Object.entries(patch.options ?? {}).forEach(([key, value]) => write(key, value))
if (patch.permissionMode !== undefined) {
write('permissionMode', patch.permissionMode === 'default' ? null : patch.permissionMode)
}
}
const normalized: AgentDedicatedChoices = {
...normalizeAgentModelOptionChoices(choices),
...(choices.permissionMode ? { permissionMode: choices.permissionMode } : {})
}
return { userArgs: cut ?? userArgs, choices: normalized, selected: cut === null ? [] : selected }
}
+624
View File
@@ -0,0 +1,624 @@
import {
normalizeSourceControlActionRecipe,
readSourceControlActionDefault,
type SourceControlActionRecipe
} from './source-control-ai-actions'
import { liftActionPermissionArgs } from './source-control-action-permission-lift'
import {
lowerActionArgumentChoices,
readActionArgumentNode,
transportActionArgumentChoices,
editActionArgumentChoices
} from './source-control-action-argument-choices'
import * as startupShell from './tui-agent-startup-shell'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import { describe, expect, it, vi } from 'vitest'
import * as choiceArgs from './agent-choice-args'
import { normalizeAgentModelOptionChoices } from './agent-launch-choices'
import { reconcileAgentChoiceArgs } from './agent-choice-reconciliation'
import { resolveAgentChoiceLayers } from './agent-choice-layers'
import { projectAgentLaunchProfile } from './agent-launch-profile'
import { agentLaunchNeedsPreparedCommand } from './agent-launch-preferences'
const keys = new Set(['model', 'effort', 'permissionMode'])
const load = (agent: 'claude' | 'codex', userArgs: string) =>
reconcileAgentChoiceArgs({ agent, userArgs, choices: {}, mode: 'load', keys })
describe('dedicated agent choices', () => {
it.each([
['-c modelx', undefined, false],
['--config=modelx', undefined, false],
['-c model_reasoning_effortx', undefined, false],
['-c model=value', { model: 'value' }, true],
['-c model_reasoning_effort=low', { options: { effort: 'low' } }, true],
['-c model=42', undefined, true]
] as const)('requires a config assignment for %s ownership', (userArgs, choices, owned) => {
const migrated = load('codex', userArgs)
const patch = { model: 'gpt-5.5', options: { effort: 'high' } }
const edited = reconcileAgentChoiceArgs({
agent: 'codex',
userArgs,
choices: {},
mode: 'choice-edit',
keys,
patch
})
const projected = projectAgentLaunchProfile(
'codex',
{ agentPermissionDefault: 'own' },
{ target: 'terminal', userArgs, choices: patch }
)
expect.soft(migrated).toEqual(choices ? { userArgs: '', choices } : { userArgs, choices: {} })
expect.soft(edited).toEqual({ userArgs: owned ? '' : userArgs, choices: patch })
expect(projected.args).toBe(
['-m gpt-5.5 -c model_reasoning_effort=high', owned ? '' : userArgs].filter(Boolean).join(' ')
)
})
it('bounds range comparisons and copied bytes on repeated-flag choice edits', () => {
const analyze = choiceArgs.analyzeAgentChoiceArgs
const slice = String.prototype.slice
const join = Array.prototype.join
let cutting = false
let work = { rangeEndpointReads: 0, copiedBytes: 0 }
const sliceSpy = vi.spyOn(String.prototype, 'slice').mockImplementation(function (
this: string,
start,
end
) {
const result = slice.call(this, start, end)
if (cutting) {
work.copiedBytes += result.length
}
return result
})
const joinSpy = vi.spyOn(Array.prototype, 'join').mockImplementation(function (
this: unknown[],
separator
) {
const result = join.call(this, separator)
if (cutting) {
work.copiedBytes += result.length
}
return result
})
const analyzeSpy = vi
.spyOn(choiceArgs, 'analyzeAgentChoiceArgs')
.mockImplementation((...args) => {
const analysis = analyze(...args)
return {
...analysis,
cut: (selected) => {
const measured = selected.map((entry) => ({
...entry,
get start() {
work.rangeEndpointReads++
return entry.start
},
get end() {
work.rangeEndpointReads++
return entry.end
}
}))
cutting = true
try {
return analysis.cut(measured)
} finally {
cutting = false
}
}
}
})
const measure = (count: number) => {
const userArgs = `--append-system-prompt before ${'--model opus --append-system-prompt keep '.repeat(count)}--append-system-prompt after`
work = { rangeEndpointReads: 0, copiedBytes: 0 }
expect(
reconcileAgentChoiceArgs({
agent: 'claude',
userArgs,
choices: { model: 'opus' },
mode: 'choice-edit',
keys,
patch: { model: 'sonnet' }
})
).toEqual({
userArgs: `--append-system-prompt before ${' --append-system-prompt keep '.repeat(count)}--append-system-prompt after`,
choices: { model: 'sonnet' }
})
return { count, inputBytes: userArgs.length, ...work }
}
try {
const small = measure(128)
const large = measure(1280)
console.info('choice-edit cut work', { small, large })
expect(small.rangeEndpointReads).toBeGreaterThan(small.count)
expect(small.copiedBytes).toBeGreaterThan(0)
expect(large.rangeEndpointReads).toBeLessThanOrEqual(small.rangeEndpointReads * 12)
expect(large.copiedBytes).toBeLessThanOrEqual(small.copiedBytes * 12)
expect(large.rangeEndpointReads).toBeLessThan(large.count * 64)
expect(large.copiedBytes).toBeLessThan(large.inputBytes * 20)
} finally {
analyzeSpy.mockRestore()
joinSpy.mockRestore()
sliceSpy.mockRestore()
}
})
it('retains independent options and bounded opaque model ids without discovery', () => {
expect(normalizeAgentModelOptionChoices({ options: { effort: 'high' } })).toEqual({
options: { effort: 'high' }
})
expect(normalizeAgentModelOptionChoices({ model: 'retired-unlisted-id' })).toEqual({
model: 'retired-unlisted-id'
})
expect(normalizeAgentModelOptionChoices({ options: { model: 'other' } })).toBeUndefined()
expect(normalizeAgentModelOptionChoices({ model: 'x'.repeat(513) })).toBeUndefined()
})
it('lifts a complete tuple atomically and keeps surviving raw bytes', () => {
expect(
load('claude', '--model saved-unlisted-id --effort high --permission-mode auto')
).toEqual({
userArgs: '',
choices: { model: 'saved-unlisted-id', options: { effort: 'high' }, permissionMode: 'auto' }
})
expect(load('codex', '-c model=opus --search -c model_reasoning_effort=high')).toEqual({
userArgs: '--search',
choices: { model: 'opus', options: { effort: 'high' } }
})
expect(load('claude', '--effort high')).toEqual({
userArgs: '',
choices: { options: { effort: 'high' } }
})
expect(load('claude', '--model opus --append-system-prompt "keep spacing"').userArgs).toBe(
'--append-system-prompt "keep spacing"'
)
})
it('keeps duplicates, operands, uncertain regions and unproved TOML literal', () => {
for (const text of [
'-m old --model new',
'-m old -m old',
'-m old -c model=new',
'-c model=42',
'-c model=true',
`-c 'model="foo"'`,
'-- --model other',
'--new-unknown-option --model other',
'--model'
]) {
expect(load('codex', text), text).toEqual({ userArgs: text, choices: {} })
}
for (const text of [
'--append-system-prompt "--model=other"',
'--system-prompt "--model sonnet"',
'--model op\\ us',
"--model 'opus'"
]) {
expect(load('claude', text), text).toEqual({ userArgs: text, choices: {} })
}
expect(load('codex', '-m old --model new -c model_reasoning_effort=high')).toEqual({
userArgs: '-m old --model new',
choices: { options: { effort: 'high' } }
})
})
it('distinguishes load conflicts, deliberate edits and independent option removal', () => {
const input = {
agent: 'claude' as const,
userArgs: '--model sonnet --effort high',
choices: { model: 'opus', options: { effort: 'high' } },
keys
}
expect(reconcileAgentChoiceArgs({ ...input, mode: 'load' })).toEqual({
userArgs: '--model sonnet',
choices: input.choices
})
expect(reconcileAgentChoiceArgs({ ...input, mode: 'arguments-edit' }).choices.model).toBe(
'sonnet'
)
expect(
reconcileAgentChoiceArgs({ ...input, mode: 'choice-edit', patch: { model: null } })
).toEqual({
userArgs: '--model sonnet',
choices: { options: { effort: 'high' } }
})
expect(
reconcileAgentChoiceArgs({
...input,
userArgs: '--model opus --effort high',
mode: 'choice-edit',
patch: { model: null }
})
).toEqual({ userArgs: '', choices: { options: { effort: 'high' } } })
})
it('reconciles unpatched dirty choices and recomposes generated model options', () => {
expect(
reconcileAgentChoiceArgs({
agent: 'claude',
userArgs: '--model opus --effort low --verbose',
choices: { model: 'sonnet', options: { effort: 'high' } },
keys,
mode: 'choice-edit',
patch: { permissionMode: 'ask' }
})
).toEqual({
userArgs: '--verbose',
choices: {
model: 'opus',
options: { effort: 'low' },
permissionMode: 'ask'
}
})
expect(
reconcileAgentChoiceArgs({
agent: 'claude',
userArgs: '',
choices: { model: 'opus', options: { effort: 'high' } },
keys,
mode: 'choice-edit',
patch: { model: 'haiku' }
}).choices
).toEqual({ model: 'haiku' })
const choices = { model: 'claude-opus-4-8', options: { thinking: 'true', effort: 'high' } }
const next = reconcileAgentChoiceArgs({
agent: 'cursor',
userArgs: '--model claude-opus-4-8-thinking-high --verbose',
choices,
keys: new Set(['model', 'thinking', 'effort']),
mode: 'choice-edit',
patch: { options: { effort: null } }
})
expect(next).toEqual({
userArgs: '--verbose',
choices: {
model: choices.model,
options: { thinking: 'true' }
}
})
expect(
projectAgentLaunchProfile(
'cursor',
{ agentPermissionDefault: 'own' },
{
target: 'terminal',
...next
}
).args
).toBe('--model claude-opus-4-8-thinking --verbose')
})
it('does not revive remembered model/options when raw text defeats a yielding layer', () => {
const input = {
agent: 'claude' as const,
remembered: { model: 'sonnet', options: { effort: 'high' } },
action: { permissionMode: 'ask' as const },
argumentOwner: 'launch' as const,
launchPreferencesYieldToAgentArgs: true
}
expect(resolveAgentChoiceLayers({ ...input, userArgs: '--model haiku' })).toEqual({
permissionMode: 'ask'
})
expect(resolveAgentChoiceLayers({ ...input, userArgs: '--effort medium' })).toEqual({
model: 'sonnet',
permissionMode: 'ask'
})
expect(
resolveAgentChoiceLayers({ ...input, userArgs: '--model haiku', explicit: { model: 'opus' } })
).toEqual({ model: 'opus', permissionMode: 'ask' })
})
it('resolves raw inheritance before projecting and never imports another model’s options', () => {
expect(
resolveAgentChoiceLayers({
agent: 'claude',
defaults: { model: 'sonnet' },
action: { model: 'opus' },
userArgs: '--append-system-prompt hello',
argumentOwner: 'launch',
launchPreferencesYieldToAgentArgs: true
})
).toEqual({ model: 'opus' })
expect(
resolveAgentChoiceLayers({
agent: 'claude',
remembered: { model: 'sonnet', options: { effort: 'high' } },
explicit: { model: 'opus' },
argumentOwner: 'defaults'
})
).toEqual({ model: 'opus' })
expect(
resolveAgentChoiceLayers({
agent: 'claude',
remembered: { model: 'sonnet' },
defaults: { options: { effort: 'high' } },
argumentOwner: 'defaults'
})
).toEqual({ model: 'sonnet', options: { effort: 'high' } })
expect(
projectAgentLaunchProfile(
'claude',
{ agentPermissionDefault: 'ask' },
{ target: 'terminal', choices: { options: { effort: 'high' } }, userArgs: '' }
).args
).toContain('--effort')
expect(
projectAgentLaunchProfile(
'claude',
{ agentPermissionDefault: 'ask' },
{ target: 'structured', choices: { model: 'opus' }, userArgs: '' }
)
).toEqual({ args: '', choices: { model: 'opus', permissionMode: 'ask' } })
expect(agentLaunchNeedsPreparedCommand('codex', { model: 'unlisted', effort: 'high' })).toBe(
false
)
})
})
it.each([
['claude', '--add-dir /tmp/😀 --model "opus" --effort high -- tail'],
['claude', '--add-dir /tmp --model=opus --effort=high -- tail'],
['codex', '--search -m o3 -c model_reasoning_effort=high -- tail'],
['codex', '--search -c model=o3 -- tail'],
['cursor', '--model claude-opus-4-8-thinking-high --verbose']
] as const)(
'preserves the current %s literal document spelling, gaps and edit lifetime: %s',
(agent, raw) => {
const input: SourceControlActionRecipe = { agentId: agent, agentArgs: raw }
const recipe = liftActionPermissionArgs(input)
if (raw.includes('😀')) {
expect(recipe.argumentChoices?.nodes.map((node) => node.at)).toEqual([18, 19])
}
expect(recipe.launchPreferences).toBeUndefined()
expect(recipe.argumentChoices?.syntaxAgent).toBe(agent)
expect(recipe.argumentChoices?.nodes.map((node) => node.text)).toEqual(
agent === 'claude'
? raw.includes('=')
? ['--model=opus', '--effort=high']
: ['--model "opus"', '--effort high']
: agent === 'cursor'
? ['--model claude-opus-4-8-thinking-high']
: raw.includes('model=o3')
? ['-c model=o3']
: ['-m o3', '-c model_reasoning_effort=high']
)
for (const shell of ['posix', 'powershell', 'cmd'] as const) {
const original = tokenizeStartupCommand(raw, shell)
const lowered = tokenizeStartupCommand(lowerActionArgumentChoices(recipe) ?? '', shell)
expect(original.ok && lowered.ok).toBe(true)
if (!original.ok || !lowered.ok) {
throw new Error('Expected common syntax')
}
expect(lowered.tokens).toEqual(original.tokens)
}
expect(normalizeSourceControlActionRecipe(recipe)).toEqual(recipe)
const padded = {
...recipe,
agentArgs: ` ${recipe.agentArgs} `,
argumentChoices: recipe.argumentChoices && {
...recipe.argumentChoices,
nodes: recipe.argumentChoices.nodes.map((node) => ({ ...node, at: node.at + 3 }))
}
}
expect(readSourceControlActionDefault({ fixChecks: padded }, 'fixChecks')).toEqual(recipe)
expect(
transportActionArgumentChoices(recipe, 'unclosed "')?.nodes.map((node) => node.at)
).toEqual(recipe.argumentChoices?.nodes.map(() => 0))
const text = `prefix ${recipe.agentArgs}`
const edited = {
...recipe,
agentArgs: text,
argumentChoices: transportActionArgumentChoices(recipe, text)
}
expect(edited.argumentChoices?.nodes.map((node) => node.at)).toEqual(
recipe.argumentChoices?.nodes.map((node) => (node.at === 0 ? 0 : node.at + 7))
)
const selected = {
model:
agent === 'claude' ? 'opus' : agent === 'cursor' ? 'claude-opus-4-8-thinking-high' : 'o3',
options: { effort: 'high' }
}
const removed = editActionArgumentChoices(edited, agent, selected, { model: null }, text)
expect(
(removed.argumentChoices?.nodes ?? []).some(
(node) => readActionArgumentNode(agent, node.text)?.key === 'model'
)
).toBe(false)
expect(liftActionPermissionArgs({ ...removed, agentId: agent }).argumentChoices).toEqual(
removed.argumentChoices
)
}
)
it('moves an anchor out of a replaced token and rejects malformed document atoms', () => {
const recipe = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude' as const,
agentArgs: '--add-dir /tmp --model opus -- tail'
})
const agentArgs = '--add-dir /tmpX -- tail'
const edited = {
...recipe,
agentArgs,
argumentChoices: transportActionArgumentChoices(recipe, agentArgs)
}
expect(edited.argumentChoices?.nodes).toEqual([{ at: 15, text: '--model opus' }])
expect(tokenizeStartupCommand(lowerActionArgumentChoices(edited) ?? '', 'posix')).toMatchObject({
ok: true,
tokens: ['--add-dir', '/tmpX', '--model', 'opus', '--', 'tail']
})
const trailing = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'codex' as const,
agentArgs: '--search -m o3 --search -c model_reasoning_effort=high'
})
const trailingArgs = `${trailing.agentArgs}^ `
const trailingChoices = transportActionArgumentChoices(trailing, trailingArgs)
expect(trailing.agentArgs).toBe('--search --search')
expect(trailing.argumentChoices?.nodes.map((node) => node.at)).toEqual([9, 18])
expect(trailingChoices?.nodes.map((node) => node.at)).toEqual([0, 0])
const canonical = { ...trailing, agentArgs: trailingArgs, argumentChoices: trailingChoices }
expect(trailingChoices?.nodes.map((node) => node.text)).toEqual([
'-m o3',
'-c model_reasoning_effort=high'
])
expect(canonical.agentArgs).toBe('--search --search^ ')
const normalized = normalizeSourceControlActionRecipe(canonical)
expect(normalized).toEqual(canonical)
expect(normalizeSourceControlActionRecipe(normalized)).toEqual(normalized)
for (const document of [canonical, normalized!]) {
expect(lowerActionArgumentChoices(document)).toBe(
'-m o3 -c model_reasoning_effort=high --search --search^'
)
}
for (const nodes of [
[{ at: -1, text: '--model opus' }],
[{ at: 0.5, text: '--model opus' }],
[
{ at: 1, text: '--model opus' },
{ at: 0, text: '--effort high' }
],
[
{ at: 0, text: '--model opus' },
{ at: 0, text: '--model sonnet' }
],
[{ at: 0, text: '--model opus --verbose' }],
[{ at: 0, text: '--permission-mode auto' }]
]) {
expect(() =>
normalizeSourceControlActionRecipe({
agentId: 'claude',
agentArgs: '--verbose',
argumentChoices: { syntaxAgent: 'claude', nodes }
})
).toThrow(/(?:Invalid|Duplicate) action argument choice/)
}
})
it('retires model-dependent live effort when raw Model changes, while preserving permission and independent removal', () => {
const recipe = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'claude' as const,
agentArgs: '--model opus --effort high',
launchPreferences: { agentId: 'claude' as const, permissionMode: 'ask' as const }
})
const changed = liftActionPermissionArgs({ ...recipe, agentArgs: '--model haiku' }, true)
expect(changed.argumentChoices?.nodes).toEqual([{ at: 0, text: '--model haiku' }])
expect(changed.launchPreferences).toEqual({ agentId: 'claude', permissionMode: 'ask' })
const removed = editActionArgumentChoices(
recipe,
'claude',
{ model: 'opus', options: { effort: 'high' }, permissionMode: 'ask' },
{ model: null },
''
)
expect(removed.argumentChoices?.nodes).toEqual([{ at: 0, text: '--effort high' }])
expect(removed.launchPreferences).toEqual({
agentId: 'claude',
permissionMode: 'ask',
modelSelection: { base: 'default', model: { agentId: 'claude', value: null } }
})
})
it('bounds real raw-edit transport across invalid, divergent, exhausted and two-node boundaries', () => {
const tokenize = startupShell.tokenizeStartupCommand
let reads = 0
let population = 0
let outcomes: boolean[] = []
const spy = vi.spyOn(startupShell, 'tokenizeStartupCommand').mockImplementation((text, shell) => {
const parsed = tokenize(text, shell)
outcomes.push(parsed.ok)
if (!parsed.ok) {
return parsed
}
population += parsed.spans.length
return {
...parsed,
spans: parsed.spans.map((span) =>
Object.defineProperties(
{ ...span },
{
start: {
get: () => {
reads++
return span.start
}
},
end: {
get: () => {
reads++
return span.end
}
}
}
)
)
}
})
const measure = (
recipe: SourceControlActionRecipe,
next: string,
expected: number[],
lower: string
) => {
reads = population = 0
outcomes = []
const document = transportActionArgumentChoices(recipe, next)
const work = { reads, population, outcomes: [...outcomes], nodes: document!.nodes.length }
expect(document?.syntaxAgent).toBe('codex')
expect(document?.nodes.map((node) => node.at)).toEqual(expected)
expect(document?.nodes.map((node) => node.text)).toEqual(
recipe.argumentChoices?.nodes.map((node) => node.text)
)
expect(outcomes).toHaveLength(3)
expect(reads).toBeLessThanOrEqual(20 * work.nodes * (population + 1))
expect(lowerActionArgumentChoices({ agentArgs: next, argumentChoices: document })).toBe(lower)
return work
}
try {
const invalid: ReturnType<typeof measure>[] = []
const valid: ReturnType<typeof measure>[] = []
const short = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'codex' as const,
agentArgs: '--search -m o3'
})
expect(short.agentArgs).toBe('--search')
expect(short.argumentChoices?.nodes).toEqual([{ at: 8, text: '-m o3' }])
for (const n of [128, 1280]) {
const recipe = liftActionPermissionArgs<SourceControlActionRecipe>({
agentId: 'codex' as const,
agentArgs: `--search -m o3 --search ${'--search '.repeat(n)}`
})
expect(recipe.agentArgs).toBe(`--search --search ${'--search '.repeat(n).trim()}`)
expect(recipe.argumentChoices?.nodes).toEqual([{ at: 9, text: '-m o3' }])
const next = String.fromCharCode(92, 34, 32) + recipe.agentArgs
const failed = measure(recipe, next, [0], `-m o3 ${next}`)
expect(failed.outcomes).toEqual([true, false, false])
expect(failed.population).toBeGreaterThan(n)
invalid.push(failed)
const divergent = `${'--search^ '.repeat(n)}tail`
const passed = measure(short, divergent, [divergent.length], `${divergent} -m o3`)
expect(passed.outcomes).toEqual([true, true, true])
expect(passed.population).toBeGreaterThan(n)
expect(passed.reads).toBeGreaterThan(n)
valid.push(passed)
}
measure(short, '--search^ ', [0], '-m o3 --search^')
expect(invalid[1].reads).toBeLessThanOrEqual(12 * (invalid[0].reads + 1))
expect(valid[1].reads).toBeLessThanOrEqual(12 * valid[0].reads)
const pair = liftActionPermissionArgs({
agentId: 'codex' as const,
agentArgs: '--search -m o3 --search -c model_reasoning_effort=high'
})
const two = measure(
pair,
`${pair.agentArgs}^ `,
[0, 0],
'-m o3 -c model_reasoning_effort=high --search --search^'
)
expect(two.reads).toBeGreaterThan(0)
process.stdout.write(
`${JSON.stringify({ metric: 'raw-edit-transport', invalid, valid, two })}\n`
)
} finally {
spy.mockRestore()
}
})
+47
View File
@@ -0,0 +1,47 @@
import { isAgentSessionOptions } from './agent-session-record'
import type { TuiAgent } from './tui-agent'
import type { AgentPermissionChoice } from './tui-agent-permission-choice'
export type AgentModelOptionChoices = { model?: string; options?: Record<string, string> }
export type AgentDedicatedChoices = AgentModelOptionChoices & {
permissionMode?: AgentPermissionChoice
}
export type AgentChoicePatch = {
model?: string | null
options?: Record<string, string | null>
permissionMode?: AgentPermissionChoice | 'default'
}
export type AgentLaunchChoices = Partial<Record<TuiAgent, AgentModelOptionChoices>>
export type AgentChoiceCliForm =
| { kind: 'value'; names: readonly string[]; lift: 'separate-and-long-equals' | 'never' }
| { kind: 'config'; names: readonly string[]; key: string; lift: 'codex-bare-string' | 'never' }
| { kind: 'recipe'; words: readonly string[]; value: AgentPermissionChoice }
export function normalizeAgentModelOptionChoices(
value: unknown
): AgentModelOptionChoices | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const entries = new Map<string, unknown>(Object.entries(value))
const rawModel = entries.get('model')
const model = typeof rawModel === 'string' ? rawModel.trim() : ''
const options = entries.get('options') ?? {}
if (
!isAgentSessionOptions(options) ||
Object.keys(options).some((key) =>
['', '__proto__', 'constructor', 'prototype', 'agentId', 'model', 'permissionMode'].includes(
key
)
) ||
(model && !isAgentSessionOptions({ model }))
) {
return undefined
}
return model || Object.keys(options).length
? {
...(model ? { model } : {}),
...(Object.keys(options).length ? { options } : {})
}
: undefined
}
+7 -3
View File
@@ -30,12 +30,16 @@ export function toAgentLaunchPreferences(
return Object.keys(preferences).length > 0 ? preferences : undefined
}
/** Authority v1 cannot carry a composed per-launch permission environment. */
export function agentPermissionNeedsPreparedCommand(
/** Older authority compilers lose effort-only choices and cannot carry permission environments. */
export function agentLaunchNeedsPreparedCommand(
agent: AgentType,
applied: Readonly<Record<string, unknown>> | null | undefined
): boolean {
return (
Object.hasOwn(YOLO_TUI_AGENT_ENV, agent) && readLaunchPermissionChoice(applied) !== undefined
(Object.hasOwn(YOLO_TUI_AGENT_ENV, agent) &&
readLaunchPermissionChoice(applied) !== undefined) ||
(typeof applied?.effort === 'string' && !!applied.effort.trim() && !applied.model)
)
}
export const agentPermissionNeedsPreparedCommand = agentLaunchNeedsPreparedCommand
+85
View File
@@ -0,0 +1,85 @@
import { agentPermissionArgsForLaunch } from './agent-permission-args'
import type { AgentDedicatedChoices } from './agent-launch-choices'
import { resolveAgentChoiceLayers } from './agent-choice-layers'
import {
removeOverriddenAgentSessionArgs,
resolveAgentSessionOptionLaunch
} from './agent-session-option-launch'
import type { AgentLaunchProfileSettings } from './tui-agent-launch-defaults'
import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions'
import { hasTypedAgentPermission, resolveAgentPermission } from './tui-agent-permission-choice'
import {
quoteStartupArg,
tokenizeStartupCommand,
type AgentStartupShell
} from './tui-agent-startup-shell'
import type { TuiAgent } from './tui-agent'
export type AgentLaunchProfileProjection = {
target: 'terminal' | 'structured' | 'legacy-permission'
choices?: AgentDedicatedChoices
userArgs?: string
shell?: AgentStartupShell
}
export function projectAgentLaunchProfile(
agent: TuiAgent,
settings: AgentLaunchProfileSettings | null | undefined,
input: AgentLaunchProfileProjection
): { args: string; choices: AgentDedicatedChoices } {
const typed = hasTypedAgentPermission(settings)
const userArgs =
input.userArgs ??
settings?.agentDefaultArgs?.[agent] ??
(typed || input.choices?.permissionMode !== undefined ? '' : (YOLO_TUI_AGENT_ARGS[agent] ?? ''))
const choices = resolveAgentChoiceLayers({
agent,
defaults: typed
? { permissionMode: resolveAgentPermission(agent, settings).choice }
: undefined,
explicit: input.choices,
userArgs,
argumentOwner: input.userArgs === undefined ? 'defaults' : 'launch',
shell: input.shell
})
const values = {
...choices.options,
...(choices.model ? { model: choices.model } : {})
}
const shell = input.shell ?? 'posix'
if (!Object.keys(values).length) {
if (
input.target !== 'terminal' ||
!choices.permissionMode ||
choices.permissionMode === 'own'
) {
return { args: userArgs, choices }
}
if (!tokenizeStartupCommand(userArgs, shell).ok) {
return { args: userArgs, choices }
}
const permission = agentPermissionArgsForLaunch(agent, choices.permissionMode, userArgs)
return { args: [permission, userArgs].filter(Boolean).join(' '), choices }
}
const parsed = tokenizeStartupCommand(userArgs, shell)
if (!parsed.ok) {
return { args: userArgs, choices }
}
const kept = removeOverriddenAgentSessionArgs(agent, values, parsed.tokens)
const launch = resolveAgentSessionOptionLaunch(agent, values, [], false, parsed.tokens)
const raw =
kept.length === parsed.tokens.length
? userArgs
: kept.map((token) => quoteStartupArg(token, shell)).join(' ')
if (input.target === 'structured') {
return { args: raw, choices }
}
const render = (token: string) =>
/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : quoteStartupArg(token, shell)
const permission =
input.target === 'terminal' && choices.permissionMode
? agentPermissionArgsForLaunch(agent, choices.permissionMode, userArgs)
: ''
const prefix = launch.args.map(render).join(' ')
return { args: [prefix, permission, raw].filter(Boolean).join(' '), choices }
}
+52 -111
View File
@@ -1,3 +1,6 @@
import { getAgentSessionOptionLaunchCatalog } from './agent-session-option-catalog'
import type { AgentChoiceCliForm } from './agent-launch-choices'
import type { AgentPermissionChoice } from './tui-agent-permission-choice'
import { agentRequiredValueOptions } from './agent-session-option-agent-args'
import { agentPermissionArgs, agentPermissionChoices } from './tui-agent-permission-choice'
import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell'
@@ -53,15 +56,19 @@ const CLAUDE_VARIADIC = [
'--betas',
'--plugin-dir'
]
const PERMISSION_LAUNCH_GRAMMARS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd']
export const PERMISSION_LAUNCH_GRAMMARS: readonly AgentStartupShell[] = [
'posix',
'powershell',
'cmd'
]
function words(text: string): string[] {
const parsed = tokenizeStartupCommand(text, 'posix')
return parsed.ok ? parsed.tokens : []
}
function optionName(agent: TuiAgent, token: string): string {
if (agent === 'codex' && /^-[acms].+/.test(token)) {
export function optionName(agent: TuiAgent, token: string): string {
if ((agent === 'codex' && /^-[acs].+/.test(token)) || /^-m.+/.test(token)) {
return token.slice(0, 2)
}
return token.split('=', 1)[0]
@@ -81,7 +88,8 @@ type PermissionOptionInventory = {
carriers: Set<string>
switches: Set<string>
}
function permissionOptionInventory(agent: TuiAgent): PermissionOptionInventory {
export function permissionOptionInventory(agent: TuiAgent): PermissionOptionInventory {
const forms = agentChoiceCliForms(agent)
const recipes = agentPermissionChoices(agent).flatMap((choice) => {
const tokens = words(agentPermissionArgs(agent, choice))
return tokens.map((token, index) => ({ token, next: tokens[index + 1] }))
@@ -103,11 +111,16 @@ function permissionOptionInventory(agent: TuiAgent): PermissionOptionInventory {
switches.add(name)
}
}
for (const { form } of forms) {
if (form.kind !== 'recipe') {
form.names.forEach((name) => values.add(name))
}
}
return { carriers, values, switches }
}
/** Bounded root-option ownership. Unknown regions stay uncertain, never rejected. */
function analyzeAgentPermissionWords(
export function analyzeAgentChoiceWords(
agent: TuiAgent,
tokens: readonly string[],
inventory = permissionOptionInventory(agent)
@@ -178,119 +191,47 @@ function analyzeAgentPermissionWords(
export function agentArgsSetPermission(agent: TuiAgent, text: string | null | undefined): boolean {
return PERMISSION_LAUNCH_GRAMMARS.some((grammar) => {
const parsed = tokenizeStartupCommand(text ?? '', grammar)
return parsed.ok && analyzeAgentPermissionWords(agent, parsed.tokens).carrier
return parsed.ok && analyzeAgentChoiceWords(agent, parsed.tokens).carrier
})
}
/** Extraction must preserve both shell words and their CLI owners after prefix composition. */
export function permissionRecipeCut(
agent: TuiAgent,
text: string,
recipe: string
): { args: string } | null {
const recipeTokens = tokenizeStartupCommand(recipe, 'posix')
const wanted = recipeTokens.ok ? recipeTokens.tokens : []
const rawRecipe = recipeTokens.ok
? recipeTokens.spans.map((span) => recipe.slice(span.start, span.end))
: []
if (!wanted.length) {
return null
}
const inventory = permissionOptionInventory(agent)
const originals = PERMISSION_LAUNCH_GRAMMARS.map((grammar) => {
const parsed = tokenizeStartupCommand(text, grammar)
return parsed.ok
? {
parsed,
analysis: analyzeAgentPermissionWords(agent, parsed.tokens, inventory),
offsets: new Map(parsed.spans.map((span, index) => [span.start, index]))
}
: null
})
const firstOriginal = originals[0]
if (!firstOriginal || originals.some((original) => !original)) {
return null
}
const { parsed: first, analysis } = firstOriginal
const candidates = new Set([0])
const firstCarrier = analysis.carrierIndices[0]
const lastCarrier = analysis.carrierIndices.at(-1)
if (
!analysis.roles.includes('uncertain') &&
firstCarrier !== undefined &&
lastCarrier !== undefined
) {
for (
let index = Math.max(1, firstCarrier - wanted.length + 1);
index <= firstCarrier;
index += 1
) {
if (index + wanted.length > lastCarrier && analysis.optionIndices.has(index)) {
candidates.add(index)
}
}
}
for (const index of candidates) {
const run = first.spans.slice(index, index + wanted.length)
const last = run.at(-1)
if (
!last ||
!run.every((span, offset) => text.slice(span.start, span.end) === rawRecipe[offset])
) {
continue
}
const start = run[0].start
const end = last.end
const before = text.slice(0, start).trimEnd()
const after = text.slice(end).trimStart()
const args = before && after ? `${before} ${after}` : before || after
const proven = originals.every((original, grammarIndex) => {
if (!original) {
return false
}
const { parsed, analysis: originalAnalysis, offsets } = original
const cut = tokenizeStartupCommand(args, PERMISSION_LAUNCH_GRAMMARS[grammarIndex])
if (!cut.ok) {
return false
}
const offset = offsets.get(start) ?? -1
export function agentChoiceCliForms(agent: TuiAgent) {
const catalog = getAgentSessionOptionLaunchCatalog(agent)
const options = [
...(catalog?.unknownModelOptions ?? []),
...(catalog?.models.flatMap((model) => model.options) ?? [])
]
const forms: { key: string; form: AgentChoiceCliForm }[] = (catalog?.modelApply.cli ?? []).map(
(form) => ({ key: 'model', form })
)
for (const option of options) {
for (const form of option.apply.cli ?? []) {
if (
offset === -1 ||
!wanted.every(
(word, n) =>
parsed.tokens[offset + n] === word &&
text.slice(parsed.spans[offset + n].start, parsed.spans[offset + n].end) ===
rawRecipe[n]
!forms.some(
(entry) => entry.key === option.id && JSON.stringify(entry.form) === JSON.stringify(form)
)
) {
return false
forms.push({ key: option.id, form })
}
const kept = parsed.tokens.filter((_, n) => n < offset || n >= offset + wanted.length)
if (kept.length !== cut.tokens.length || !kept.every((word, n) => word === cut.tokens[n])) {
return false
}
if (analyzeAgentPermissionWords(agent, cut.tokens, inventory).carrier) {
return false
}
// A leading exact recipe reconstructs the original argv, even with an unknown remainder.
if (offset === 0) {
return true
}
if (
!originalAnalysis.optionIndices.has(offset) ||
originalAnalysis.roles.includes('uncertain')
) {
return false
}
const composed = analyzeAgentPermissionWords(agent, [...wanted, ...cut.tokens], inventory)
const keptRoles = originalAnalysis.roles.filter(
(_, n) => n < offset || n >= offset + wanted.length
)
return keptRoles.every((role, n) => role === composed.roles[wanted.length + n])
})
if (proven) {
return { args }
}
}
return null
for (const value of agentPermissionChoices(agent)) {
const recipe = words(agentPermissionArgs(agent, value))
if (recipe.length) {
forms.push({ key: 'permissionMode', form: { kind: 'recipe', words: recipe, value } })
}
}
return forms
}
export function agentPermissionArgsForLaunch(
agent: TuiAgent,
choice: AgentPermissionChoice,
text: string
): string | null {
return choice === 'own'
? ''
: agentArgsSetPermission(agent, text)
? null
: agentPermissionArgs(agent, choice)
}
@@ -1,4 +1,4 @@
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
const ANTIGRAVITY_EFFORT: CatalogOption = {
@@ -15,8 +15,17 @@ const ANTIGRAVITY_EFFORT: CatalogOption = {
defaultValue: 'high'
},
apply: {
launchArgs: (value) => ['--effort', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('antigravity', tokens, ['--effort']),
...agentChoiceCliApply(
'antigravity',
[
{
kind: 'value',
names: ['--effort'],
lift: 'separate-and-long-equals'
}
],
'--effort'
),
midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` }
}
}
@@ -26,8 +35,17 @@ export const ANTIGRAVITY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// Model availability is account-scoped; worker-start accepts the slug reported by `agy models`.
models: [],
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('antigravity', tokens, ['--model']),
...agentChoiceCliApply(
'antigravity',
[
{
kind: 'value',
names: ['--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
),
midSession: { kind: 'agent-picker', command: '/model' }
},
unknownModelOptions: [ANTIGRAVITY_EFFORT]
@@ -3,27 +3,13 @@ import type {
CatalogModel,
CatalogOption
} from './agent-session-option-catalog-types'
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import {
CLAUDE_MODEL_LIST_ARGS,
CLAUDE_MODEL_LIST_STDIN,
parseClaudeModelList
} from './claude-model-list-probe'
// Why: Codex also reads any setting from a `-c key=value` config override.
function removeCodexAgentArgs(
tokens: readonly string[],
flags: readonly string[],
configKey: string
): string[] {
return removeAgentArgOption(
'codex',
removeAgentArgOption('codex', tokens, flags),
['-c', '--config'],
(value) => new RegExp(`^\\s*${configKey}\\s*=`).test(value ?? '')
)
}
const STANDARD_EFFORT_CHOICES = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
@@ -53,8 +39,17 @@ function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): Catal
: (choices[0]?.value ?? 'high')
},
apply: {
launchArgs: (value) => ['--effort', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('claude', tokens, ['--effort']),
...agentChoiceCliApply(
'claude',
[
{
kind: 'value',
names: ['--effort'],
lift: 'separate-and-long-equals'
}
],
'--effort'
),
midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` }
}
}
@@ -126,8 +121,17 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
}
],
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('claude', tokens, ['--model']),
...agentChoiceCliApply(
'claude',
[
{
kind: 'value',
names: ['--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
),
midSession: {
kind: 'command',
build: (value) => `/model ${String(value)}`,
@@ -166,9 +170,19 @@ function codexEffort(ceiling: 'xhigh' | 'max' | 'ultra'): CatalogOption {
defaultValue: 'medium'
},
apply: {
launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`],
removeAgentArgs: (tokens) =>
removeCodexAgentArgs(tokens, ['--reasoning-effort'], 'model_reasoning_effort'),
...agentChoiceCliApply(
'codex',
[
{
kind: 'config',
names: ['-c', '--config'],
key: 'model_reasoning_effort',
lift: 'codex-bare-string'
},
{ kind: 'value', names: ['--reasoning-effort'], lift: 'never' }
],
'-c'
),
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
}
}
@@ -190,8 +204,18 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
}
],
modelApply: {
launchArgs: (value) => ['-m', String(value)],
removeAgentArgs: (tokens) => removeCodexAgentArgs(tokens, ['-m', '--model'], 'model'),
...agentChoiceCliApply(
'codex',
[
{
kind: 'value',
names: ['-m', '--model'],
lift: 'separate-and-long-equals'
},
{ kind: 'config', names: ['-c', '--config'], key: 'model', lift: 'codex-bare-string' }
],
'-m'
),
// Codex treats multi-character writes as paste; type the command and let its picker select an account-supported model.
midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' }
},
@@ -1,4 +1,4 @@
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
const CODEBUDDY_EFFORT: CatalogOption = {
@@ -18,8 +18,17 @@ const CODEBUDDY_EFFORT: CatalogOption = {
defaultValue: 'medium'
},
apply: {
launchArgs: (value) => ['--effort', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('codebuddy', tokens, ['--effort'])
...agentChoiceCliApply(
'codebuddy',
[
{
kind: 'value',
names: ['--effort'],
lift: 'separate-and-long-equals'
}
],
'--effort'
)
}
}
@@ -34,8 +43,17 @@ export const CODEBUDDY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
{ id: 'deep-model', label: 'Deep' }
].map((model) => ({ ...model, options: [CODEBUDDY_EFFORT] })),
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('codebuddy', tokens, ['--model'])
...agentChoiceCliApply(
'codebuddy',
[
{
kind: 'value',
names: ['--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
)
},
unknownModelOptions: [CODEBUDDY_EFFORT]
}
@@ -3,7 +3,7 @@ import type {
CatalogModel,
CatalogOption
} from './agent-session-option-catalog-types'
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
models: [
@@ -13,8 +13,17 @@ export const GEMINI_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', options: [] }
],
modelApply: {
launchArgs: (value) => ['-m', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('gemini', tokens, ['-m', '--model']),
...agentChoiceCliApply(
'gemini',
[
{
kind: 'value',
names: ['-m', '--model'],
lift: 'separate-and-long-equals'
}
],
'-m'
),
midSession: { kind: 'agent-picker', command: '/model' }
}
}
@@ -82,8 +91,17 @@ export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
}
],
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('cursor', tokens, ['-m', '--model']),
...agentChoiceCliApply(
'cursor',
[
{
kind: 'value',
names: ['-m', '--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
),
midSession: { kind: 'command', build: (value) => `/model ${String(value)}` }
},
composeModelValue: (modelId, values) => {
@@ -3,7 +3,7 @@ import type {
CatalogModel,
CatalogOption
} from './agent-session-option-catalog-types'
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import { parseGrokModelList } from './grok-model-list-probe'
// Omit none (untranslated), minimal and max (never advertised by a grok menu) from the canonical effort ladder.
@@ -29,10 +29,17 @@ function grokEffort(ceiling: 'high' | 'xhigh'): CatalogOption {
defaultValue: 'high'
},
apply: {
launchArgs: (value) => ['--reasoning-effort', String(value)],
// Why: grok reads both spellings.
removeAgentArgs: (tokens) =>
removeAgentArgOption('grok', tokens, ['--effort', '--reasoning-effort']),
...agentChoiceCliApply(
'grok',
[
{
kind: 'value',
names: ['--effort', '--reasoning-effort'],
lift: 'separate-and-long-equals'
}
],
'--reasoning-effort'
),
midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` }
}
}
@@ -67,8 +74,11 @@ export const GROK_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
}
],
modelApply: {
launchArgs: (value) => ['-m', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('grok', tokens, ['-m', '--model']),
...agentChoiceCliApply(
'grok',
[{ kind: 'value', names: ['-m', '--model'], lift: 'separate-and-long-equals' }],
'-m'
),
// agent-picker replaces the list and never persists a model, preventing -m emission.
midSession: { kind: 'command', build: (value) => `/model ${String(value)}` }
},
@@ -1,4 +1,4 @@
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types'
// `none` is omitted: native-chat-session-option-labels.ts has no translation for it.
@@ -20,8 +20,17 @@ const MUSE_EFFORT: CatalogOption = {
defaultValue: 'high'
},
apply: {
launchArgs: (value) => ['--reasoning-effort', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('muse', tokens, ['--reasoning-effort'])
...agentChoiceCliApply(
'muse',
[
{
kind: 'value',
names: ['--reasoning-effort'],
lift: 'separate-and-long-equals'
}
],
'--reasoning-effort'
)
}
}
@@ -30,8 +39,17 @@ export const MUSE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// Muse has account-scoped IDs and no listing command: seed no picker; let worker-start pass the caller's --model.
models: [],
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('muse', tokens, ['--model'])
...agentChoiceCliApply(
'muse',
[
{
kind: 'value',
names: ['--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
)
},
unknownModelOptions: [MUSE_EFFORT]
}
+12 -3
View File
@@ -1,4 +1,4 @@
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog-types'
import { parseOmpModelList } from './omp-model-list-probe'
@@ -12,8 +12,17 @@ export const OMP_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = {
// OMP models depend on configured provider keys; unknown /model IDs fail. Seed none, discover picker rows, and retain the hook-reported current model.
models: [],
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('omp', tokens, ['--model']),
...agentChoiceCliApply(
'omp',
[
{
kind: 'value',
names: ['--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
),
// OMP only opens its TUI picker for /model; our extension applies the exact selector.
midSession: { kind: 'command', build: (value) => `/orca-model ${String(value)}` }
},
@@ -1,14 +1,21 @@
import { removeAgentArgOption } from './agent-session-option-agent-args'
import { agentChoiceCliApply } from './agent-choice-cli-apply'
import type { AgentSessionOptionCatalog } from './agent-session-option-catalog-types'
const MODEL_FLAGS = ['-m', '--model'] as const
// Why: worker-start needs launch serialization without exposing a new Native Chat option surface.
export const OPENCODE_LAUNCH_OPTION_CATALOG: AgentSessionOptionCatalog = {
supportsWorkerLaunchPreferences: true,
models: [],
modelApply: {
launchArgs: (value) => ['--model', String(value)],
removeAgentArgs: (tokens) => removeAgentArgOption('opencode', tokens, MODEL_FLAGS)
...agentChoiceCliApply(
'opencode',
[
{
kind: 'value',
names: ['-m', '--model'],
lift: 'separate-and-long-equals'
}
],
'--model'
)
}
}
@@ -1,3 +1,4 @@
import type { AgentChoiceCliForm } from './agent-launch-choices'
import type { AgentType } from './agent-status-types'
import type {
SessionOptionDescriptor,
@@ -20,6 +21,7 @@ export type CatalogMidSessionApply =
| { kind: 'unsupported' }
export type CatalogOptionApply = {
cli?: readonly AgentChoiceCliForm[]
launchArgs?: (value: SessionOptionValue) => string[]
/** Removes conflicting free-form args, or detects when to omit the pick from launch args and the record. */
removeAgentArgs?: (tokens: readonly string[]) => string[]
@@ -1,3 +1,4 @@
import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode'
import { CODEBUDDY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-codebuddy'
import type { AgentType } from './agent-status-types'
import { ANTIGRAVITY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-antigravity'
@@ -98,3 +99,9 @@ export function mergeDiscoveredAuthoritativeModels(
export function sessionOptionValueIsValid(value: unknown): value is SessionOptionValue {
return typeof value === 'string' || typeof value === 'boolean'
}
export function getAgentSessionOptionLaunchCatalog(
agent: AgentType
): AgentSessionOptionCatalog | null {
return agent === 'opencode' ? OPENCODE_LAUNCH_OPTION_CATALOG : getAgentSessionOptionCatalog(agent)
}
@@ -0,0 +1,112 @@
import type { AgentType } from './agent-status-types'
import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog'
import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode'
import type {
AgentSessionOptionCatalog,
CatalogOptionApply
} from './agent-session-option-catalog-types'
import type { SessionOptionValue } from './native-chat/input/native-chat-session-options'
export type ResolvedSessionOptionLaunch = {
args: string[]
appliedValues: Record<string, SessionOptionValue>
}
function isOverriddenByAgentArgs(apply: CatalogOptionApply, tokens: readonly string[]): boolean {
const kept = apply.removeAgentArgs?.(tokens)
return kept !== undefined && kept.length < tokens.length
}
export function getAgentSessionOptionLaunchCatalog(
agent: AgentType
): AgentSessionOptionCatalog | null {
return agent === 'opencode' ? OPENCODE_LAUNCH_OPTION_CATALOG : getAgentSessionOptionCatalog(agent)
}
export function removeOverriddenAgentSessionArgs(
agent: AgentType,
values: Record<string, SessionOptionValue> | null | undefined,
tokens: readonly string[]
): string[] {
const catalog = getAgentSessionOptionLaunchCatalog(agent)
const modelId = typeof values?.model === 'string' ? values.model : null
if (!catalog || !values || !modelId) {
return [...tokens]
}
let result = catalog.modelApply.removeAgentArgs?.(tokens) ?? [...tokens]
const model = findCatalogModel(catalog, modelId)
const modelOptions = model?.options ?? catalog.unknownModelOptions ?? []
for (const option of modelOptions) {
if (values[option.id] !== undefined && option.apply.removeAgentArgs) {
result = option.apply.removeAgentArgs(result)
}
}
return result
}
export function resolveAgentSessionOptionLaunch(
agent: AgentType,
values: Record<string, SessionOptionValue> | null | undefined,
trailingAgentArgs: readonly string[] = [],
includeCatalogDefaults = true
): ResolvedSessionOptionLaunch {
const catalog = getAgentSessionOptionLaunchCatalog(agent)
const modelId = typeof values?.model === 'string' ? values.model : null
if (!catalog || !values || !modelId) {
return { args: [], appliedValues: {} }
}
const model = findCatalogModel(catalog, modelId)
const appliedValues: Record<string, SessionOptionValue> = {}
const args: string[] = []
const modelOptions = model?.options ?? catalog.unknownModelOptions ?? []
const modelValues = Object.fromEntries(
modelOptions.flatMap((option) => {
const explicitValue = values[option.id]
if (explicitValue !== undefined) {
if (
!model &&
option.kind.type === 'select' &&
!option.kind.choices.some((choice) => choice.value === explicitValue)
) {
return []
}
return [[option.id, explicitValue]]
}
return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : []
})
)
const composedModelId = catalog.composeModelValue
? catalog.composeModelValue(modelId, modelValues)
: modelId
const modelOverridden = isOverriddenByAgentArgs(catalog.modelApply, trailingAgentArgs)
// Why: a repeated model flag can prevent the CLI from starting.
if (catalog.modelApply.launchArgs && !modelOverridden) {
args.push(...catalog.modelApply.launchArgs(composedModelId))
appliedValues.model = modelId
}
for (const option of modelOptions) {
const value = modelValues[option.id]
if (value === undefined) {
continue
}
if (option.apply.composedIntoModel) {
if (catalog.modelApply.launchArgs && !modelOverridden) {
appliedValues[option.id] = value
}
continue
}
// Why: options belong to the picked model and may be invalid for the user's model.
if (
!option.apply.launchArgs ||
modelOverridden ||
isOverriddenByAgentArgs(option.apply, trailingAgentArgs)
) {
continue
}
args.push(...option.apply.launchArgs(value))
appliedValues[option.id] = value
}
return { args, appliedValues }
}
+6 -9
View File
@@ -1,6 +1,9 @@
import type { AgentType } from './agent-status-types'
import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog'
import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode'
import {
findCatalogModel,
getAgentSessionOptionLaunchCatalog
} from './agent-session-option-catalog'
export { getAgentSessionOptionLaunchCatalog } from './agent-session-option-catalog'
import type {
AgentSessionOptionCatalog,
CatalogModel,
@@ -9,7 +12,7 @@ import type {
} from './agent-session-option-catalog-types'
import type { SessionOptionValue } from './native-chat/input/native-chat-session-options'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode'
import { agentPermissionArgsForLaunch } from './tui-agent-launch-defaults'
import { agentPermissionArgsForLaunch } from './agent-permission-args'
import {
readLaunchPermissionChoice,
agentPermissionHasTerminalChoice
@@ -29,12 +32,6 @@ function isOverriddenByAgentArgs(apply: CatalogOptionApply, tokens: readonly str
return kept !== undefined && kept.length < tokens.length
}
export function getAgentSessionOptionLaunchCatalog(
agent: AgentType
): AgentSessionOptionCatalog | null {
return agent === 'opencode' ? OPENCODE_LAUNCH_OPTION_CATALOG : getAgentSessionOptionCatalog(agent)
}
export function removeOverriddenAgentSessionArgs(
agent: AgentType,
values: Record<string, SessionOptionValue> | null | undefined,
+3 -20
View File
@@ -1,3 +1,4 @@
import { normalizeSourceControlAiActionDefaults } from '../source-control-ai-actions'
import { z } from 'zod'
import { OptionalGitAdmissionTier } from './git-admission-tier-params'
import { OptionalTuiAgent } from './worktree-params'
@@ -124,16 +125,7 @@ export const CommitMessageAiSettings = z.object({
})
export const SourceControlAiSettings = CommitMessageAiSettings.omit({ customPrompt: true }).extend({
actions: z
.record(
z.string(),
z.object({
agentId: z.string().nullable().optional(),
commandInputTemplate: z.string().optional(),
agentArgs: z.string().optional()
})
)
.optional(),
actions: z.unknown().optional().transform(normalizeSourceControlAiActionDefaults),
instructionsByOperation: z.record(z.string(), z.string()).optional(),
modelOverridesByOperation: z
.record(
@@ -155,16 +147,7 @@ export const SourceControlAiSettings = CommitMessageAiSettings.omit({ customProm
openAfterCreate: z.boolean().optional()
})
.optional(),
launchActionDefaults: z
.record(
z.string(),
z.object({
agentId: z.string().nullable().optional(),
commandInputTemplate: z.string().optional(),
agentArgs: z.string().optional()
})
)
.optional()
launchActionDefaults: z.unknown().optional().transform(normalizeSourceControlAiActionDefaults)
})
export const ResolvedSourceControlAiGenerationParams = z.object({
@@ -0,0 +1,300 @@
import { reconcileAgentChoiceArgsDetailed } from './agent-choice-reconciliation'
import type { AgentChoicePatch, AgentDedicatedChoices } from './agent-launch-choices'
import {
actionModelSelectionChoices,
normalizeActionModelSelection,
readActionModelSelection
} from './source-control-action-model-selection'
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
import type { ActionModelSelection, SourceControlActionRecipe } from './source-control-ai-actions'
import {
actionArgumentChoicesValue,
lowerActionArgumentNodes,
readActionArgumentNode,
demoteActionArgumentChoices
} from './source-control-action-argument-document'
export type { ActionArgumentChoices } from './source-control-action-argument-document'
export {
lowerActionArgumentChoices,
trimActionArgumentDocument,
readActionArgumentNode,
normalizeActionArgumentDocument,
lowerActionArgumentNodes,
actionArgumentChoicesValue,
transportActionArgumentChoices,
demoteActionArgumentChoices
} from './source-control-action-argument-document'
function actionCutOffset(
text: string,
result: ReturnType<typeof reconcileAgentChoiceArgsDetailed>
) {
const ranges = [...result.selected].sort((a, b) => a.start - b.start)
let leading = 0
let position = 0
let rangeIndex = 0
while (position < text.length) {
const range = ranges[rangeIndex]
if (range && position === range.start) {
position = range.end
rangeIndex++
continue
}
if (!text[position].trim()) {
leading++
position++
continue
}
break
}
return (position: number) => {
let removed = 0
for (const range of ranges) {
if (position <= range.start) {
break
}
removed += Math.min(position, range.end) - range.start
}
return Math.max(0, Math.min(result.userArgs.length, position - removed - leading))
}
}
/** Cut positions come from the same original analysis, including permission cuts and trim. */
export function reconcileActionArgumentDocument<T extends SourceControlActionRecipe>(
recipe: T,
mode: 'load' | 'arguments-edit'
): T {
return reconcileActionArgumentDocumentTransaction(recipe, mode).recipe
}
/** The receipt belongs to this operation, never to the saved document. */
export function reconcileActionArgumentDocumentTransaction<T extends SourceControlActionRecipe>(
recipe: T,
mode: 'load' | 'arguments-edit',
input?: {
choices: AgentDedicatedChoices
permissionEligible: boolean
literalKeys: readonly string[]
}
) {
if (!isTuiAgent(recipe.agentId) || typeof recipe.agentArgs !== 'string') {
return { recipe }
}
const agent = recipe.agentId
const authored = recipe.launchPreferences
const selection = readActionModelSelection(authored)
const eligible = actionModelSelectionChoices(selection, agent)
const literal = actionArgumentChoicesValue(recipe)
const effective = {
...literal,
...eligible,
options: { ...literal.options, ...eligible.options },
...(authored?.agentId === agent ? { permissionMode: authored.permissionMode } : {})
}
const result = reconcileAgentChoiceArgsDetailed({
agent,
userArgs: recipe.agentArgs,
choices: input?.choices ?? effective,
mode,
keys: new Set([
'model',
'effort',
...((input ? !input.permissionEligible : authored && authored.agentId !== agent)
? []
: ['permissionMode'])
])
})
if (!result.selected.length) {
return { recipe, result }
}
const ranges = [...result.selected].sort((a, b) => a.start - b.start)
const map = actionCutOffset(recipe.agentArgs, result)
const touched = new Set(result.selected.map((entry) => entry.key))
const document = recipe.argumentChoices
const foreign = document !== undefined && document.syntaxAgent !== agent
const retained = (document?.nodes ?? [])
.filter((node) => {
const key = readActionArgumentNode(document!.syntaxAgent, node.text)!.key
return (
foreign ||
(!touched.has(key) &&
!(
touched.has('model') &&
key !== 'model' &&
(!input || input.literalKeys.includes(key)) &&
result.choices.options?.[key] === undefined
))
)
})
.map((node) => ({ ...node, at: map(node.at) }))
const nodes = [
...retained,
...ranges
.filter((entry) => entry.key !== 'permissionMode')
.map((entry) => ({
at: map(entry.start),
text: recipe.agentArgs!.slice(entry.start, entry.end)
}))
].sort((a, b) => a.at - b.at)
const committed = lowerActionArgumentNodes(
{
agentArgs: result.userArgs,
argumentChoices: { syntaxAgent: agent, nodes }
},
(node) => foreign && retained.includes(node)
)
const preferences = { ...authored }
const modelSelection = retireActionArgumentModelSelection(
selection,
agent,
touched,
result.choices,
mode
)
if (touched.has('permissionMode')) {
preferences.permissionMode = result.choices.permissionMode
}
const normalized = modelSelection && normalizeActionModelSelection(modelSelection)
return {
result,
recipe: {
...recipe,
agentArgs: committed.agentArgs,
launchPreferences:
normalized || preferences.permissionMode
? {
agentId: authored?.agentId ?? agent,
...(normalized ? { modelSelection: normalized } : {}),
...(preferences.permissionMode ? { permissionMode: preferences.permissionMode } : {})
}
: undefined,
argumentChoices: committed.argumentChoices?.nodes.length
? committed.argumentChoices
: undefined
}
}
}
/** Retire only the authored keys selected by this document operation. */
export function retireActionArgumentModelSelection(
value: ActionModelSelection | undefined,
agent: TuiAgent,
touched: ReadonlySet<string>,
choices: AgentDedicatedChoices,
mode: 'load' | 'arguments-edit',
owns: (key: string) => boolean = () => true
) {
if (!value) {
return value
}
const next = { ...value, options: { ...value.options } }
let changed = false
if (
mode === 'arguments-edit' &&
touched.has('model') &&
owns('model') &&
next.model?.agentId === agent
) {
delete next.model
changed = true
}
for (const [key, entry] of Object.entries(next.options)) {
if (
owns(key) &&
entry.agentId === agent &&
((mode === 'arguments-edit' && touched.has(key)) ||
(touched.has('model') && entry.value !== null && choices.options?.[key] === undefined))
) {
delete next.options[key]
changed = true
}
}
return changed ? normalizeActionModelSelection(next) : value
}
export function editActionArgumentChoices(
recipe: SourceControlActionRecipe,
agent: TuiAgent,
selected: AgentDedicatedChoices,
patch: AgentChoicePatch,
text: string,
base: ActionModelSelection['base'] = 'default'
) {
const result = reconcileAgentChoiceArgsDetailed({
agent,
userArgs: text,
choices: selected,
mode: 'choice-edit',
keys: new Set(['model', 'effort', 'permissionMode']),
patch
})
const previous = recipe.launchPreferences
const authored = readActionModelSelection(previous)
const selection: ActionModelSelection = {
...authored,
base: authored?.base ?? base,
options: { ...authored?.options }
}
if (patch.model !== undefined) {
selection.model = { agentId: agent, value: result.choices.model ?? null }
for (const key of Object.keys(selected.options ?? {})) {
if (
result.choices.options?.[key] === undefined &&
(!selection.options![key] || selection.options![key].agentId === agent)
) {
selection.options![key] = { agentId: agent, value: null }
}
}
}
for (const key of Object.keys(patch.options ?? {})) {
selection.options![key] = { agentId: agent, value: result.choices.options?.[key] ?? null }
}
const lifted = result.selected.filter(
(entry) =>
entry.key !== 'permissionMode' &&
(entry.key === 'model' ? patch.model === undefined : patch.options?.[entry.key] === undefined)
)
const map = actionCutOffset(text, result)
const nodes = (recipe.argumentChoices?.nodes ?? []).filter((node) => {
const key = readActionArgumentNode(recipe.argumentChoices!.syntaxAgent, node.text)!.key
return (
!lifted.some((entry) => entry.key === key) &&
!(key === 'model'
? patch.model !== undefined
: patch.options?.[key] !== undefined ||
(patch.model !== undefined && result.choices.options?.[key] === undefined))
)
})
for (const entry of lifted) {
if (entry.key === 'model' && selection.model?.agentId === agent) {
delete selection.model
} else if (selection.options![entry.key]?.agentId === agent) {
delete selection.options![entry.key]
}
}
const updatedNodes = [
...nodes.map((node) => ({ ...node, at: map(node.at) })),
...lifted.map((entry) => ({ at: map(entry.start), text: text.slice(entry.start, entry.end) }))
].sort((a, b) => a.at - b.at)
const modelTouched = patch.model !== undefined || Object.keys(patch.options ?? {}).length > 0
const preferences =
authored || modelTouched ? normalizeActionModelSelection(selection) : undefined
const permissionMode =
patch.permissionMode !== undefined ? result.choices.permissionMode : previous?.permissionMode
const next = {
...recipe,
agentArgs: result.userArgs,
launchPreferences:
preferences || permissionMode
? {
agentId: patch.permissionMode !== undefined ? agent : (previous?.agentId ?? agent),
...(preferences ? { modelSelection: preferences } : {}),
...(permissionMode ? { permissionMode } : {})
}
: undefined,
argumentChoices: updatedNodes.length ? { syntaxAgent: agent, nodes: updatedNodes } : undefined
}
return updatedNodes.length && recipe.agentId !== agent ? demoteActionArgumentChoices(next) : next
}
@@ -0,0 +1,247 @@
import { analyzeAgentChoiceArgs } from './agent-choice-args'
import { reconcileAgentChoiceArgsDetailed } from './agent-choice-reconciliation'
import type { AgentDedicatedChoices } from './agent-launch-choices'
import { PERMISSION_LAUNCH_GRAMMARS } from './agent-permission-args'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
import type { SourceControlActionRecipe } from './source-control-ai-actions'
export type ActionArgumentChoices = {
syntaxAgent: TuiAgent
nodes: { at: number; text: string }[]
}
type ArgumentDocument = Pick<SourceControlActionRecipe, 'agentArgs' | 'argumentChoices'>
export function lowerActionArgumentChoices(document: ArgumentDocument): string | undefined {
const raw = document.agentArgs
if (raw === undefined || !document.argumentChoices) {
return raw
}
const chunks: string[] = []
let cursor = 0
for (const node of document.argumentChoices.nodes) {
chunks.push(raw.slice(cursor, node.at), ' ', node.text, ' ')
cursor = node.at
}
chunks.push(raw.slice(cursor))
return chunks.join('').trim()
}
export function trimActionArgumentDocument<T extends ArgumentDocument>(document: T): T {
if (typeof document.agentArgs !== 'string') {
return document
}
const leading = document.agentArgs.length - document.agentArgs.trimStart().length
const agentArgs = document.agentArgs.trim()
return {
...document,
agentArgs,
...(document.argumentChoices
? {
argumentChoices: {
...document.argumentChoices,
nodes: document.argumentChoices.nodes.map((node) => ({
...node,
at: Math.max(0, Math.min(agentArgs.length, node.at - leading))
}))
}
}
: {})
}
}
export function readActionArgumentNode(agent: TuiAgent, text: string) {
const result = reconcileAgentChoiceArgsDetailed({
agent,
userArgs: text,
choices: {},
mode: 'load',
keys: new Set(['model', 'effort'])
})
return result.userArgs === '' && result.selected.length === 1 ? result.selected[0] : undefined
}
export function normalizeActionArgumentDocument<T extends ArgumentDocument>(
document: T,
value: unknown,
agent: unknown
): T {
if (value === undefined) {
return document
}
if (
!value ||
typeof value !== 'object' ||
!('syntaxAgent' in value) ||
!isTuiAgent(value.syntaxAgent) ||
value.syntaxAgent !== agent ||
typeof document.agentArgs !== 'string' ||
!('nodes' in value) ||
!Array.isArray(value.nodes)
) {
throw new Error('Invalid action argument choices')
}
const nodes: ActionArgumentChoices['nodes'] = []
const keys = new Set<string>()
let at = 0
for (const item of value.nodes) {
if (
!item ||
typeof item !== 'object' ||
!Number.isInteger(item.at) ||
item.at < at ||
item.at > document.agentArgs.length ||
typeof item.text !== 'string'
) {
throw new Error('Invalid action argument choice position')
}
const candidates = analyzeAgentChoiceArgs(value.syntaxAgent, item.text).occurrences
if (
candidates.length !== 1 ||
!['model', 'effort'].includes(candidates[0].key) ||
item.text.slice(0, candidates[0].start).trim() ||
item.text.slice(candidates[0].end).trim()
) {
throw new Error('Invalid action argument choice atom')
}
const key = candidates[0].key
if (keys.has(key)) {
throw new Error('Duplicate action argument choice')
}
keys.add(key)
nodes.push({ at: item.at, text: item.text })
at = item.at
}
if (!nodes.length) {
return document
}
const argumentChoices = { syntaxAgent: value.syntaxAgent, nodes }
return lowerActionArgumentNodes(
{ ...document, argumentChoices },
(node) => !readActionArgumentNode(argumentChoices.syntaxAgent, node.text)
)
}
export function lowerActionArgumentNodes<T extends ArgumentDocument>(
document: T,
lower: (node: ActionArgumentChoices['nodes'][number]) => boolean
): T {
const argumentChoices = document.argumentChoices
if (!argumentChoices || typeof document.agentArgs !== 'string') {
return document
}
const chunks: string[] = []
const retained: ActionArgumentChoices['nodes'] = []
let cursor = 0
let length = 0
for (const node of argumentChoices.nodes) {
const prefix = document.agentArgs.slice(cursor, node.at)
chunks.push(prefix)
length += prefix.length
cursor = node.at
if (!lower(node)) {
retained.push({ ...node, at: length })
} else {
const text = ` ${node.text} `
chunks.push(text)
length += text.length
}
}
chunks.push(document.agentArgs.slice(cursor))
if (retained.length !== argumentChoices.nodes.length) {
return {
...document,
agentArgs: chunks.join(''),
argumentChoices: retained.length ? { ...argumentChoices, nodes: retained } : undefined
}
}
return document
}
export function actionArgumentChoicesValue(document: ArgumentDocument): AgentDedicatedChoices {
const choices: AgentDedicatedChoices = {}
for (const node of document.argumentChoices?.nodes ?? []) {
const occurrence = readActionArgumentNode(document.argumentChoices!.syntaxAgent, node.text)
if (occurrence?.key === 'model') {
choices.model = occurrence.value
} else if (occurrence) {
choices.options = { ...choices.options, [occurrence.key]: occurrence.value }
}
}
return choices
}
export function transportActionArgumentChoices(
document: ArgumentDocument,
next: string
): ActionArgumentChoices | undefined {
const current = document.agentArgs ?? ''
const choices = document.argumentChoices
if (!choices || current === next) {
return choices
}
let start = 0
while (start < current.length && start < next.length && current[start] === next[start]) {
start++
}
let end = current.length
let nextEnd = next.length
while (end > start && nextEnd > start && current[end - 1] === next[nextEnd - 1]) {
end--
nextEnd--
}
const parses = PERMISSION_LAUNCH_GRAMMARS.map((shell) => tokenizeStartupCommand(next, shell))
const nodes = choices.nodes.map((node) => {
let at = node.at <= start ? node.at : node.at >= end ? node.at + nextEnd - end : nextEnd
if (parses.some((parsed) => !parsed.ok)) {
return { ...node, at: 0 }
}
const cursors = parses.map(() => 0)
const boundary = (position: number) =>
parses.every((parsed, index) => {
if (!parsed.ok) {
return false
}
while (
cursors[index] < parsed.spans.length &&
parsed.spans[cursors[index]].end <= position
) {
cursors[index]++
}
const span = parsed.spans[cursors[index]]
return !span || span.start >= position
})
if (!boundary(at)) {
const first = parses[0]
const initial = at
at = 0
if (first.ok) {
for (const span of first.spans) {
const end = span.end
if (end >= initial && boundary(end)) {
at = end
break
}
}
}
}
return { ...node, at }
})
for (let index = nodes.length - 2; index >= 0; index--) {
nodes[index].at = Math.min(nodes[index].at, nodes[index + 1].at)
}
return {
...choices,
nodes
}
}
export function demoteActionArgumentChoices<T extends ArgumentDocument>(document: T): T {
return {
...document,
agentArgs: lowerActionArgumentChoices(document),
argumentChoices: undefined
}
}
@@ -1,3 +1,14 @@
import {
sourceControlActionRecipeEntry,
type SelectedSourceControlActionRecipe
} from './source-control-action-selection'
import {
readActionArgumentNode,
lowerActionArgumentNodes,
lowerActionArgumentChoices
} from './source-control-action-argument-choices'
import { actionModelSelectionChoices } from './source-control-action-model-selection'
import { normalizeAgentModelOptionChoices } from './agent-launch-choices'
import type { ExecutionHostId } from './execution-host'
import {
resolveTuiAgentLaunchArgs,
@@ -5,6 +16,7 @@ import {
} from './tui-agent-launch-defaults'
import {
findCatalogModel,
getAgentSessionOptionLaunchCatalog,
getAgentSessionOptionCatalog,
type CatalogOption
} from './agent-session-option-catalog'
@@ -19,6 +31,7 @@ import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat/input/native-ch
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode'
import {
agentPermissionHasTerminalChoice,
agentPermissionChoices,
type AgentPermissionChoice
} from './tui-agent-permission-choice'
@@ -26,7 +39,9 @@ import {
export function sourceControlActionAgentPicksModel(
agent: string | null | undefined
): agent is TuiAgent {
return isTuiAgent(agent) && Boolean(getAgentSessionOptionCatalog(agent)?.models.length)
return (
isTuiAgent(agent) && Boolean(getAgentSessionOptionLaunchCatalog(agent)?.modelApply.launchArgs)
)
}
/** The one route a saved choice reaches, for the hint under its control; null reaches both. */
@@ -70,12 +85,16 @@ export function sourceControlActionOptions(
})
}
/** The action's choice as launch inputs, with whether this repository's own CLI arguments outrank
* an inherited choice on terminals. */
/** The selected action group's typed inputs; the host composes them with actual raw Args. */
export function sourceControlActionLaunchOptions(
recipe: Pick<
SourceControlActionRecipe,
'agentId' | 'launchPreferences' | 'launchPreferencesYieldToAgentArgs'
| 'agentId'
| 'launchPreferences'
| 'launchPreferencesYieldToAgentArgs'
| 'argumentChoices'
| 'agentArgs'
| 'resolvedChoiceSources'
>,
agent: TuiAgent
): {
@@ -83,31 +102,34 @@ export function sourceControlActionLaunchOptions(
sessionOptionsYieldToAgentArgs?: true
} {
const sessionOptions = resolveSourceControlActionSessionOptions(recipe, agent)
return sessionOptions
? {
sessionOptions,
...(recipe.launchPreferencesYieldToAgentArgs
? { sessionOptionsYieldToAgentArgs: true as const }
: {})
}
: {}
return sessionOptions ? { sessionOptions } : {}
}
/** The resolved agent alone can receive its bound action choice. */
export function resolveSourceControlActionSessionOptions(
recipe: Pick<SourceControlActionRecipe, 'agentId' | 'launchPreferences'>,
recipe: Pick<
SourceControlActionRecipe,
'agentId' | 'launchPreferences' | 'argumentChoices' | 'agentArgs' | 'resolvedChoiceSources'
>,
agent: TuiAgent
): Readonly<Record<string, string>> | undefined {
const preferences = recipe.launchPreferences
if (!preferences || preferences.agentId !== agent) {
return undefined
}
return {
...(preferences.model ? { ...preferences.options, model: preferences.model } : {}),
...(preferences.permissionMode
? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: preferences.permissionMode }
return selectedActionSessionOptions(sourceControlActionRecipeEntry(recipe), agent)
}
function selectedActionSessionOptions(recipe: SelectedSourceControlActionRecipe, agent: TuiAgent) {
const { model, permission } = recipe.resolvedChoiceSources
const literal = actionLiteralProjection(recipe, agent).choices
const choices = actionModelSelectionChoices(model, agent)
const sessionOptions = {
...literal?.options,
...(literal?.model ? { model: literal.model } : {}),
...choices?.options,
...(choices?.model ? { model: choices.model } : {}),
...(permission?.agentId === agent && permission.permissionMode
? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permission.permissionMode }
: {})
}
return Object.keys(sessionOptions).length ? sessionOptions : undefined
}
/** Saved replays request only a saved terminal; a saved chat follows the settings route, which can
@@ -119,15 +141,36 @@ export function resolveSourceControlActionRequestedSurface(
return recipe.surface === 'terminal' && recipe.agentId === agent ? 'terminal' : undefined
}
type ActionLaunchProjectionInput = ({ basePrompt: string } | { renderedPrompt: string }) & {
settings?: AgentLaunchProfileSettings | null
}
type ActionLaunchLiveChoice = {
requestedSurface?: 'chat' | 'terminal'
executionHostId?: ExecutionHostId
}
/** One recipe meaning for every adapter; shell validation and delivery stay with the host. */
export function projectSourceControlActionLaunch(
recipe: SourceControlActionRecipe,
agent: TuiAgent,
input: ({ basePrompt: string } | { renderedPrompt: string }) & {
settings?: AgentLaunchProfileSettings | null
},
liveChoice?: { requestedSurface?: 'chat' | 'terminal'; executionHostId?: ExecutionHostId }
input: ActionLaunchProjectionInput,
liveChoice?: ActionLaunchLiveChoice
) {
return projectSelectedSourceControlActionLaunch(
sourceControlActionRecipeEntry(recipe),
agent,
input,
liveChoice
)
}
export function projectSelectedSourceControlActionLaunch(
recipe: SelectedSourceControlActionRecipe,
agent: TuiAgent,
input: ActionLaunchProjectionInput,
liveChoice?: ActionLaunchLiveChoice
) {
const sessionOptions = selectedActionSessionOptions(recipe, agent)
return {
commandInput: ('renderedPrompt' in input
? input.renderedPrompt
@@ -136,10 +179,10 @@ export function projectSourceControlActionLaunch(
})
).trim(),
agentArgs: ('settings' in input
? sourceControlActionAgentArgs(recipe, agent, input.settings)
: recipe.agentArgs
? selectedActionAgentArgs(recipe, agent, input.settings)
: selectedActionExecutionArgs(recipe, agent)
)?.trim(),
...sourceControlActionLaunchOptions(recipe, agent),
...(sessionOptions ? { sessionOptions } : {}),
requestedSurface:
liveChoice?.requestedSurface ?? resolveSourceControlActionRequestedSurface(recipe, agent),
selectedExecutionHostId: liveChoice?.executionHostId
@@ -148,18 +191,107 @@ export function projectSourceControlActionLaunch(
/** An action without its own permission follows Agents, including when it owns unrelated text. */
export function sourceControlActionAgentArgs(
recipe: Pick<SourceControlActionRecipe, 'agentArgs' | 'launchPreferences'>,
recipe: Pick<
SourceControlActionRecipe,
'agentArgs' | 'launchPreferences' | 'argumentChoices' | 'resolvedChoiceSources'
>,
agent: TuiAgent,
settings: AgentLaunchProfileSettings | null | undefined
): string | undefined {
if (
recipe.agentArgs === undefined ||
(recipe.launchPreferences?.agentId === agent && recipe.launchPreferences.permissionMode)
) {
return recipe.agentArgs
return selectedActionAgentArgs(sourceControlActionRecipeEntry(recipe), agent, settings)
}
function selectedActionAgentArgs(
recipe: SelectedSourceControlActionRecipe,
agent: TuiAgent,
settings: AgentLaunchProfileSettings | null | undefined
) {
const agentArgs = selectedActionExecutionArgs(recipe, agent)
const permission = recipe.resolvedChoiceSources.permission
if (agentArgs === undefined || (permission?.agentId === agent && permission.permissionMode)) {
return agentArgs
}
return resolveTuiAgentLaunchArgs(agent, {
...settings,
agentDefaultArgs: { ...settings?.agentDefaultArgs, [agent]: recipe.agentArgs }
agentDefaultArgs: { ...settings?.agentDefaultArgs, [agent]: agentArgs }
})
}
export function sourceControlActionExecutionArgs(
recipe: Pick<
SourceControlActionRecipe,
'agentArgs' | 'argumentChoices' | 'resolvedChoiceSources' | 'launchPreferences'
>,
agent: TuiAgent
): string | undefined {
return selectedActionExecutionArgs(sourceControlActionRecipeEntry(recipe), agent)
}
function selectedActionExecutionArgs(recipe: SelectedSourceControlActionRecipe, agent: TuiAgent) {
return lowerActionArgumentChoices({
...recipe,
argumentChoices: actionLiteralProjection(recipe, agent).document
})
}
function actionLiteralProjection(recipe: SelectedSourceControlActionRecipe, agent: TuiAgent) {
const sources = recipe.resolvedChoiceSources
const selection = sources.model
const choices: { model?: string; options: Record<string, string> } = { options: {} }
const document = recipe.argumentChoices
const nodes = (document?.nodes ?? []).filter((node) => {
const occurrence = readActionArgumentNode(document!.syntaxAgent, node.text)!
const entry =
occurrence.key === 'model' ? selection?.model : selection?.options?.[occurrence.key]
const selected =
sources.literalKeys.includes(occurrence.key) &&
(!entry || entry.agentId === document!.syntaxAgent)
if (!selected || document!.syntaxAgent !== agent) {
return true
}
if (!entry) {
if (occurrence.key === 'model') {
choices.model = occurrence.value
} else {
choices.options[occurrence.key] = occurrence.value
}
}
return false
})
return { choices, document: document && nodes.length ? { ...document, nodes } : undefined }
}
export function demoteDisplacedActionArgumentChoices(recipe: SourceControlActionRecipe) {
if (!recipe.resolvedChoiceSources || !recipe.argumentChoices) {
return recipe
}
const literal = isTuiAgent(recipe.agentId)
? (actionLiteralProjection(sourceControlActionRecipeEntry(recipe), recipe.agentId).document
?.nodes ?? [])
: recipe.argumentChoices.nodes
return lowerActionArgumentNodes(recipe, (node) => literal.includes(node))
}
export function sourceControlActionSelectedPreferences(
recipe: SourceControlActionRecipe,
agent: TuiAgent
) {
return selectedSourceControlActionPreferences(sourceControlActionRecipeEntry(recipe), agent)
}
export function selectedSourceControlActionPreferences(
recipe: SelectedSourceControlActionRecipe,
agent: TuiAgent
) {
const options = selectedActionSessionOptions(recipe, agent)
if (!options) {
return undefined
}
const { model, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permission, ...values } = options
const permissionMode = agentPermissionChoices(agent).find((value) => value === permission)
return {
agentId: agent,
...normalizeAgentModelOptionChoices({ model, options: values }),
...(permissionMode ? { permissionMode } : {})
}
}
@@ -0,0 +1,148 @@
import {
normalizeAgentModelOptionChoices,
type AgentDedicatedChoices
} from './agent-launch-choices'
import { agentPermissionChoices } from './tui-agent-permission-choice'
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
import type {
ActionChoiceValue,
ActionModelSelection,
SourceControlActionLaunchPreferences
} from './source-control-ai-actions'
export function normalizeActionModelSelection(value: unknown): ActionModelSelection | undefined {
if (
!value ||
typeof value !== 'object' ||
!('base' in value) ||
(value.base !== 'inherit' && value.base !== 'default')
) {
throw new Error('Invalid action model selection')
}
const choice = (entry: unknown): ActionChoiceValue => {
if (
!entry ||
typeof entry !== 'object' ||
!('agentId' in entry) ||
!isTuiAgent(entry.agentId) ||
!('value' in entry) ||
(entry.value !== null &&
(typeof entry.value !== 'string' ||
!normalizeAgentModelOptionChoices({ model: entry.value })))
) {
throw new Error('Invalid action model choice')
}
return {
agentId: entry.agentId,
value: typeof entry.value === 'string' ? entry.value.trim() : null
}
}
const model = 'model' in value && value.model !== undefined ? choice(value.model) : undefined
const options: Record<string, ActionChoiceValue> = {}
if ('options' in value && value.options !== undefined) {
if (
!value.options ||
typeof value.options !== 'object' ||
Array.isArray(value.options) ||
Object.keys(value.options).length > 32
) {
throw new Error('Invalid action model options')
}
for (const [key, entry] of Object.entries(value.options)) {
if (!normalizeAgentModelOptionChoices({ options: { [key]: 'value' } })) {
throw new Error('Invalid action model option key')
}
options[key] = choice(entry)
}
}
return value.base === 'default' || model || Object.keys(options).length
? {
base: value.base,
...(model ? { model } : {}),
...(Object.keys(options).length ? { options } : {})
}
: undefined
}
export function readActionModelSelection(
preferences: SourceControlActionLaunchPreferences | undefined
): ActionModelSelection | undefined {
if (preferences?.modelSelection) {
return preferences.modelSelection
}
const choices = normalizeAgentModelOptionChoices(preferences)
if (!preferences || !choices) {
return undefined
}
return {
base: 'default',
...(choices.model ? { model: { agentId: preferences.agentId, value: choices.model } } : {}),
...(choices.options
? {
options: Object.fromEntries(
Object.entries(choices.options).map(([key, value]) => [
key,
{ agentId: preferences.agentId, value }
])
)
}
: {})
}
}
export function actionModelSelectionChoices(
selection: ActionModelSelection | undefined,
agent: TuiAgent
): AgentDedicatedChoices {
return {
...(selection?.model?.agentId === agent && selection.model.value
? { model: selection.model.value }
: {}),
options: Object.fromEntries(
Object.entries(selection?.options ?? {}).flatMap(([key, entry]) =>
entry.agentId === agent && entry.value ? [[key, entry.value]] : []
)
)
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function normalizeSourceControlActionLaunchPreferences(
value: unknown,
recipeAgent?: unknown
): SourceControlActionLaunchPreferences | undefined {
if (!isRecord(value)) {
return undefined
}
const agentId = value.agentId ?? recipeAgent
const modelSelection =
value.modelSelection === undefined
? undefined
: normalizeActionModelSelection(value.modelSelection)
if (!isTuiAgent(agentId)) {
if (value.modelSelection !== undefined) {
throw new Error('Invalid action choice binding')
}
return undefined
}
const choices = normalizeAgentModelOptionChoices({
...value,
options:
value.options ?? (typeof value.effort === 'string' ? { effort: value.effort } : undefined)
})
const permissionMode = agentPermissionChoices(agentId).find(
(mode) => mode === value.permissionMode
)
return (value.modelSelection === undefined ? choices : modelSelection) || permissionMode
? {
agentId,
...(value.modelSelection === undefined ? choices : {}),
...(modelSelection ? { modelSelection } : {}),
...(permissionMode ? { permissionMode } : {})
}
: undefined
}
@@ -1,13 +1,20 @@
import { isTuiAgent } from './tui-agent-config'
import { liftAgentPermissionArgs } from './tui-agent-permission-lift'
import {
normalizeActionArgumentDocument,
reconcileActionArgumentDocument
} from './source-control-action-argument-choices'
import {
SOURCE_CONTROL_LAUNCH_ACTION_IDS,
canonicalActionLaunchPreferences,
type AiActionId,
type SourceControlActionRecipe
} from './source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from './source-control-ai-types'
type PermissionRecipe = Pick<SourceControlActionRecipe, 'agentId' | 'launchPreferences'> & {
type PermissionRecipe = Pick<
SourceControlActionRecipe,
'agentId' | 'launchPreferences' | 'argumentChoices'
> & {
agentArgs?: string | null
}
@@ -20,26 +27,21 @@ export function liftActionPermissionArgs<T extends PermissionRecipe>(
if (actionId && !SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((id) => id === actionId)) {
return recipe
}
const canonical = canonicalActionLaunchPreferences(recipe.launchPreferences)
const authored =
JSON.stringify(canonical) === JSON.stringify(recipe.launchPreferences)
? recipe
: { ...recipe, launchPreferences: canonical }
if (!isTuiAgent(recipe.agentId) || typeof recipe.agentArgs !== 'string') {
return recipe
}
const lifted = liftAgentPermissionArgs(recipe.agentId, recipe.agentArgs)
if (!lifted.choice) {
return recipe
}
const previous = recipe.launchPreferences
if (
previous &&
(previous.agentId !== recipe.agentId ||
(!edited && previous.permissionMode && previous.permissionMode !== lifted.choice))
) {
return recipe
}
return {
...recipe,
agentArgs: lifted.args,
launchPreferences: { ...previous, agentId: recipe.agentId, permissionMode: lifted.choice }
return authored
}
const document = normalizeActionArgumentDocument(
{ ...authored, agentArgs: recipe.agentArgs },
recipe.argumentChoices,
recipe.agentId
)
const result = reconcileActionArgumentDocument(document, edited ? 'arguments-edit' : 'load')
return JSON.stringify(result) === JSON.stringify(recipe) ? recipe : { ...recipe, ...result }
}
/** Only the row's own concrete agent proves which CLI grammar its saved text uses. */
@@ -0,0 +1,307 @@
import type {
SourceControlActionLaunchPreferences,
SourceControlActionRecipe
} from './source-control-ai-actions'
import {
actionArgumentChoicesValue,
reconcileActionArgumentDocumentTransaction,
retireActionArgumentModelSelection,
demoteActionArgumentChoices,
readActionArgumentNode,
transportActionArgumentChoices
} from './source-control-action-argument-choices'
import {
actionModelSelectionChoices,
readActionModelSelection
} from './source-control-action-model-selection'
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
type CurrentActionSource = {
authoredAgentId: SourceControlActionRecipe['agentId']
authoredPreferences: SourceControlActionLaunchPreferences | undefined
}
export type CurrentActionSelection = {
current: Omit<
SourceControlActionRecipe,
'launchPreferences' | 'resolvedChoiceSources' | 'launchPreferencesYieldToAgentArgs'
>
source: CurrentActionSource &
(
| { scope: 'global' }
| {
scope: 'repo'
parentPreferences: SourceControlActionLaunchPreferences | undefined
ownsArgs: boolean
}
)
}
export type SelectedSourceControlActionRecipe = SourceControlActionRecipe & {
resolvedChoiceSources: NonNullable<SourceControlActionRecipe['resolvedChoiceSources']> & {
canonicalAgentId: TuiAgent | undefined
}
}
/** Canonical entries have one authored source and no inferred parent. */
export function canonicalSourceControlActionSelection(
recipe: SourceControlActionRecipe
): CurrentActionSelection {
const {
launchPreferences,
resolvedChoiceSources: _sources,
launchPreferencesYieldToAgentArgs: _yield,
...current
} = recipe
return {
current,
source: {
scope: 'global',
authoredAgentId: recipe.agentId,
authoredPreferences: launchPreferences
}
}
}
export function selectSourceControlActionRecipe(
selection: CurrentActionSelection
): SelectedSourceControlActionRecipe {
const { current, source } = selection
const own = source.authoredPreferences
const parent = source.scope === 'repo' ? source.parentPreferences : undefined
const ownsArgs = source.scope === 'repo' && source.ownsArgs
const ownModel = readActionModelSelection(own)
const parentModel = readActionModelSelection(parent)
const model =
ownsArgs || ownModel?.base === 'default'
? ownModel
: ownModel
? {
base: 'inherit' as const,
model: ownModel.model ?? parentModel?.model,
options: { ...parentModel?.options, ...ownModel.options }
}
: parentModel
const permission =
source.scope === 'repo'
? (own ?? (source.authoredAgentId === undefined ? parent : undefined))
: own
const preferences = source.scope === 'global' || ownsArgs ? own : (own ?? parent)
return {
...current,
...(preferences ? { launchPreferences: preferences } : {}),
resolvedChoiceSources: {
model: source.scope === 'global' ? ownModel : model,
permission,
canonicalAgentId: permission?.agentId ?? preferences?.agentId ?? parent?.agentId,
literalKeys:
ownsArgs || ownModel?.base !== 'default' || source.scope === 'global'
? (current.argumentChoices?.nodes ?? []).flatMap((node) => {
const key = readActionArgumentNode(
current.argumentChoices!.syntaxAgent,
node.text
)?.key
return key ? [key] : []
})
: []
}
}
}
/** Read-only recipe adapters admit canonical data once; mutable invocations retain their inputs. */
export function sourceControlActionRecipeEntry(
recipe: SourceControlActionRecipe
): SelectedSourceControlActionRecipe {
return recipe.resolvedChoiceSources
? {
...recipe,
resolvedChoiceSources: {
...recipe.resolvedChoiceSources,
canonicalAgentId: recipe.resolvedChoiceSources.canonicalAgentId
}
}
: selectSourceControlActionRecipe(canonicalSourceControlActionSelection(recipe))
}
export function pickSourceControlActionAgent(
selection: CurrentActionSelection,
agent: TuiAgent,
observedAgent: TuiAgent | null,
surface: SourceControlActionRecipe['surface']
): CurrentActionSelection {
const actual = isTuiAgent(selection.current.agentId) ? selection.current.agentId : observedAgent
const same = actual === agent
const preferences = selection.source.authoredPreferences
const model = readActionModelSelection(preferences)
const alreadyBound =
preferences?.agentId === agent &&
(!model?.model || model.model.agentId === agent) &&
Object.values(model?.options ?? {}).every((entry) => entry.agentId === agent)
const document = selection.current.argumentChoices
const { surface: _surface, ...current } =
!same && document && document.syntaxAgent !== agent
? demoteActionArgumentChoices(selection.current)
: selection.current
return {
current: { ...current, agentId: agent, ...(surface ? { surface } : {}) },
source: same
? selection.source
: {
...selection.source,
authoredAgentId: agent,
authoredPreferences: alreadyBound ? preferences : undefined
}
}
}
export function editSourceControlActionArguments(
selection: CurrentActionSelection,
agentArgs: string
): CurrentActionSelection {
return {
...selection,
current: {
...selection.current,
agentArgs,
argumentChoices: transportActionArgumentChoices(selection.current, agentArgs)
}
}
}
/** Finalize an explicit raw edit once, retaining captured sources beside the consumed view. */
export function commitSourceControlActionArguments(
selection: CurrentActionSelection,
agent: TuiAgent
): { selection: CurrentActionSelection; recipe: SelectedSourceControlActionRecipe } {
const selected = selectSourceControlActionRecipe(selection)
const { model, permission, literalKeys } = selected.resolvedChoiceSources
const document = selected.argumentChoices
const literal = actionArgumentChoicesValue({
argumentChoices:
document?.syntaxAgent === agent
? {
...document,
nodes: document.nodes.filter((node) => {
const key = readActionArgumentNode(document.syntaxAgent, node.text)!.key
const entry = key === 'model' ? model?.model : model?.options?.[key]
return literalKeys.includes(key) && !entry
})
}
: undefined
})
const typed = actionModelSelectionChoices(model, agent)
const current = selection.current
const transaction = reconcileActionArgumentDocumentTransaction(
{ ...current, agentId: agent },
'arguments-edit',
{
choices: {
...literal,
...typed,
options: { ...literal.options, ...typed.options },
...(permission?.agentId === agent ? { permissionMode: permission.permissionMode } : {})
},
literalKeys,
permissionEligible: !permission || permission.agentId === agent
}
)
const receipt = transaction.result
if (!receipt?.selected.length) {
return { selection, recipe: selected }
}
const touched = new Set(receipt.selected.map((entry) => entry.key))
const source = selection.source
const own = readActionModelSelection(source.authoredPreferences)
const ownsKey = (key: string) =>
source.scope === 'global' ||
source.ownsArgs ||
own?.base === 'default' ||
(key === 'model' ? own?.model : own?.options?.[key]) !== undefined
const update = (
preferences: SourceControlActionLaunchPreferences | undefined,
owns: (key: string) => boolean,
ownsPermission: boolean
) => {
const previous = readActionModelSelection(preferences)
const modelSelection = retireActionArgumentModelSelection(
previous,
agent,
touched,
receipt.choices,
'arguments-edit',
owns
)
if (modelSelection === previous && !(ownsPermission && touched.has('permissionMode'))) {
return preferences
}
const permissionMode =
ownsPermission && touched.has('permissionMode')
? receipt.choices.permissionMode
: preferences?.permissionMode
return modelSelection || permissionMode
? {
agentId: preferences?.agentId ?? agent,
...(modelSelection ? { modelSelection } : {}),
...(permissionMode ? { permissionMode } : {})
}
: undefined
}
const ownPermission =
source.scope === 'global' ||
source.authoredPreferences !== undefined ||
source.authoredAgentId !== undefined
const next: CurrentActionSelection = {
current: {
...current,
agentArgs: transaction.recipe.agentArgs,
argumentChoices: transaction.recipe.argumentChoices
},
source: {
...source,
authoredPreferences: update(source.authoredPreferences, ownsKey, ownPermission),
...(source.scope === 'repo'
? {
parentPreferences: update(
source.parentPreferences,
(key) => !ownsKey(key),
!ownPermission
)
}
: {})
}
}
const committedPermission = touched.has('permissionMode')
? {
agentId: permission?.agentId ?? agent,
permissionMode: receipt.choices.permissionMode
}
: permission
const nodes = transaction.recipe.argumentChoices
return {
selection: next,
recipe: {
...selected,
agentArgs: transaction.recipe.agentArgs,
argumentChoices: nodes,
resolvedChoiceSources: {
...selected.resolvedChoiceSources,
model: retireActionArgumentModelSelection(
model,
agent,
touched,
receipt.choices,
'arguments-edit'
),
permission: committedPermission,
canonicalAgentId: touched.has('permissionMode')
? committedPermission?.agentId
: selected.resolvedChoiceSources.canonicalAgentId,
literalKeys: (nodes?.nodes ?? []).flatMap((node) => {
const key = readActionArgumentNode(nodes!.syntaxAgent, node.text)!.key
return literalKeys.includes(key) || touched.has(key) ? [key] : []
})
}
}
}
}
@@ -1,3 +1,11 @@
import { normalizeSourceControlActionRecipe } from './source-control-ai-actions'
import { liftActionPermissionArgs } from './source-control-action-permission-lift'
import { editActionArgumentChoices } from './source-control-action-argument-choices'
import {
projectSourceControlActionLaunch,
sourceControlActionLaunchOptions
} from './source-control-action-launch-preferences'
import { saveSourceControlActionRecipe } from './source-control-ai-recipe-save'
import { describe, expect, it } from 'vitest'
import { getDefaultSettings } from './constants'
import {
@@ -8,6 +16,11 @@ import {
} from './source-control-ai'
import type { GlobalSettings } from './global-settings-types'
import type { SourceControlActionRecipe } from './source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from './source-control-ai-types'
type RepoActionOverride = NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>['fixChecks']
>
function settings(): GlobalSettings {
const base = getDefaultSettings('/tmp')
@@ -30,6 +43,84 @@ function settings(): GlobalSettings {
}
describe('source-control AI action recipes', () => {
it('owns empty, null and unrelated Args without inheriting model or effort, but keeps permission binding', () => {
const base = settings()
base.agentPermissionDefault = 'own'
base.sourceControlAi!.actions = {
fixChecks: {
agentId: 'claude',
surface: 'terminal',
launchPreferences: {
agentId: 'claude',
modelSelection: {
base: 'default',
model: { agentId: 'claude', value: 'opus' },
options: { effort: { agentId: 'claude', value: 'high' } }
},
permissionMode: 'ask'
}
}
}
for (const agentArgs of ['', null, '--verbose', '--dangerously-skip-permissions']) {
const recipe = resolveSourceControlActionRecipe({
settings: base,
actionId: 'fixChecks',
repo: { sourceControlAi: { actionOverrides: { fixChecks: { agentArgs } } } }
})
expect(recipe.agentArgs).toBe(agentArgs ?? '')
expect(recipe.launchPreferences).toBeUndefined()
expect(recipe.resolvedChoiceSources?.permission).toEqual(
base.sourceControlAi!.actions!.fixChecks!.launchPreferences
)
expect(recipe).not.toHaveProperty('launchPreferencesYieldToAgentArgs')
expect(sourceControlActionLaunchOptions(recipe, 'claude').sessionOptions).toEqual({
permissionMode: 'ask'
})
const own = {
agentId: 'codex',
modelSelection: { base: 'default', model: { agentId: 'codex', value: 'gpt-5.5' } }
} as const
const bound = resolveSourceControlActionRecipe({
settings: base,
actionId: 'fixChecks',
repo: {
sourceControlAi: { actionOverrides: { fixChecks: { agentArgs, launchPreferences: own } } }
}
})
expect(bound.launchPreferences).toEqual(own)
expect(sourceControlActionLaunchOptions(bound, 'claude').sessionOptions).toBeUndefined()
}
})
it('resolves text action CLI args into generation params', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
actions: {
...base.sourceControlAi!.actions,
pullRequest: {
commandInputTemplate: '{basePrompt}',
agentArgs: ' --model gpt-5.4 '
}
}
}
expect(
resolveSourceControlAiForOperation({
settings: base,
repo: null,
operation: 'pullRequest',
discoveryHostKey: 'local'
})
).toMatchObject({
ok: true,
value: {
params: {
agentArgs: '--model gpt-5.4'
}
}
})
})
it('treats an explicit action null agent as the default agent, not the global legacy agent', () => {
const base = settings()
base.defaultTuiAgent = 'codex'
@@ -113,11 +204,17 @@ describe('source-control AI action recipes', () => {
).toEqual({
agentId: 'codex',
commandInputTemplate: '{basePrompt}\n\nrepo',
agentArgs: '--model gpt-5.5'
agentArgs: '--model gpt-5.5',
resolvedChoiceSources: {
model: undefined,
permission: undefined,
canonicalAgentId: undefined,
literalKeys: []
}
})
})
it('gives a repository owning its agent its own default, and partial overrides global ownership', () => {
it('selects model inheritance independently of the explicit agent and permission tuple', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
@@ -125,7 +222,10 @@ describe('source-control AI action recipes', () => {
...base.sourceControlAi!.actions,
fixChecks: {
agentId: 'codex',
launchPreferences: { agentId: 'codex' as const, model: 'gpt-5.6-terra' }
launchPreferences: {
agentId: 'codex' as const,
modelSelection: { base: 'default', model: { agentId: 'codex', value: 'gpt-5.6-terra' } }
}
}
}
}
@@ -137,18 +237,36 @@ describe('source-control AI action recipes', () => {
})
expect(resolve({ agentId: 'codex', agentArgs: '--search' }).launchPreferences).toBeUndefined()
expect(resolve({ agentId: null }).launchPreferences).toBeUndefined()
expect(resolve({ agentArgs: '--search' }).launchPreferences).toEqual({
agentId: 'codex',
model: 'gpt-5.6-terra'
})
expect(resolve({ agentId: 'claude' })).not.toHaveProperty('launchPreferences')
expect(resolve({ agentId: null }).launchPreferences).toEqual(
base.sourceControlAi.actions?.fixChecks?.launchPreferences
)
expect(resolve({ agentArgs: '--search' }).launchPreferences).toBeUndefined()
expect(resolve({ agentId: 'claude' }).launchPreferences).toEqual(
base.sourceControlAi.actions?.fixChecks?.launchPreferences
)
expect(sourceControlActionLaunchOptions(resolve({ agentId: 'claude' }), 'claude')).toEqual({})
expect(
sourceControlActionLaunchOptions(
resolve({
agentId: 'codex',
launchPreferences: { agentId: 'claude', permissionMode: 'ask' }
}),
'codex'
)
).toEqual({ sessionOptions: { model: 'gpt-5.6-terra' } })
expect(
resolve({
agentId: 'codex',
launchPreferences: { agentId: 'codex', model: 'gpt-5.5', options: { effort: 'high' } }
}).launchPreferences
).toEqual({ agentId: 'codex', model: 'gpt-5.5', options: { effort: 'high' } })
).toEqual({
agentId: 'codex',
modelSelection: {
base: 'default',
model: { agentId: 'codex', value: 'gpt-5.5' },
options: { effort: { agentId: 'codex', value: 'high' } }
}
})
})
it('lets repo action recipes explicitly clear inherited CLI args', () => {
@@ -182,7 +300,13 @@ describe('source-control AI action recipes', () => {
).toEqual({
agentId: 'claude',
commandInputTemplate: '{basePrompt}',
agentArgs: ''
agentArgs: '',
resolvedChoiceSources: {
model: undefined,
permission: undefined,
canonicalAgentId: undefined,
literalKeys: []
}
})
})
@@ -223,6 +347,80 @@ describe('source-control AI action recipes', () => {
})
})
it('lets repo launch action null templates inherit resolved action templates', () => {
const base = settings()
base.sourceControlAi = {
...base.sourceControlAi!,
actions: {
...base.sourceControlAi!.actions,
fixChecks: {
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nGlobal checks template.',
resolvedChoiceSources: {
model: undefined,
permission: undefined,
canonicalAgentId: undefined,
literalKeys: []
}
}
}
}
expect(
resolveSourceControlActionRecipe({
settings: base,
repo: {
sourceControlAi: {
actionOverrides: {
fixChecks: {
commandInputTemplate: null
}
}
}
},
actionId: 'fixChecks'
})
).toEqual({
agentId: 'claude',
commandInputTemplate: '{basePrompt}\n\nGlobal checks template.',
resolvedChoiceSources: {
model: undefined,
permission: undefined,
canonicalAgentId: undefined,
literalKeys: []
}
})
})
it('preserves existing action templates when legacy settings are only the rollback projection', () => {
const source = {
...settings().sourceControlAi!,
actions: {
...settings().sourceControlAi!.actions,
commitMessage: {
agentId: 'codex' as const,
commandInputTemplate: 'use $best-commit-msg to write a commit'
},
branchName: {
agentId: 'claude' as const,
commandInputTemplate: 'name this branch from {firstPrompt}'
}
}
}
const legacy = projectSourceControlAiToLegacyCommitMessageAi(source)
const merged = mergeLegacyCommitMessageAiIntoSourceControlAi(source, legacy)
expect(merged.actions?.commitMessage).toEqual({
agentId: 'codex',
commandInputTemplate: 'use $best-commit-msg to write a commit'
})
expect(merged.actions?.branchName).toEqual({
agentId: 'claude',
commandInputTemplate: 'name this branch from {firstPrompt}'
})
})
it('does not let rollback enabled changes clobber independent branch action templates', () => {
const source = {
...settings().sourceControlAi!,
@@ -374,3 +572,167 @@ describe('source-control AI action recipes', () => {
})
})
})
it('keeps scoped defaults, complete legacy ownership, independent bindings and literal dispositions', () => {
const global = liftActionPermissionArgs({
agentId: 'claude' as const,
agentArgs: '--model sonnet --effort high --verbose',
launchPreferences: { agentId: 'claude' as const, permissionMode: 'ask' as const }
})
const selection = { base: 'inherit' as const, model: { agentId: 'claude' as const, value: null } }
const base = settings()
base.sourceControlAi!.actions = { fixChecks: global }
const scoped = {
launchPreferences: {
agentId: 'codex' as const,
permissionMode: 'ask' as const,
modelSelection: selection
}
}
const resolve = (row: RepoActionOverride) =>
resolveSourceControlActionRecipe({
settings: base,
repo: { sourceControlAi: { actionOverrides: { fixChecks: row } } },
actionId: 'fixChecks'
})
const plan = projectSourceControlActionLaunch(resolve(scoped), 'claude', { basePrompt: 'fix' })
expect(plan.agentArgs).toBe('--verbose')
expect(plan.sessionOptions).toEqual({ effort: 'high' })
const typedBase = {
...base,
sourceControlAi: {
...base.sourceControlAi!,
actions: {
fixChecks: {
agentId: 'claude' as const,
agentArgs: '--verbose',
launchPreferences: {
agentId: 'claude' as const,
model: 'opus',
options: { effort: 'high' }
}
}
}
}
}
const sparse = {
sourceControlAi: {
actionOverrides: {
fixChecks: {
launchPreferences: { agentId: 'claude' as const, permissionMode: 'ask' as const }
}
}
}
}
const shown = resolveSourceControlActionRecipe({
settings: typedBase,
repo: sparse,
actionId: 'fixChecks'
})
const saved = saveSourceControlActionRecipe({
target: { type: 'repo', repoId: 'r' },
settings: typedBase,
repo: sparse,
actionId: 'fixChecks',
recipe: shown
})
if (!('update' in saved)) {
throw new Error('Missing repository update')
}
const reopened = resolveSourceControlActionRecipe({
settings: typedBase,
repo: { sourceControlAi: saved.update.sourceControlAi ?? undefined },
actionId: 'fixChecks'
})
expect(projectSourceControlActionLaunch(reopened, 'claude', { basePrompt: 'fix' })).toEqual(
projectSourceControlActionLaunch(shown, 'claude', { basePrompt: 'fix' })
)
const foreign = resolve({
launchPreferences: {
agentId: 'claude',
modelSelection: {
base: 'inherit',
model: { agentId: 'codex', value: null }
}
}
})
expect(projectSourceControlActionLaunch(foreign, 'claude', { basePrompt: 'fix' })).toMatchObject({
agentArgs: '--model sonnet --verbose',
sessionOptions: { effort: 'high' }
})
const legacy = normalizeSourceControlActionRecipe(
{
launchPreferences: {
agentId: 'claude',
model: 'opus',
effort: 'medium'
}
},
true
)!
expect(legacy.launchPreferences).toEqual({
agentId: 'claude',
modelSelection: {
base: 'default',
model: { agentId: 'claude', value: 'opus' },
options: { effort: { agentId: 'claude', value: 'medium' } }
}
})
const cleared = editActionArgumentChoices(
legacy,
'claude',
{ model: 'opus', options: { effort: 'medium' } },
{ model: null, options: { effort: null } },
''
)
expect(cleared.launchPreferences?.modelSelection?.base).toBe('default')
expect(
projectSourceControlActionLaunch(
resolve({ launchPreferences: cleared.launchPreferences }),
'claude',
{ basePrompt: 'fix' }
)
).toMatchObject({
agentArgs: '--model sonnet --effort high --verbose'
})
for (const raw of [undefined, '', null, '--verbose']) {
const row = { ...scoped, ...(raw !== undefined ? { agentArgs: raw } : {}) }
expect(
normalizeSourceControlActionRecipe(
{ ...row, agentArgs: raw === null ? undefined : raw },
true
)?.launchPreferences
).toEqual(scoped.launchPreferences)
if (raw !== undefined) {
expect(
projectSourceControlActionLaunch(resolve(row), 'claude', { basePrompt: 'fix' })
.sessionOptions
).toBeUndefined()
}
}
const empty = { agentId: 'claude', modelSelection: { base: 'default' } }
expect(
normalizeSourceControlActionRecipe(
{ launchPreferences: { ...empty, model: 'ignored', options: { effort: 'high' } } },
true
)?.launchPreferences
).toEqual(empty)
expect(
normalizeSourceControlActionRecipe(
{ launchPreferences: { ...empty, modelSelection: { base: 'inherit' } } },
true
)
).toBeUndefined()
for (const modelSelection of [
{ base: 'invalid' },
{ base: 'inherit', model: { agentId: 'unknown', value: null } },
{ base: 'default', options: { model: { agentId: 'claude', value: 'opaque' } } }
]) {
expect(() =>
normalizeSourceControlActionRecipe(
{ launchPreferences: { agentId: 'claude', modelSelection } },
true
)
).toThrow()
}
})
+15 -3
View File
@@ -53,11 +53,23 @@ describe('source-control AI launch action defaults', () => {
agentId: 'codex',
launchPreferences: {
agentId: 'codex' as const,
model: 'gpt-5.6-terra',
options: { effort: 'high' }
modelSelection: {
base: 'default',
model: { agentId: 'codex', value: 'gpt-5.6-terra' },
options: { effort: { agentId: 'codex', value: 'high' } }
}
}
},
resolveConflicts: {
agentId: 'claude',
launchPreferences: {
agentId: 'claude',
modelSelection: {
base: 'default',
options: { effort: { agentId: 'claude', value: 'high' } }
}
}
},
resolveConflicts: { agentId: 'claude' },
resolveComments: { agentId: 'claude' }
})
})
+72 -48
View File
@@ -1,8 +1,19 @@
import {
normalizeActionArgumentDocument,
trimActionArgumentDocument,
demoteActionArgumentChoices,
type ActionArgumentChoices
} from './source-control-action-argument-choices'
import { isCustomAgentId, type CustomAgentId } from './commit-message-agent-spec'
import { isTuiAgent } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
import { isAgentSessionOptions } from './agent-session-record'
import { agentPermissionChoices, type AgentPermissionChoice } from './tui-agent-permission-choice'
import type { AgentPermissionChoice } from './tui-agent-permission-choice'
import {
isRecord,
normalizeSourceControlActionLaunchPreferences,
readActionModelSelection
} from './source-control-action-model-selection'
export { normalizeSourceControlActionLaunchPreferences } from './source-control-action-model-selection'
// Keep the variable registry separate for max-lines headroom; no re-export so consumer searches stay complete.
@@ -20,8 +31,16 @@ export type SourceControlActionId = SourceControlTextActionId | SourceControlLau
export type AiActionId = AiTextActionId | SourceControlLaunchActionId
/** The bound agent namespace is independent of the recipe’s default-agent selection. */
export type ActionChoiceValue = { agentId: TuiAgent; value: string | null }
export type ActionModelSelection = {
base: 'inherit' | 'default'
model?: ActionChoiceValue
options?: Record<string, ActionChoiceValue>
}
export type SourceControlActionLaunchPreferences = {
agentId: TuiAgent
modelSelection?: ActionModelSelection
/** Absent: a chat starts on the agent's remembered selection, a terminal on its arguments. */
model?: string
options?: Record<string, string>
@@ -36,10 +55,18 @@ export type SourceControlActionRecipe = {
agentId?: TuiAgent | CustomAgentId | null
commandInputTemplate?: string
agentArgs?: string
argumentChoices?: ActionArgumentChoices
/** Only the bound agent receives these values, on a chat or a terminal. */
launchPreferences?: SourceControlActionLaunchPreferences
/** Bound to `agentId` like `launchPreferences`; absent follows the agent's settings route. */
surface?: SourceControlActionSurface
/** Source bindings exist only in the resolved projection, never in writable recipes. */
resolvedChoiceSources?: {
model?: ActionModelSelection
permission?: SourceControlActionLaunchPreferences
canonicalAgentId?: TuiAgent
literalKeys: readonly string[]
}
/** Resolved only, never saved: the choice is inherited from Global while this repository sets
* its own CLI arguments, so on terminals a flag those arguments set wins over the choice. */
launchPreferencesYieldToAgentArgs?: true
@@ -109,10 +136,6 @@ export const DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES: Record<AiActionId,
const ACTION_ID_SET = new Set<string>(AI_ACTION_IDS)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isSafeRecordKey(key: string): boolean {
return key !== '' && key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
}
@@ -122,7 +145,8 @@ function isAiActionId(value: string): value is AiActionId {
}
export function normalizeSourceControlActionRecipe(
value: unknown
value: unknown,
launchAction = false
): SourceControlActionRecipe | undefined {
if (!isRecord(value)) {
return undefined
@@ -148,49 +172,27 @@ export function normalizeSourceControlActionRecipe(
agentId
)
if (launchPreferences) {
normalized.launchPreferences = launchPreferences
normalized.launchPreferences = launchAction
? canonicalActionLaunchPreferences(launchPreferences)
: launchPreferences
}
if (value.surface === 'chat' || value.surface === 'terminal') {
normalized.surface = value.surface
}
return Object.keys(normalized).length > 0 ? normalized : undefined
const document = normalizeActionArgumentDocument(normalized, value.argumentChoices, agentId)
return Object.keys(document).length > 0 ? document : undefined
}
export function normalizeSourceControlActionLaunchPreferences(
value: unknown,
recipeAgent?: unknown
export function canonicalActionLaunchPreferences(
value: SourceControlActionLaunchPreferences | undefined
): SourceControlActionLaunchPreferences | undefined {
if (!isRecord(value)) {
const normalized = normalizeSourceControlActionLaunchPreferences(value)
if (!normalized) {
return undefined
}
const agentId = value.agentId ?? recipeAgent
if (!isTuiAgent(agentId)) {
return undefined
}
const model = typeof value.model === 'string' ? value.model.trim() : ''
const permissionMode = agentPermissionChoices(agentId).find(
(mode) => mode === value.permissionMode
)
// Options belong to a model; the permission mode has its own field.
const options = model
? (value.options ?? (typeof value.effort === 'string' ? { effort: value.effort } : {}))
: {}
if (
!isAgentSessionOptions(options) ||
Object.keys(options).some(
(key) => !isSafeRecordKey(key) || ['agentId', 'model', 'permissionMode'].includes(key)
) ||
(model && !isAgentSessionOptions({ ...options, model })) ||
(!model && !permissionMode)
) {
return undefined
}
return {
agentId,
...(model ? { model } : {}),
...(Object.keys(options).length ? { options } : {}),
...(permissionMode ? { permissionMode } : {})
}
const { model: _model, options: _options, ...preferences } = normalized
const modelSelection = readActionModelSelection(normalized)
return { ...preferences, ...(modelSelection ? { modelSelection } : {}) }
}
export function normalizeSourceControlAiActionDefaults(
@@ -205,7 +207,16 @@ export function normalizeSourceControlAiActionDefaults(
if (!isSafeRecordKey(key) || !isAiActionId(key)) {
continue
}
const defaultValue = normalizeSourceControlActionRecipe(item)
const defaultValue = normalizeSourceControlActionRecipe(
item,
SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((id) => id === key)
)
if (
(defaultValue?.argumentChoices || defaultValue?.launchPreferences?.modelSelection) &&
!SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((id) => id === key)
) {
throw new Error('Action choices require a launch action')
}
if (defaultValue) {
normalized[key] = defaultValue
}
@@ -218,15 +229,16 @@ export function readSourceControlActionDefault(
actionId: AiActionId
): SourceControlActionRecipe {
const value = defaults?.[actionId]
return {
return trimActionArgumentDocument({
...(value?.agentId !== undefined ? { agentId: value.agentId } : {}),
...(typeof value?.commandInputTemplate === 'string'
? { commandInputTemplate: value.commandInputTemplate.trim() }
: {}),
...(typeof value?.agentArgs === 'string' ? { agentArgs: value.agentArgs.trim() } : {}),
...(typeof value?.agentArgs === 'string' ? { agentArgs: value.agentArgs } : {}),
...(value?.argumentChoices ? { argumentChoices: value.argumentChoices } : {}),
...(value?.launchPreferences ? { launchPreferences: value.launchPreferences } : {}),
...(value?.surface ? { surface: value.surface } : {})
}
})
}
export function resolveSourceControlActionCommandTemplate(
@@ -248,7 +260,11 @@ export function setSourceControlActionDefault(
...defaults,
[actionId]: {
...defaults?.[actionId],
...value
...value,
...(SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((id) => id === actionId) &&
'launchPreferences' in value
? { launchPreferences: canonicalActionLaunchPreferences(value.launchPreferences) }
: {})
}
}
}
@@ -259,11 +275,19 @@ export function setSourceControlActionAgentDefault(
agentId: TuiAgent | CustomAgentId | null
): SourceControlAiActionDefaults {
const current = defaults?.[actionId]
if (!current || current.agentId === agentId || (!current.launchPreferences && !current.surface)) {
if (
!current ||
current.agentId === agentId ||
(!current.launchPreferences && !current.surface && !current.argumentChoices)
) {
return setSourceControlActionDefault(defaults, actionId, { agentId })
}
// A model, permission mode and chat-or-CLI pick name one agent's choices; none survives a change.
const { launchPreferences: _previousAgentModel, surface: _previousSurface, ...rest } = current
const {
launchPreferences: _previousAgentModel,
surface: _previousSurface,
...rest
} = demoteActionArgumentChoices(current)
return { ...defaults, [actionId]: { ...rest, agentId } }
}
@@ -1,11 +1,16 @@
import { trimActionArgumentDocument } from './source-control-action-argument-choices'
import {
selectSourceControlActionRecipe,
type CurrentActionSelection,
type SelectedSourceControlActionRecipe
} from './source-control-action-selection'
import type { GlobalSettings } from './global-settings-types'
import type { Repo } from './repo-types'
import {
readSourceControlActionDefault,
resolveSourceControlActionCommandTemplate,
type AiActionId,
type SourceControlTextActionId,
type SourceControlActionRecipe
type SourceControlTextActionId
} from './source-control-ai-actions'
import { commandTemplateFromOperationInstruction } from './source-control-ai-command-template'
import { normalizeRepoSourceControlAiOverrides } from './source-control-ai-repo-settings'
@@ -182,11 +187,11 @@ export function resolveSourceControlAiEnabled(input: {
)
}
export function resolveSourceControlActionRecipe(input: {
export function captureSourceControlActionSelection(input: {
settings: Pick<GlobalSettings, 'sourceControlAi' | 'commitMessageAi'> | null | undefined
repo?: Pick<Repo, 'sourceControlAi'> | null
actionId: AiActionId
}): SourceControlActionRecipe {
}): CurrentActionSelection {
const source = normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
input.settings?.commitMessageAi
@@ -202,37 +207,48 @@ export function resolveSourceControlActionRecipe(input: {
source.actions,
input.actionId
)
if (!repoRecipe) {
return { ...globalRecipe, commandInputTemplate }
}
// A model and a chat-or-CLI pick belong to their agent; a repo on another agent inherits neither.
const ownsRaw = repoRecipe?.agentArgs !== undefined
const raw = ownsRaw ? repoRecipe : globalRecipe
const {
launchPreferences: globalLaunchPreferences,
launchPreferences: _globalPreferences,
argumentChoices: _globalDocument,
agentArgs: _globalArgs,
surface: globalSurface,
...globalWithoutModel
...globalBase
} = globalRecipe
const repoKeepsGlobalAgent = repoRecipe.agentId === undefined
const launchPreferences =
repoRecipe.launchPreferences ?? (repoKeepsGlobalAgent ? globalLaunchPreferences : undefined)
const surface = repoRecipe.surface ?? (repoKeepsGlobalAgent ? globalSurface : undefined)
const inheritsChoiceUnderOwnArgs =
launchPreferences !== undefined &&
repoRecipe.launchPreferences === undefined &&
repoRecipe.agentArgs !== undefined
return {
...globalWithoutModel,
const current = trimActionArgumentDocument({
...globalBase,
commandInputTemplate,
...(launchPreferences ? { launchPreferences } : {}),
...(surface ? { surface } : {}),
...(inheritsChoiceUnderOwnArgs ? { launchPreferencesYieldToAgentArgs: true as const } : {}),
...(repoRecipe.agentId !== undefined ? { agentId: repoRecipe.agentId } : {}),
...(typeof repoRecipe.commandInputTemplate === 'string'
? { commandInputTemplate: repoRecipe.commandInputTemplate.trim() }
...(raw?.agentArgs !== undefined ? { agentArgs: raw.agentArgs ?? '' } : {}),
...(raw?.argumentChoices ? { argumentChoices: raw.argumentChoices } : {}),
...((repoRecipe?.surface ?? (repoRecipe?.agentId === undefined ? globalSurface : undefined))
? { surface: repoRecipe?.surface ?? globalSurface }
: {}),
...(typeof repoRecipe.agentArgs === 'string'
? { agentArgs: repoRecipe.agentArgs.trim() }
: repoRecipe.agentArgs === null
? { agentArgs: '' }
: {})
...(repoRecipe?.agentId !== undefined ? { agentId: repoRecipe.agentId } : {}),
...(typeof repoRecipe?.commandInputTemplate === 'string'
? { commandInputTemplate: repoRecipe.commandInputTemplate.trim() }
: {})
})
return {
current,
source: repoRecipe
? {
scope: 'repo',
authoredAgentId: repoRecipe.agentId,
authoredPreferences: repoRecipe.launchPreferences,
parentPreferences: globalRecipe.launchPreferences,
ownsArgs: ownsRaw
}
: {
scope: 'global',
authoredAgentId: globalRecipe.agentId,
authoredPreferences: globalRecipe.launchPreferences
}
}
}
export function resolveSourceControlActionRecipe(
input: Parameters<typeof captureSourceControlActionSelection>[0]
): SelectedSourceControlActionRecipe {
return selectSourceControlActionRecipe(captureSourceControlActionSelection(input))
}
@@ -74,6 +74,42 @@ describe('source-control AI recipe saves', () => {
})
})
it('keeps the action model when saving a repo recipe', () => {
const launchPreferences = {
agentId: 'codex' as const,
model: 'gpt-5.6-terra',
options: { effort: 'high' }
}
const result = saveSourceControlActionRecipe({
target: { type: 'repo', repoId: 'repo-1' },
settings: settings(),
repo: null,
actionId: 'fixChecks',
recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}', launchPreferences }
})
expect(result).toMatchObject({
update: {
sourceControlAi: {
actionOverrides: {
fixChecks: {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
launchPreferences: {
agentId: 'codex',
modelSelection: {
base: 'default',
model: { agentId: 'codex', value: 'gpt-5.6-terra' },
options: { effort: { agentId: 'codex', value: 'high' } }
}
}
}
}
}
}
})
})
it('clears empty repo source-control AI writes with the JSON boundary sentinel', () => {
expect(toSourceControlAiRepoUpdate({ prCreationDefaults: { draft: null } })).toEqual({
sourceControlAi: null
+39 -4
View File
@@ -1,3 +1,6 @@
import { sourceControlActionRecipeEntry } from './source-control-action-selection'
import { trimActionArgumentDocument } from './source-control-action-argument-choices'
import { demoteDisplacedActionArgumentChoices } from './source-control-action-launch-preferences'
import {
normalizeRepoSourceControlAiOverrides,
normalizeSourceControlAiSettings
@@ -5,6 +8,7 @@ import {
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
SOURCE_CONTROL_TEXT_ACTION_IDS,
canonicalActionLaunchPreferences,
type SourceControlActionId,
type SourceControlActionRecipe
} from './source-control-ai-actions'
@@ -41,6 +45,7 @@ type ReadCompatibleActionRecipe = {
agentId?: SourceControlActionRecipe['agentId']
commandInputTemplate?: string | null
agentArgs?: string | null
argumentChoices?: SourceControlActionRecipe['argumentChoices']
launchPreferences?: SourceControlActionRecipe['launchPreferences']
surface?: SourceControlActionRecipe['surface']
}
@@ -87,12 +92,20 @@ function normalizeCompleteRecipe(
? recipe.commandInputTemplate
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
const rawAgentArgs = recipe.agentArgs
const agentArgs = typeof rawAgentArgs === 'string' ? rawAgentArgs.trim() : undefined
const agentArgs = typeof rawAgentArgs === 'string' ? rawAgentArgs : undefined
return {
...trimActionArgumentDocument({ agentArgs, argumentChoices: recipe.argumentChoices }),
...(rawAgentArgs === null ? { agentArgs: null } : {}),
agentId: recipe.agentId ?? null,
commandInputTemplate,
...(agentArgs !== undefined ? { agentArgs } : {}),
...(recipe.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {}),
...(recipe.launchPreferences
? {
launchPreferences: TEXT_ACTION_ID_SET.has(actionId)
? recipe.launchPreferences
: canonicalActionLaunchPreferences(recipe.launchPreferences)
}
: {}),
...(recipe.surface ? { surface: recipe.surface } : {})
}
}
@@ -177,7 +190,29 @@ function normalizeRecipeForSave(
export function saveSourceControlActionRecipe(
input: SaveSourceControlActionRecipeInput
): SourceControlActionRecipeSaveResult {
const savedRecipe = normalizeRecipeForSave(input.actionId, input.recipe)
const selected = sourceControlActionRecipeEntry(input.recipe)
const recipe = demoteDisplacedActionArgumentChoices(selected)
const sources = selected.resolvedChoiceSources
const savedRecipe = normalizeRecipeForSave(
input.actionId,
sources
? {
...recipe,
launchPreferences:
sources.canonicalAgentId && (sources.model || sources.permission)
? {
agentId: sources.canonicalAgentId,
...(sources.model
? { modelSelection: { ...sources.model, base: 'default' } }
: {}),
...(sources.permission?.permissionMode
? { permissionMode: sources.permission.permissionMode }
: {})
}
: undefined
}
: recipe
)
if (input.target.type === 'global') {
const current = normalizeSourceControlAiSettings(
input.settings.sourceControlAi,
+18 -4
View File
@@ -1,6 +1,7 @@
import type { TuiAgent } from './tui-agent'
import {
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_LAUNCH_ACTION_IDS,
SOURCE_CONTROL_TEXT_ACTION_IDS,
normalizeSourceControlActionRecipe,
type SourceControlActionId
@@ -92,7 +93,7 @@ function normalizeModelChoice(value: unknown): SourceControlAiModelChoice | unde
function normalizeKnownRecord<K extends string, T>(
value: unknown,
keys: readonly K[],
normalizeValue: (value: unknown) => T | undefined
normalizeValue: (value: unknown, key: K) => T | undefined
): Partial<Record<K, T>> | undefined {
if (!isRecord(value)) {
return undefined
@@ -102,7 +103,7 @@ function normalizeKnownRecord<K extends string, T>(
if (!Object.hasOwn(value, key)) {
continue
}
const item = normalizeValue(value[key])
const item = normalizeValue(value[key], key)
if (item !== undefined) {
normalized[key] = item
}
@@ -158,11 +159,16 @@ export function normalizeRepoSourceControlAiOverrides(
const actionOverrides = normalizeKnownRecord<SourceControlActionId, RepoActionOverride>(
value.actionOverrides,
SOURCE_CONTROL_ACTION_IDS,
(item) => {
(item, key) => {
if (!isRecord(item)) {
return undefined
}
const recipe: RepoActionOverride = { ...normalizeSourceControlActionRecipe(item) }
const recipe: RepoActionOverride = {
...normalizeSourceControlActionRecipe(
item,
SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((id) => id === key)
)
}
if (item.commandInputTemplate === null) {
recipe.commandInputTemplate = null
}
@@ -172,6 +178,14 @@ export function normalizeRepoSourceControlAiOverrides(
return Object.keys(recipe).length > 0 ? recipe : undefined
}
)
for (const [id, recipe] of Object.entries(actionOverrides ?? {})) {
if (
(recipe.argumentChoices || recipe.launchPreferences?.modelSelection) &&
!SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((action) => action === id)
) {
throw new Error('Action choices require a launch action')
}
}
const migratedActionOverrides = { ...actionOverrides }
for (const operation of SOURCE_CONTROL_TEXT_ACTION_IDS) {
const instruction = instructionsByOperation?.[operation]
+4 -1
View File
@@ -5,6 +5,7 @@ import type {
SourceControlAiActionDefaults,
SourceControlActionLaunchPreferences,
SourceControlActionSurface,
SourceControlActionRecipe,
SourceControlActionId,
SourceControlTextActionId,
AiTextActionId
@@ -61,6 +62,7 @@ export type RepoSourceControlAiOverrides = {
agentId?: TuiAgent | CustomAgentId | null
commandInputTemplate?: string | null
agentArgs?: string | null
argumentChoices?: SourceControlActionRecipe['argumentChoices']
launchPreferences?: SourceControlActionLaunchPreferences
surface?: SourceControlActionSurface
}
@@ -77,7 +79,8 @@ export type RepoSourceControlAiOverrides = {
export type CompleteSourceControlActionRecipe = {
agentId: TuiAgent | CustomAgentId | null
commandInputTemplate: string
agentArgs?: string
agentArgs?: string | null
argumentChoices?: SourceControlActionRecipe['argumentChoices']
launchPreferences?: SourceControlActionLaunchPreferences
surface?: SourceControlActionSurface
}

Some files were not shown because too many files have changed in this diff Show More