diff --git a/config/vitest.config.ts b/config/vitest.config.ts index adae6fa4d13..5647a9a1710 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -21,15 +21,7 @@ const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-e const paletteMeasurementFile = 'src/renderer/src/lib/palette-match/palette-match-performance.test.ts' const measurementFiles = [measurementFile, paletteMeasurementFile] -const phoneContractModules = [ - 'mobile/src/session/native-chat/mobile-native-chat-stream-frame.ts', - 'mobile/src/session/mobile-native-chat-render-data.ts', - 'mobile/src/i18n/mobile-i18n.ts', - 'mobile/src/i18n/locale-catalog-imports.ts' -].map((file) => normalizePath(resolve(file))) -const phoneContractPattern = new RegExp( - `^(?:${phoneContractModules.map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(?:\\?.*)?$` -) +const phoneContractPattern = /\/mobile\/src\/.*\.(?:ts|tsx)(?:\?.*)?$/ const transforms = { oxc: { exclude: [/\.js$/, phoneContractPattern] }, plugins: [ @@ -39,7 +31,7 @@ const transforms = { enforce: 'pre', async transform(source, id) { const filename = normalizePath(id.split('?')[0]) - if (!phoneContractModules.includes(filename)) { + if (!phoneContractPattern.test(filename)) { return null } const result = await transformWithOxc(source, filename, { tsconfig: false }) diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index dd87bfa2db1..a9c4d8d9493 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -191,7 +191,7 @@ describe('launchAgentWithPrompt', () => { expect(launchParams(sendRequest)).toHaveProperty('agentArgs', '--model opus') }) - it('keeps inherited phone options without raw conflicts when the workspace shell is unknown', async () => { + it('forwards the repository raw group when the workspace shell is unknown', async () => { const settings = { defaultTuiAgent: 'codex' as const, sourceControlAi: { @@ -213,7 +213,7 @@ describe('launchAgentWithPrompt', () => { } expect( resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixChecks' }) - ).toMatchObject({ launchPreferencesYieldToAgentArgs: true }) + ).not.toHaveProperty('launchPreferences') const { client, sendRequest } = hostClient({ settings, repo, @@ -224,8 +224,8 @@ describe('launchAgentWithPrompt', () => { kind: 'sent' }) const params = AgentLaunchReplay.parse(launchParams(sendRequest)) - expect(params).toMatchObject({ surface: 'terminal', chatOptions: { model: 'gpt-5.4' } }) - expect(params).not.toHaveProperty('agentArgs') + expect(params).toMatchObject({ surface: 'terminal', agentArgs: '--model gpt-5.3-codex' }) + expect(params).not.toHaveProperty('chatOptions') const preferences = toAgentLaunchPreferences(params.chatOptions, params.agent) const startup = buildAgentStartupPlan({ ...resolveAgentStartupPlanInputs({ @@ -239,14 +239,14 @@ describe('launchAgentWithPrompt', () => { }), prompt: params.prompt?.text ?? '' }) - expect(startup?.launchCommand).toContain('gpt-5.4') - expect(startup?.launchCommand).not.toContain('gpt-5.3-codex') + expect(startup?.launchCommand).toContain('gpt-5.3-codex') + expect(startup?.launchCommand).not.toContain('gpt-5.4') sendRequest.mockClear() await expect(run(client, capabilities)).resolves.toEqual({ kind: 'sent' }) const olderHostParams = AgentLaunchReplay.parse(launchParams(sendRequest)) expect(olderHostParams).not.toHaveProperty('surface') - expect(olderHostParams).not.toHaveProperty('agentArgs') + expect(olderHostParams.agentArgs).toBe('--model gpt-5.3-codex') expect(olderHostParams.chatOptions).toEqual(params.chatOptions) }) diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 0a364356a41..7a7b3012cde 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -91,8 +91,7 @@ export async function launchAgentWithPrompt(args: { worktreeId: args.worktreeId, agent: resolved.agent, ...(projection.sessionOptions ? { chatOptions: projection.sessionOptions } : {}), - // The phone lacks the workspace shell needed to project inherited choices over raw flags. - agentArgs: projection.sessionOptionsYieldToAgentArgs ? undefined : projection.agentArgs, + agentArgs: projection.agentArgs, surface: projection.requestedSurface, prompt: { text, delivery: 'submit' }, launchSource: args.launchSource diff --git a/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts b/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts index a9636894b59..920970bc65e 100644 --- a/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts +++ b/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts @@ -119,6 +119,18 @@ describe('Store', () => { options: { thinkingEffort: 'high' } } } + const canonicalRecipe = { + ...recipe, + launchPreferences: { + agentId: 'codex', + modelSelection: { + base: 'default', + model: { agentId: 'codex', value: 'gpt-6-sol' }, + options: { thinkingEffort: { agentId: 'codex', value: 'high' } } + }, + permissionMode: 'ask' + } + } const sparse = { agentArgs: '--model gpt-6-sol' } const repo = makeRepo() writeDataFile({ @@ -135,14 +147,16 @@ describe('Store', () => { }) const store = await createStore() - expect(store.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe) + expect(store.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(canonicalRecipe) expect(store.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual(sparse) store.updateSettings({ theme: 'light' }) store.flush() await closeTestStores() const reopened = await createStore() - expect(reopened.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe) + expect(reopened.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual( + canonicalRecipe + ) expect(reopened.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual( sparse ) diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts index 03065fdd6ab..d6e6f1a2f5d 100644 --- a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts +++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts @@ -90,17 +90,21 @@ export function prepareLoadedTerminalSettings( markNeedsSave() } const legacyCommitMessageAi = parsed.settings?.commitMessageAi - let migratedSourceControlAi = rawSourceControlAiMissing + const sourceBeforeNormalization = rawSourceControlAiMissing ? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi) - : mergeLegacyCommitMessageAiIntoSourceControlAi( - parsed.settings?.sourceControlAi, - legacyCommitMessageAi - ) - const actions = migrateActionPermissionArgs(migratedSourceControlAi.actions) - if (actions !== migratedSourceControlAi.actions) { - migratedSourceControlAi = { ...migratedSourceControlAi, actions } + : rawSourceControlAi + const sourceActions = { + ...sourceBeforeNormalization.launchActionDefaults, + ...sourceBeforeNormalization.actions + } + const actions = migrateActionPermissionArgs(sourceActions) + if (actions !== sourceActions) { markNeedsSave() } + const sourceWithActions = { ...sourceBeforeNormalization, actions } + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceWithActions + : mergeLegacyCommitMessageAiIntoSourceControlAi(sourceWithActions, legacyCommitMessageAi) migratedExternalVisibility.repos = migratedExternalVisibility.repos.map((repo) => { const actionOverrides = migrateActionPermissionArgs(repo.sourceControlAi?.actionOverrides) if (actionOverrides === repo.sourceControlAi?.actionOverrides) { diff --git a/src/main/persistence/persistence-load-repair-durability.test.ts b/src/main/persistence/persistence-load-repair-durability.test.ts index f29931bf256..01653a5afef 100644 --- a/src/main/persistence/persistence-load-repair-durability.test.ts +++ b/src/main/persistence/persistence-load-repair-durability.test.ts @@ -1,7 +1,12 @@ -import * as permissionLift from '../../shared/tui-agent-permission-lift' +import { projectSourceControlActionLaunch } from '../../shared/source-control-action-launch-preferences' +import { resolveSourceControlActionRecipe } from '../../shared/source-control-ai-operation-policy' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { resolveAgentLaunchCommand } from '../../shared/tui-agent-launch-command' +import { isTuiAgent } from '../../shared/tui-agent-config' +import * as reconciliation from '../../shared/agent-choice-reconciliation' import * as terminalPreparation from './loading-store/prepare-loaded-terminal-settings' import * as domainWrites from './profile-state/profile-state-domain-writes' -import * as permissionArgs from '../../shared/agent-permission-args' +import * as choiceArgs from '../../shared/agent-choice-args' import * as shell from '../../shared/tui-agent-startup-shell' import { agentPermissionChoices } from '../../shared/tui-agent-permission-choice' import { @@ -90,6 +95,72 @@ describe('load-time normalization durability', () => { rmSync(testState.dir, { recursive: true, force: true }) }) + it.each(['claude', 'codex'] as const)( + 'retains independent %s effort when lifting a model through load, save and reopen', + async (agent) => { + const settled = await settledProfile() + await closeTestStores() + const source = settled.settings.sourceControlAi + if (!source) { + throw new Error('Missing settled Source Control settings') + } + const model = agent === 'claude' ? 'opus' : 'gpt-5.4' + const rawModel = `--model ${model}` + const rawEffort = agent === 'claude' ? '--effort low' : '-c model_reasoning_effort=low' + const choices = { agentId: agent, options: { effort: 'high' } } + settled.settings.sourceControlAi = { + ...source, + actions: { + fixChecks: { + agentId: agent, + agentArgs: `${rawModel} ${rawEffort}`, + launchPreferences: choices + } + } + } + settled.repos = [ + { + id: 'independent-effort', + displayName: 'recipe', + badgeColor: '', + addedAt: 0, + path: '/tmp/recipe', + sourceControlAi: { + actionOverrides: { + fixChecks: { agentId: agent, agentArgs: rawModel, launchPreferences: choices } + } + } + } + ] + writeDataFile(settled) + const expected = { + agentId: agent, + modelSelection: { base: 'default', options: { effort: { agentId: agent, value: 'high' } } } + } + const assertChoices = (store: ReturnType) => { + expect(store.getSettings().sourceControlAi?.actions?.fixChecks).toMatchObject({ + agentArgs: rawEffort, + launchPreferences: expected, + argumentChoices: { syntaxAgent: agent, nodes: [{ at: 0, text: rawModel }] } + }) + expect(store.getRepos()[0]?.sourceControlAi?.actionOverrides?.fixChecks).toMatchObject({ + agentArgs: '', + launchPreferences: expected, + argumentChoices: { syntaxAgent: agent, nodes: [{ at: 0, text: rawModel }] } + }) + } + const store = createStore() + assertChoices(store) + store.flush() + await closeTestStores() + const persisted = readDataFile() + const reopened = createStore() + assertChoices(reopened) + reopened.flush() + expect(readDataFile()).toEqual(persisted) + } + ) + const loadWork: number[][] = [] it.each([32, 320])( 'migrates %i owned rows through actual typed-profile load, flush and no-op reopen', @@ -112,11 +183,19 @@ describe('load-time normalization durability', () => { actions: { fixChecks: { agentId: 'claude', - agentArgs: '--dangerously-skip-permissions --verbose', - launchPreferences: { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } } + agentArgs: + '--model retired-unlisted-id --effort high --dangerously-skip-permissions --verbose' }, resolveComments: { agentId: 'codex', agentArgs: literal }, - resolveConflicts: { agentId: 'gemini', agentArgs: '--yolo' }, + resolveConflicts: { + agentId: 'claude', + agentArgs: '--effort high', + launchPreferences: { + agentId: 'codex', + permissionMode: 'ask', + modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } } + } + }, fixPushFailure: { agentArgs: ' --dangerously-skip-permissions ' }, fixCommitFailure: conflict, commitMessage: { agentId: 'claude', agentArgs: '--dangerously-skip-permissions' } @@ -133,17 +212,18 @@ describe('load-time normalization durability', () => { fixChecks: { agentId: i % 2 ? 'claude' : 'codex', agentArgs: (i % 2 - ? '--dangerously-skip-permissions' - : '--dangerously-bypass-approvals-and-sandbox' + ? '--model retired-unlisted-id --effort high --dangerously-skip-permissions' + : '-c model=retired-unlisted-id -c model_reasoning_effort=high --dangerously-bypass-approvals-and-sandbox' ).padEnd(64) }, - resolveComments: { agentArgs: ' --dangerously-bypass-approvals-and-sandbox ' }, + resolveComments: { agentArgs: ' --model untouched --effort high ' }, + resolveConflicts: { agentId: 'custom', agentArgs: ' --model untouched --effort high ' }, fixPushFailure: { agentId: null, agentArgs: literal } } } })) - const lift = vi.spyOn(permissionLift, 'liftAgentPermissionArgs') - const cut = vi.spyOn(permissionArgs, 'permissionRecipeCut') + const lift = vi.spyOn(reconciliation, 'reconcileAgentChoiceArgsDetailed') + const cut = vi.spyOn(choiceArgs, 'analyzeAgentChoiceArgs') const tokenize = vi.spyOn(shell, 'tokenizeStartupCommand') const prepare = terminalPreparation.prepareLoadedTerminalSettings const marks = vi.fn() @@ -156,7 +236,8 @@ describe('load-time normalization durability', () => { ) writeDataFile(settled) const store = createStore() - expect(lift).toHaveBeenCalledTimes(n + 4) + // Four global raw recipes plus validation of their six newly canonical terms. + expect(lift).toHaveBeenCalledTimes(n + 10) const work = [ cut.mock.calls.length, tokenize.mock.calls.length, @@ -185,13 +266,28 @@ describe('load-time normalization durability', () => { expect(store.getSettings().sourceControlAi?.actions?.fixChecks).toMatchObject({ agentArgs: '--verbose', + argumentChoices: { + syntaxAgent: 'claude', + nodes: [ + { at: 0, text: '--model retired-unlisted-id' }, + { at: 0, text: '--effort high' } + ] + }, launchPreferences: { agentId: 'claude', - model: 'sonnet', - options: { effort: 'high' }, permissionMode: 'full-access' } }) + if (n === 32) { + const failure = vi + .spyOn(domainWrites, 'writeProfileStateDomains') + .mockImplementationOnce(() => { + throw new Error('disk refused') + }) + expect(() => store.flushOrThrow()).toThrow('disk refused') + expect(readDataFile()).toEqual(settled) + failure.mockRestore() + } store.flush() await closeTestStores() marks.mockClear() @@ -200,18 +296,84 @@ describe('load-time normalization durability', () => { for (const row of reopened.getRepos()) { expect(row.sourceControlAi?.actionOverrides?.fixChecks).toMatchObject({ agentArgs: '', + argumentChoices: { + syntaxAgent: row.sourceControlAi?.actionOverrides?.fixChecks?.agentId, + nodes: [ + { + at: 0, + text: + row.sourceControlAi?.actionOverrides?.fixChecks?.agentId === 'claude' + ? '--model retired-unlisted-id' + : '-c model=retired-unlisted-id' + }, + { + at: 0, + text: + row.sourceControlAi?.actionOverrides?.fixChecks?.agentId === 'claude' + ? '--effort high' + : '-c model_reasoning_effort=high' + } + ] + }, launchPreferences: { agentId: row.sourceControlAi?.actionOverrides?.fixChecks?.agentId, permissionMode: 'full-access' } }) } + for (const row of reopened.getRepos().slice(0, 2)) { + const recipe = resolveSourceControlActionRecipe({ + settings: reopened.getSettings(), + repo: row, + actionId: 'fixChecks' + }) + if (!isTuiAgent(recipe.agentId)) { + throw new Error('Missing concrete migrated agent') + } + const projection = projectSourceControlActionLaunch(recipe, recipe.agentId, { + basePrompt: 'Fix checks' + }) + const launch = resolveAgentLaunchCommand({ + ...resolveAgentStartupPlanInputs({ + agent: recipe.agentId, + settings: reopened.getSettings(), + platform: 'linux', + isRemote: false, + shell: 'posix', + agentArgs: projection.agentArgs, + sessionOptions: projection.sessionOptions + }), + shell: 'posix' + }) + expect(launch.ok).toBe(true) + if (!launch.ok) { + throw new Error(launch.error) + } + expect(launch.appliedSessionOptions).toEqual({ + model: 'retired-unlisted-id', + effort: 'high', + permissionMode: 'full-access' + }) + expect(launch.command).toBe( + recipe.agentId === 'claude' + ? "claude '--model' 'retired-unlisted-id' '--effort' 'high' '--dangerously-skip-permissions'" + : "codex '-m' 'retired-unlisted-id' '-c' 'model_reasoning_effort=high' '--dangerously-bypass-approvals-and-sandbox'" + ) + } const write = vi.spyOn(domainWrites, 'writeProfileStateDomains') const actions = reopened.getSettings().sourceControlAi?.actions expect(actions?.fixChecks?.launchPreferences?.permissionMode).toBe('full-access') expect(actions?.resolveConflicts).toMatchObject({ agentArgs: '', - launchPreferences: { agentId: 'gemini', permissionMode: 'full-access' } + launchPreferences: { + agentId: 'codex', + permissionMode: 'ask', + modelSelection: { + base: 'inherit', + model: { agentId: 'claude', value: null } + } + }, + argumentChoices: { syntaxAgent: 'claude', nodes: [{ at: 0, text: '--effort high' }] } }) expect(actions?.resolveComments?.agentArgs).toBe(literal) expect(actions?.fixPushFailure?.agentArgs).toBe(' --dangerously-skip-permissions ') @@ -221,6 +383,9 @@ describe('load-time normalization durability', () => { expect(rows?.resolveComments).toEqual( settled.repos[0]?.sourceControlAi?.actionOverrides?.resolveComments ) + expect(rows?.resolveConflicts).toEqual( + settled.repos[0]?.sourceControlAi?.actionOverrides?.resolveConflicts + ) expect(rows?.fixPushFailure).toEqual( settled.repos[0]?.sourceControlAi?.actionOverrides?.fixPushFailure ) @@ -238,6 +403,90 @@ describe('load-time normalization durability', () => { } ) + it('durably demotes a well-shaped current term whose effort is no longer supported', async () => { + const settled = await settledProfile() + await closeTestStores() + settled.settings.sourceControlAi!.actions!.fixChecks = { + agentId: 'codex', + agentArgs: '--search', + argumentChoices: { + syntaxAgent: 'codex', + nodes: [{ at: 0, text: '-c model_reasoning_effort=retired' }] + } + } + settled.repos = [ + { + id: 'retired-effort', + displayName: 'recipe', + badgeColor: '', + addedAt: 0, + path: '/tmp/recipe', + sourceControlAi: { + actionOverrides: { fixChecks: settled.settings.sourceControlAi!.actions!.fixChecks } + } + } + ] + const mark = vi.fn() + terminalPreparation.prepareLoadedTerminalSettings(settled, mark) + expect(mark).toHaveBeenCalled() + writeDataFile(settled) + const persisted = await reloadAndSettleWrites() + expect(persisted.settings.sourceControlAi?.actions?.fixChecks).toMatchObject({ + agentArgs: ' -c model_reasoning_effort=retired --search' + }) + expect(persisted.settings.sourceControlAi?.actions?.fixChecks?.argumentChoices).toBeUndefined() + expect(persisted.repos[0].sourceControlAi?.actionOverrides?.fixChecks).toEqual( + persisted.settings.sourceControlAi?.actions?.fixChecks + ) + await closeTestStores() + expect(createStore().getSettings().sourceControlAi?.actions?.fixChecks).toEqual( + persisted.settings.sourceControlAi?.actions?.fixChecks + ) + }) + + it.each(['document', 'selection'] as const)( + 'rejects malformed %s writes and preserves stored data at the load error owner', + async (kind) => { + const settled = await settledProfile() + const invalid = + kind === 'document' + ? { + agentId: 'claude' as const, + agentArgs: '', + argumentChoices: { + syntaxAgent: 'claude' as const, + nodes: [{ at: 99, text: '--model opus' }] + } + } + : { + agentId: 'claude' as const, + launchPreferences: { + agentId: 'claude' as const, + modelSelection: { + base: 'inherit' as const, + model: { agentId: 'claude' as const, value: 'x'.repeat(513) } + } + } + } + const store = createStore() + const source = store.getSettings().sourceControlAi! + expect(() => + store.updateSettings({ sourceControlAi: { ...source, actions: { fixChecks: invalid } } }) + ).toThrow( + kind === 'document' + ? 'Invalid action argument choice position' + : 'Invalid action model choice' + ) + expect(store.getSettings().sourceControlAi).toEqual(source) + await closeTestStores() + settled.settings.sourceControlAi = { ...source, actions: { fixChecks: invalid } } + writeDataFile(settled) + const raw = readDataFile() + expect(() => createStore()).toThrow('Failed to load imported profile state') + expect(readDataFile()).toEqual(raw) + } + ) + it('persists the legacy search-tab explorer view repair', async () => { const settled = await settledProfile() delete (settled.ui as Record).rightSidebarExplorerView diff --git a/src/main/runtime/rpc/methods/repo-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/repo-rpc.test-fixture.ts new file mode 100644 index 00000000000..672e64cb1cb --- /dev/null +++ b/src/main/runtime/rpc/methods/repo-rpc.test-fixture.ts @@ -0,0 +1,18 @@ +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { REPO_METHODS } from './repo' + +export function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +export function repoDispatcher( + runtime: Pick & Partial +) { + return new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These RPC cases supply every runtime method their handlers and envelope access. + runtime: runtime as OrcaRuntimeService, + methods: REPO_METHODS + }) +} diff --git a/src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts b/src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts new file mode 100644 index 00000000000..10bdbc9a7dd --- /dev/null +++ b/src/main/runtime/rpc/methods/repo-source-control-ai-publication.test.ts @@ -0,0 +1,63 @@ +import '../unused-default-rpc-methods.test-fixture' +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { makeRequest, repoDispatcher } from './repo-rpc.test-fixture' + +describe('repo RPC methods', () => { + it('preserves the canonical action document through update, list and show envelopes', async () => { + const sourceControlAi = { + actionOverrides: { + fixChecks: { + agentId: 'codex' as const, + agentArgs: '--search', + launchPreferences: { + agentId: 'claude' as const, + permissionMode: 'ask' as const, + modelSelection: { + base: 'inherit' as const, + model: { agentId: 'codex' as const, value: null } + } + }, + argumentChoices: { + syntaxAgent: 'codex' as const, + nodes: [{ at: 0, text: '-c model=o3' }] + } + } + } + } + const repo = { + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '', + addedAt: 0, + sourceControlAi + } + const updateRepo = vi.fn().mockResolvedValue(repo) + const dispatcher = repoDispatcher({ + getRuntimeId: () => 'test-runtime', + updateRepo, + listVisibleRepos: () => [repo], + showRepo: async () => repo + }) + expect( + await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'id:repo-1', + updates: { sourceControlAi } + }) + ) + ).toMatchObject({ ok: true, result: { repo: { sourceControlAi } } }) + expect(updateRepo).toHaveBeenCalledExactlyOnceWith('id:repo-1', { sourceControlAi }) + expect(await dispatcher.dispatch(makeRequest('repo.list'))).toMatchObject({ + ok: true, + result: { repos: [{ sourceControlAi }] } + }) + expect( + await dispatcher.dispatch(makeRequest('repo.show', { repo: 'id:repo-1' })) + ).toMatchObject({ + ok: true, + result: { repo: { sourceControlAi } } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index c86ba069849..444c11ff385 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -1,9 +1,9 @@ import '../unused-default-rpc-methods.test-fixture' import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' -import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { REPO_METHODS } from './repo' +import { makeRequest, repoDispatcher } from './repo-rpc.test-fixture' import { NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, @@ -14,20 +14,6 @@ import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runti import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits' import { SSH_TARGET_MANAGEMENT_RUNTIME_CAPABILITY } from '../../../../shared/ssh-target-management' -function makeRequest(method: string, params?: unknown): RpcRequest { - return { id: 'req-1', authToken: 'tok', method, params } -} - -function repoDispatcher( - runtime: Pick & Partial -) { - return new RpcDispatcher({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These RPC cases supply every runtime method their handlers and envelope access. - runtime: runtime as OrcaRuntimeService, - methods: REPO_METHODS - }) -} - describe('repo RPC methods', () => { it('advertises qualified-ref support from hosts and native remote clients', () => { expect(RUNTIME_CAPABILITIES).toContain(REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY) diff --git a/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts b/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts index 4faa1bcb261..7b28ea7f778 100644 --- a/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts +++ b/src/main/runtime/runtime-client-settings-source-control-projection.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { RuntimeClientSettingsController } from './runtime-client-settings' import { createGlobalSettingsFixture } from '../../shared/global-settings-test-fixture' import type { GlobalSettings } from '../../shared/global-settings-types' +import { SourceControlAiSettings } from '../../shared/rpc-contract/git-params' +import { liftActionPermissionArgs } from '../../shared/source-control-action-permission-lift' import { getDefaultSourceControlAiSettings } from '../../shared/source-control-ai' // settings.get is an allowlist: omitted per-action agent recipes look unsaved to paired AI buttons. @@ -16,15 +18,28 @@ function hostSettings(sourceControlAi: GlobalSettings['sourceControlAi']): Parti describe('RuntimeClientSettingsController source control launch recipes', () => { it('publishes the recipe saved for a launch action', () => { - const projected = projectionOf( - hostSettings({ - ...getDefaultSourceControlAiSettings(), - actions: { fixChecks: { agentId: 'codex', agentArgs: '--fast' } } - }) + const recipe = liftActionPermissionArgs({ + agentId: 'codex' as const, + agentArgs: '-c model=o3 --fast', + launchPreferences: { + agentId: 'claude', + permissionMode: 'ask', + modelSelection: { + base: 'inherit', + options: { effort: { agentId: 'codex', value: null } } + } + } + }) + const source = { ...getDefaultSourceControlAiSettings(), actions: { fixChecks: recipe } } + expect(SourceControlAiSettings.parse(source).actions?.fixChecks).toEqual(recipe) + const projected = projectionOf(hostSettings(source)) + expect(projected.sourceControlAi.actions.fixChecks?.launchPreferences).toEqual( + recipe.launchPreferences ) expect(projected.sourceControlAi.actions.fixChecks).toMatchObject({ agentId: 'codex', - agentArgs: '--fast' + agentArgs: '--fast', + argumentChoices: { syntaxAgent: 'codex', nodes: [{ at: 0, text: '-c model=o3' }] } }) }) diff --git a/src/main/runtime/runtime-project-host-setup-publication.test.ts b/src/main/runtime/runtime-project-host-setup-publication.test.ts index e0f05ab6711..a7130b1b1b4 100644 --- a/src/main/runtime/runtime-project-host-setup-publication.test.ts +++ b/src/main/runtime/runtime-project-host-setup-publication.test.ts @@ -13,7 +13,24 @@ const repo: Repo = { displayName: 'app', badgeColor: 'blue', addedAt: 1, - kind: 'git' + kind: 'git', + sourceControlAi: { + actionOverrides: { + fixChecks: { + agentId: 'claude', + agentArgs: '', + launchPreferences: { + agentId: 'codex', + permissionMode: 'ask', + modelSelection: { + base: 'inherit', + model: { agentId: 'claude', value: null } + } + }, + argumentChoices: { syntaxAgent: 'claude', nodes: [{ at: 0, text: '--effort high' }] } + } + } + } } const projection = projectHostSetupProjectionFromRepos([repo]) const project = projection.projects[0] @@ -55,14 +72,25 @@ function makeController(): { describe('RuntimeProjectHostSetupController publication', () => { it('publishes a created setup to open clients', () => { const { controller, notifyReposChanged } = makeController() - controller.createSetup({ projectId: project.id, hostId: 'local', path: '/srv/app' }) + const result = controller.createSetup({ + projectId: project.id, + hostId: 'local', + path: '/srv/app' + }) + expect(result.setup.sourceControlAi).toEqual(repo.sourceControlAi) + expect(controller.listSetups()).toEqual([setup]) expect(notifyReposChanged).toHaveBeenCalledTimes(1) }) it('publishes a rename without retiring cached worktree scans', async () => { const { controller, notifyReposChanged, invalidateResolvedWorktrees, retired } = makeController() - await controller.updateSetup({ setupId: setup.id, updates: { displayName: 'renamed' } }) + const result = await controller.updateSetup({ + setupId: setup.id, + updates: { displayName: 'renamed' } + }) + expect(result.setup.sourceControlAi).toEqual(repo.sourceControlAi) + expect(result.repo?.sourceControlAi).toEqual(repo.sourceControlAi) expect(notifyReposChanged).toHaveBeenCalledTimes(1) expect(invalidateResolvedWorktrees).toHaveBeenCalled() expect(retired).toEqual([]) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 496f2bfcd69..e60e699d9a9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -1,3 +1,4 @@ +import type { CurrentActionSelection } from '../../../../shared/source-control-action-selection' import React from 'react' import type { RuntimeGitContext } from '@/runtime/runtime-git-client-context' import { @@ -22,6 +23,7 @@ import { translate } from '@/i18n/i18n' export type SourceControlAgentActionDialogProps = { open: boolean initialRecipe?: SourceControlActionRecipe + initialSelection?: CurrentActionSelection onOpenChange: (open: boolean) => void actionId: SourceControlLaunchActionId title: string @@ -127,7 +129,11 @@ function SourceControlAgentActionEditor( agentArgsApply={agentArgsApply} recipe={recipe} commandTemplate={commandTemplate} - savedCommandInputTemplate={savedCommandInputTemplate} + savedCommandInputTemplate={ + props.initialSelection?.current.commandInputTemplate ?? + props.initialRecipe?.commandInputTemplate ?? + savedCommandInputTemplate + } saveLaunchRecipe={saveLaunchRecipe} saveTargetValue={saveTargetValue} saveTargets={saveTargets} diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx index dc1c6e91d1e..63c0f869f9c 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx @@ -1,3 +1,5 @@ +import { sourceControlActionRecipeEntry } from '../../../../shared/source-control-action-selection' +import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' import React, { type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' @@ -47,7 +49,9 @@ vi.mock('../source-control/SourceControlActionVariableChips', () => ({ })) function renderForm( - overrides: Partial> = {} + overrides: Partial< + Omit, 'recipe'> + > & { recipe?: SourceControlActionRecipe } = {} ): string { return renderToStaticMarkup( React.createElement(SourceControlAgentActionDialogForm, { @@ -66,11 +70,6 @@ function renderForm( statusCopy: null, agentArgs: '', agentArgsApply: true, - recipe: { - agentId: overrides.selectedAgent ?? 'codex', - commandInputTemplate: overrides.commandTemplate ?? '{basePrompt}', - ...(overrides.agentArgs !== undefined ? { agentArgs: overrides.agentArgs } : {}) - }, commandTemplate: '{basePrompt}', savedCommandInputTemplate: '{basePrompt}', saveLaunchRecipe: true, @@ -93,7 +92,14 @@ function renderForm( onSaveAgentDefaultChange: () => {}, onCancel: () => {}, onStart: () => {}, - ...overrides + ...overrides, + recipe: sourceControlActionRecipeEntry( + overrides.recipe ?? { + agentId: overrides.selectedAgent ?? 'codex', + commandInputTemplate: overrides.commandTemplate ?? '{basePrompt}', + ...(overrides.agentArgs !== undefined ? { agentArgs: overrides.agentArgs } : {}) + } + ) }) ) } @@ -143,10 +149,34 @@ describe('SourceControlAgentActionDialogForm', () => { ) expect( renderForm({ - recipe: { ...recipe, launchPreferences: { agentId: 'codex', model: 'saved' } }, + recipe: { + ...recipe, + launchPreferences: { + agentId: 'claude', + permissionMode: 'ask', + modelSelection: { + base: 'inherit', + model: { agentId: 'codex', value: 'saved' } + } + } + }, agentArgsApply: false }) ).toContain('Model from Settings: saved') + expect( + renderForm({ + recipe: { + agentId: 'codex', + launchPreferences: { + agentId: 'codex', + modelSelection: { + base: 'default', + options: { effort: { agentId: 'codex', value: 'high' } } + } + } + } + }) + ).toContain('Model from Settings: High') expect( renderForm({ selectedAgent: 'antigravity', diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 7a6ab9bf8ab..028201d557a 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -1,3 +1,4 @@ +import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection' import React from 'react' import { Info, RefreshCw, RotateCcw, Settings, Sparkles, TriangleAlert } from 'lucide-react' import AgentCombobox from '@/components/agent/AgentCombobox' @@ -13,10 +14,7 @@ import { } from '@/components/ui/select' import type { AgentCatalogEntry } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import type { - SourceControlActionRecipe, - SourceControlLaunchActionId -} from '../../../../shared/source-control-ai-actions' +import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' @@ -28,6 +26,7 @@ import { describeSourceControlActionModel } from '../settings/source-control-act import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match' import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result' import { translate } from '@/i18n/i18n' +import { selectedSourceControlActionPreferences } from '../../../../shared/source-control-action-launch-preferences' /** Expose only delivery errors the user can fix before launch. */ export type SourceControlAgentActionDeliveryPlanState = @@ -47,7 +46,7 @@ type SourceControlAgentActionDialogFormProps = { agentArgs: string /** False when the launch would be structured native chat; the field is then absent, not disabled. */ agentArgsApply: boolean - recipe: SourceControlActionRecipe + recipe: SelectedSourceControlActionRecipe commandTemplate: string savedCommandInputTemplate?: string | null saveLaunchRecipe: boolean @@ -120,6 +119,9 @@ export function SourceControlAgentActionDialogForm({ const defaultCommandTemplate = savedCommandInputTemplate ?? '{basePrompt}' const commandTemplateIncludesBasePrompt = commandTemplate.includes('{basePrompt}') const selectedRecipe = recipe + const selectedPreferences = selectedAgent + ? selectedSourceControlActionPreferences(selectedRecipe, selectedAgent) + : undefined const selectedSaveTarget = sourceControlLaunchSaveTargetFromValue(saveTargetValue, repo) // Copy describes only the selected save target, which is the only one start/save writes. const selectedLaunchRecipeAlreadySaved = Boolean( @@ -192,9 +194,9 @@ export function SourceControlAgentActionDialogForm({ {statusCopy}

- ) : selectedAgent && selectedRecipe.launchPreferences?.agentId === selectedAgent ? ( + ) : selectedAgent && selectedPreferences ? (

- {describeSourceControlActionModel(selectedAgent, selectedRecipe.launchPreferences)} + {describeSourceControlActionModel(selectedAgent, selectedPreferences)}

) : null} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index 39d5969f3e5..d28efd0c88f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -8,8 +8,6 @@ import { SourceControlAgentActionDialog } from '../SourceControlAgentActionDialo import { ChecksPanelUpdatedAtMetadata } from '../checks-panel-updated-at-metadata' import { getChecksPanelRefreshErrorBannerLine } from '../github-refresh-error-copy' import { resolveChecksPanelHostedReviewModifierDestination } from '../checks-panel-hosted-review-click-routing' -import { readSourceControlLaunchRecipeAgentId } from '../../../../../shared/source-control-launch-agent-selection' -import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai' import { clearPendingPRCommentAiAck } from '../pr-comments-ai-launch-ack' import { getBrokenChecks } from '../../../../../shared/pr-checks-fix-prompt' import { PRTriageStrip } from './triage-strip' @@ -291,35 +289,6 @@ export function ChecksPanelActiveContent({ promptDelivery="submit-after-ready" launchPlatform={activeSourceControlLaunchPlatform} launchSource={agentComposerState?.launchSource ?? 'task_page'} - savedAgentId={ - agentComposerState - ? readSourceControlLaunchRecipeAgentId( - resolveSourceControlActionRecipe({ - settings, - repo, - actionId: agentComposerState.actionId - }) - ) - : null - } - savedCommandInputTemplate={ - agentComposerState - ? (resolveSourceControlActionRecipe({ - settings, - repo, - actionId: agentComposerState.actionId - }).commandInputTemplate ?? null) - : null - } - savedAgentArgs={ - agentComposerState - ? (resolveSourceControlActionRecipe({ - settings, - repo, - actionId: agentComposerState.actionId - }).agentArgs ?? null) - : null - } onSaveAgentDefault={saveLaunchActionDefault} {...agentComposerState?.launchContext} /> diff --git a/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx index d2f1e55ef3b..55bca07d3c0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/saved-action-delivery.test.tsx @@ -370,7 +370,7 @@ it.each([false, true])( await act(async () => f.inventory.resolve(['claude'])) await waitFor(() => expect(f.composer).toHaveBeenCalledOnce()) expect(launch).not.toHaveBeenCalled() - expect(f.composer.mock.calls[0]?.[0]?.launchContext?.initialRecipe?.agentId).toBe( + expect(f.composer.mock.calls[0]?.[0]?.launchContext?.initialSelection?.current.agentId).toBe( saved ? 'codex' : undefined ) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx index 263983fc314..0244878c569 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-queue.tsx @@ -1,5 +1,5 @@ import { useSavedSourceControlActionStart } from '../use-saved-source-control-action-start' -import { resolveSourceControlActionRecipe } from '../../../../../shared/source-control-ai' +import { captureSourceControlActionSelection } from '../../../../../shared/source-control-ai-operation-policy' import { checksPanelReviewStableKey, clearPendingPRCommentAiAck, @@ -69,7 +69,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { const savedStart = useSavedSourceControlActionStart() const openOrStart = (composer: ChecksAgentComposerState): void => { void savedStart.start(() => { - const recipe = resolveSourceControlActionRecipe({ + const selection = captureSourceControlActionSelection({ settings: model.settings, repo, actionId: composer.actionId @@ -95,7 +95,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { ) } const launchContext = { - initialRecipe: recipe, + initialSelection: selection, worktreeId: activeWorktreeId, groupId: activeWorktreeId, connectionId: model.activeConnectionId, @@ -105,7 +105,7 @@ export function useChecksPanelAiQueue(model: ChecksPanelAiQueueInput) { } return { ...launchContext, - recipe, + selection, basePrompt: composer.prompt, settings: model.settings, repo, diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts index 83eb13c2430..cfa9ab83011 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts @@ -1,3 +1,5 @@ +import { sourceControlActionRecipeEntry } from '../../../../shared/source-control-action-selection' +import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' import type * as RuntimeGitStatusClientModule from '@/runtime/runtime-git-status-client' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -41,12 +43,13 @@ import { import type { Repo } from '../../../../shared/repo-types' function buildArgs( - overrides: Partial[0]> = {} + overrides: Partial[0], 'recipe'>> & { + recipe?: SourceControlActionRecipe + } = {} ): Parameters[0] { return { selectedAgent: 'codex', trimmedCommandInput: 'Fix the bug', - recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '--model gpt-5' }, saveTargetValue: 'none', actionId: 'resolveComments', repoId: null, @@ -61,7 +64,14 @@ function buildArgs( onSaveAgentDefault: mocks.onSaveAgentDefault, onLaunched: mocks.onLaunched, onClose: mocks.onClose, - ...overrides + ...overrides, + recipe: sourceControlActionRecipeEntry( + overrides.recipe ?? { + agentId: 'codex', + commandInputTemplate: '{basePrompt}', + agentArgs: '--model gpt-5' + } + ) } } @@ -197,11 +207,11 @@ describe('runSourceControlAgentActionStart', () => { }) const launchPromise = runSourceControlAgentActionStart(args) - expect(onSaveAgentDefault).toHaveBeenCalledWith( - { type: 'global' }, - 'resolveComments', - args.recipe - ) + expect(onSaveAgentDefault).toHaveBeenCalledWith({ type: 'global' }, 'resolveComments', { + agentId: 'codex', + commandInputTemplate: '{basePrompt}', + agentArgs: '--model gpt-5' + }) expect(onSaveAgentDefault.mock.invocationCallOrder[0]).toBeLessThan( mocks.launchAgentInNewTab.mock.invocationCallOrder[0] ) @@ -492,11 +502,13 @@ it('executes the actual bound namespace while saving the literal default recipe' expect(onStart).toHaveBeenCalledWith( expect.objectContaining({ sessionOptions: { model: 'chosen' } }) ) - expect(mocks.onSaveAgentDefault).toHaveBeenLastCalledWith( - { type: 'global' }, - 'resolveComments', - recipe - ) + expect(mocks.onSaveAgentDefault).toHaveBeenLastCalledWith({ type: 'global' }, 'resolveComments', { + ...recipe, + launchPreferences: { + agentId: 'codex', + modelSelection: { base: 'default', model: { agentId: 'codex', value: 'chosen' } } + } + }) }) it('confirms delivery and closes even while saving remains pending', async () => { @@ -530,13 +542,12 @@ it.each([undefined, '', '--add-dir /extra'])( expect(onStart).toHaveBeenCalledWith( expect.objectContaining({ agentArgs, - sessionOptions: { model: 'saved' }, - sessionOptionsYieldToAgentArgs: true + sessionOptions: { model: 'saved' } }) ) } ) -// Saved to the repository, the copied choice owns the action there, as on every later launch. +// Save keeps the repository's selected Args group on every later launch. it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])( 'persists the complete target for changed or unchanged Customize arguments %s', async (agentArgs) => { @@ -570,10 +581,9 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])( await runSourceControlAgentActionStart(buildArgs({ ...args, actionId: 'fixCommitFailure' })) - expect(shown.launchPreferencesYieldToAgentArgs).toBe(true) + expect(shown.launchPreferencesYieldToAgentArgs).toBeUndefined() expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).toMatchObject({ - agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}`, - sessionOptions: { model: 'gpt-5.6-terra' } + agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}` }) expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).not.toHaveProperty( 'sessionOptionsYieldToAgentArgs' @@ -581,12 +591,11 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])( expect(repo.sourceControlAi?.actionOverrides?.fixCommitFailure).toEqual({ agentId: null, commandInputTemplate: '{basePrompt}', - agentArgs, - launchPreferences: terra + agentArgs }) const next = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' }) const nextOptions = sourceControlActionLaunchOptions(next, 'codex') - expect(nextOptions).toEqual({ sessionOptions: { model: terra.model } }) + expect(nextOptions).toEqual({}) const command = resolveAgentLaunchCommand({ agent: 'codex', cmdOverrides: {}, @@ -601,10 +610,11 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])( yieldToAgentArgs: nextOptions.sessionOptionsYieldToAgentArgs }) }) - expect(command).toMatchObject({ ok: true, appliedSessionOptions: { model: terra.model } }) - if (command.ok) { - expect(command.command).not.toContain(agentArgs.split(' ')[1]) - } + expect(command).toMatchObject({ + ok: true, + appliedSessionOptions: {}, + command: `codex '--model' '${agentArgs.split(' ')[1]}'` + }) expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1) await runSourceControlAgentActionStart( buildArgs({ ...args, recipe: next, actionId: 'fixCommitFailure' }) @@ -612,3 +622,57 @@ it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])( expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1) } ) + +it('saves a foreign resolved literal as current raw and starts from the canonical saved result', async () => { + vi.clearAllMocks() + const base = getDefaultSettings('/tmp') + const settings = { + ...base, + agentPermissionDefault: 'own' as const, + sourceControlAi: { + ...base.sourceControlAi!, + actions: { + fixChecks: { + agentId: 'codex' as const, + agentArgs: '', + argumentChoices: { + syntaxAgent: 'codex' as const, + nodes: [{ at: 0, text: '-c model=o3' }] + } + } + } + } + } + const repo = { + id: 'repo-1', + sourceControlAi: { actionOverrides: { fixChecks: { agentId: 'claude' as const } } } + } + const recipe = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixChecks' }) + const onStart = vi.fn().mockResolvedValue(true) + await expect( + runSourceControlAgentActionStart( + buildArgs({ + actionId: 'fixChecks', + recipe, + settings, + repo, + repoId: 'repo-1', + selectedAgent: 'claude', + saveTargetValue: 'repo', + onStart + }) + ) + ).resolves.toBe(true) + expect(mocks.onSaveAgentDefault).toHaveBeenCalledExactlyOnceWith( + { type: 'repo', repoId: 'repo-1' }, + 'fixChecks', + { + agentId: 'claude', + commandInputTemplate: '{basePrompt}', + agentArgs: '-c model=o3' + } + ) + expect(onStart).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ agent: 'claude', agentArgs: '-c model=o3' }) + ) +}) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 1552457b290..2c73b3dadce 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,3 +1,7 @@ +import { + sourceControlActionRecipeEntry, + type SelectedSourceControlActionRecipe +} from '../../../../shared/source-control-action-selection' import type { AgentLaunchPreferences } from '@/components/settings/use-host-agent-launch-preferences' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' @@ -17,12 +21,11 @@ import { saveSourceControlActionRecipe, type SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' -import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match' import { resolveSourceControlAgentSaveTarget } from './source-control-agent-action-dialog-support' -import { projectSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences' +import { projectSelectedSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences' import { admitSourceControlRecovery } from './source-control-recovery-admission' export type RunSourceControlAgentActionStartArgs = { @@ -30,7 +33,7 @@ export type RunSourceControlAgentActionStartArgs = { trimmedCommandInput: string /** The picker's choice: a deliberate surface stays explicit, a saved replay requests terminal only. */ launchChoice?: AgentLaunchChoice - recipe: SourceControlActionRecipe + recipe: SelectedSourceControlActionRecipe saveTargetValue: string actionId: SourceControlLaunchActionId repoId?: string | null @@ -107,14 +110,13 @@ export async function runSourceControlAgentActionStart({ let launchFailureNotified = false let launchAcceptedNotified = false const saveTarget = resolveSourceControlAgentSaveTarget(saveTargetValue, repoId) - // The dialog edits agent, arguments and prompt; the model stays the saved one for that agent. - // Why: a recipe being saved launches as it will resolve once saved, so the next launch matches. + // Complete only the submitted selection; later parent changes cannot rewrite this launch. let launchRecipe = recipe - let recipeToSave = recipe - if (saveTarget && settings) { + let recipeToSave: SourceControlActionRecipe = recipe + if (saveTarget) { const saved = saveSourceControlActionRecipe({ target: saveTarget, - settings, + settings: settings ?? {}, repo, actionId, recipe @@ -125,17 +127,9 @@ export async function runSourceControlAgentActionStart({ ? saved.sourceControlAi.actions?.[actionId] : saved.update.sourceControlAi?.actionOverrides?.[actionId] ) ?? recipe - launchRecipe = resolveSourceControlActionRecipe({ - settings: - 'sourceControlAi' in saved - ? { ...settings, sourceControlAi: saved.sourceControlAi } - : settings, - repo: - 'update' in saved ? { sourceControlAi: saved.update.sourceControlAi ?? undefined } : repo, - actionId - }) + launchRecipe = sourceControlActionRecipeEntry(recipeToSave) } - const projection = projectSourceControlActionLaunch( + const projection = projectSelectedSourceControlActionLaunch( launchRecipe, selectedAgent, { renderedPrompt: trimmedCommandInput, settings }, @@ -154,7 +148,7 @@ export async function runSourceControlAgentActionStart({ sourceControlActionRecipeSaveMatchesTarget({ actionId, target: saveTarget, - recipe: recipeToSave, + recipe: launchRecipe, settings, repo }) diff --git a/src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx b/src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx new file mode 100644 index 00000000000..4073517451a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-action-dialog.test-fixture.tsx @@ -0,0 +1,205 @@ +import { terminalSessionOptionInputs } from '../../../../shared/tui-agent-launch-command' +import { buildAgentStartupPlan } from '../../../../shared/tui-agent-startup' +import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy' +import { TooltipProvider } from '@/components/ui/tooltip' +import { + SourceControlAgentActionDialog, + type SourceControlAgentActionDialogProps +} from './SourceControlAgentActionDialog' +import type { RunSourceControlAgentActionStartArgs } from './runSourceControlAgentActionStart' +import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' +import type { Repo } from '../../../../shared/repo-types' +import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai' +import { saveSourceControlAiActionRecipeForTarget } from './source-control/ai/recipe-persistence' +import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import { getDefaultSettings } from '../../../../shared/constants' +import { useAppStore } from '@/store' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY as SURFACE } from '../../../../shared/agent-launch-runtime-capability' +import { + STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT +} from '../../../../shared/protocol-version' +import { normalizeSourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' +import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog' +import { + render, + renderHook, + screen, + fireEvent, + waitFor, + type RenderResult, + type RenderHookResult +} from '@testing-library/react' +import { expect, vi, type Mock } from 'vitest' + +export type SaveAction = NonNullable + +type MountedSourceDialog = { + view: RenderResult + mount: () => RenderResult + persist: Mock + onStart: Mock> + props: SourceControlAgentActionDialogProps + repo: Repo +} + +type RenderedSourceDialog = RenderHookResult< + ReturnType, + unknown +> & { + onStart: Mock + onSaveAgentDefault: Mock + launchPreferences: NonNullable +} + +export function mountSourceDialog( + global: SourceControlActionRecipe, + own: SourceControlActionRecipe, + save?: SaveAction, + onStart = vi + .fn>() + .mockResolvedValue(true), + scope: 'global' | 'repo' = 'repo' +): MountedSourceDialog { + const settings = { + ...getDefaultSettings('/tmp/fc1-action'), + defaultTuiAgent: 'claude' as const, + experimentalNativeChat: true, + agentPermissionDefault: 'own' as const + } + settings.sourceControlAi!.actions = { resolveConflicts: global } + const repo = { ...TEST_REPO, sourceControlAi: { actionOverrides: { resolveConflicts: own } } } + useAppStore.setState({ + settings, + repos: [repo], + detectedAgentIds: ['claude', 'codex'], + localDetectedAgentIdsByContext: { host: ['claude', 'codex'] } + }) + setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, SURFACE]) + const persist = vi.fn( + save ?? + (async (target, actionId, recipe) => { + await saveSourceControlAiActionRecipeForTarget({ + getStoreState: useAppStore.getState, + target, + actionId, + recipe, + updateSettings: async (patch) => { + useAppStore.setState((state) => ({ settings: { ...state.settings!, ...patch } })) + }, + updateRepo: async (id, patch) => { + useAppStore.setState((state) => ({ + repos: state.repos.map((row) => + row.id === id + ? { + ...row, + sourceControlAi: normalizeRepoSourceControlAiOverrides(patch.sourceControlAi) + } + : row + ) + })) + return true + } + }) + }) + ) + const props = { + open: true, + onOpenChange: vi.fn(), + actionId: 'resolveConflicts' as const, + title: 'Resolve', + description: '', + baseCommandInput: 'fix', + launchSource: 'source_control_recovery' as const, + repoId: scope === 'repo' ? repo.id : undefined, + connectionId: null, + onStart, + onSaveAgentDefault: persist + } + const mount = () => + render(, { wrapper: TooltipProvider }) + return { view: mount(), mount, persist, onStart, props, repo } +} + +export async function pickDialogAgent(label: string, terminal: boolean) { + await waitFor(() => + expect(screen.getAllByRole('combobox')[0].hasAttribute('disabled')).toBe(false) + ) + fireEvent.click(screen.getAllByRole('combobox')[0]) + if (terminal) { + fireEvent.click(await screen.findByRole('button', { name: `Start ${label} in a terminal` })) + } else { + const row = screen.getAllByRole('option').find((item) => item.textContent?.startsWith(label)) + if (!row) { + throw new Error(`Missing agent row ${label}`) + } + fireEvent.click(row) + } +} + +export function renderDialog( + chat: boolean, + capable = true, + initialRecipe?: SourceControlActionRecipe +): RenderedSourceDialog { + const settings = { ...getDefaultSettings('/tmp/pr-3'), defaultTuiAgent: 'claude' as const } + const launchPreferences = normalizeSourceControlActionRecipe( + { launchPreferences: { agentId: 'codex', model: 'chosen' } }, + true + )!.launchPreferences! + settings.sourceControlAi!.actions = { resolveConflicts: { agentId: null, launchPreferences } } + useAppStore.setState({ + settings: { ...settings, experimentalNativeChat: chat }, + detectedAgentIds: ['claude', 'codex'], + localDetectedAgentIdsByContext: { host: ['claude', 'codex'] } + }) + setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, ...(capable ? [SURFACE] : [])]) + const onStart = vi.fn().mockResolvedValue(true) + const onSaveAgentDefault = vi.fn() + const view = renderHook(() => + useSourceControlAgentActionDialog({ + open: true, + initialRecipe, + onOpenChange: () => {}, + actionId: 'resolveConflicts', + title: '', + description: '', + baseCommandInput: 'fix', + launchSource: 'source_control_recovery', + savedAgentId: null, + onStart, + onSaveAgentDefault + }) + ) + return { ...view, onStart, onSaveAgentDefault, launchPreferences } +} + +export function freshDialogRecipe(repoId: string) { + const state = useAppStore.getState() + return resolveSourceControlActionRecipe({ + settings: state.settings, + repo: state.repos.find((row) => row.id === repoId), + actionId: 'resolveConflicts' + }) +} + +export async function pickDialogSaveTarget(label: string) { + fireEvent.click(screen.getAllByRole('combobox')[1]) + fireEvent.click(await screen.findByRole('option', { name: label })) +} + +export function submittedActionStartupPlan( + submitted: Parameters>[0] +) { + const { agent, agentArgs, sessionOptions } = submitted + return buildAgentStartupPlan({ + agent, + prompt: 'fix', + platform: 'darwin', + shell: 'posix', + cmdOverrides: {}, + agentArgs, + ...terminalSessionOptionInputs({ agent, agentArgs, sessionOptions, shell: 'posix' }) + }) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts index 1a17b749984..334642807ed 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.test.ts @@ -3,6 +3,7 @@ import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match' +import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy' function settings(): GlobalSettings { const base = getDefaultSettings('/tmp') @@ -24,6 +25,28 @@ function settings(): GlobalSettings { } describe('sourceControlActionRecipeMatchesTarget', () => { + it('compares the same live document after trimming its residual and gaps', () => { + const current = settings() + const recipe = { + agentId: 'codex' as const, + agentArgs: '', + argumentChoices: { syntaxAgent: 'codex' as const, nodes: [{ at: 0, text: '-c model=o3' }] } + } + current.sourceControlAi!.actions!.resolveConflicts = { + ...recipe, + agentArgs: ' ', + argumentChoices: { ...recipe.argumentChoices, nodes: [{ at: 3, text: '-c model=o3' }] } + } + expect( + sourceControlActionRecipeMatchesTarget({ + actionId: 'resolveConflicts', + target: { type: 'global' }, + recipe, + settings: current + }) + ).toBe(true) + }) + it('returns true when the launch recipe matches the global saved recipe', () => { expect( sourceControlActionRecipeMatchesTarget({ @@ -190,4 +213,50 @@ it('distinguishes inheriting arguments from an explicit clear when deciding whet recipe: { ...recipe, agentArgs: '' } }) ).toBe(false) + const repo = { + sourceControlAi: { + actionOverrides: { + fixChecks: { + launchPreferences: { agentId: 'codex' as const, permissionMode: 'ask' as const } + } + } + } + } + base.sourceControlAi!.actions = { + fixChecks: { + ...recipe, + launchPreferences: { + agentId: 'codex', + modelSelection: { base: 'default', model: { agentId: 'codex', value: 'o3' } } + } + } + } + const selected = resolveSourceControlActionRecipe({ settings: base, repo, actionId: 'fixChecks' }) + const changed = { + ...base, + sourceControlAi: { + ...base.sourceControlAi!, + actions: { + fixChecks: { + ...recipe, + launchPreferences: { + agentId: 'codex' as const, + modelSelection: { + base: 'default' as const, + model: { agentId: 'codex' as const, value: 'gpt-5.4' } + } + } + } + } + } + } + expect( + sourceControlActionRecipeMatchesTarget({ + actionId: 'fixChecks', + target: { type: 'repo', repoId: 'r' }, + settings: changed, + repo, + recipe: selected + }) + ).toBe(false) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts index c63b1da99f4..a6428b944bb 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts @@ -1,4 +1,6 @@ +import { sourceControlActionRecipeEntry } from '../../../../shared/source-control-action-selection' import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec' +import { trimActionArgumentDocument } from '../../../../shared/source-control-action-argument-choices' import { normalizeRepoSourceControlAiOverrides, normalizeSourceControlAiSettings, @@ -21,6 +23,7 @@ type NormalizedSourceControlActionRecipe = { commandInputTemplate: string agentArgs: string | undefined launchPreferences: string + argumentChoices: string surface: string } @@ -28,31 +31,46 @@ type PersistedSourceControlActionRecipe = { agentId?: SourceControlActionRecipe['agentId'] commandInputTemplate?: string | null agentArgs?: string | null + argumentChoices?: SourceControlActionRecipe['argumentChoices'] launchPreferences?: SourceControlActionRecipe['launchPreferences'] surface?: SourceControlActionRecipe['surface'] + resolvedChoiceSources?: SourceControlActionRecipe['resolvedChoiceSources'] } function normalizeSourceControlActionRecipeForComparison( actionId: SourceControlActionId, recipe: PersistedSourceControlActionRecipe | null | undefined ): NormalizedSourceControlActionRecipe { + const document = trimActionArgumentDocument({ + agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs : undefined, + argumentChoices: recipe?.argumentChoices + }) + const { model: selection, permission } = sourceControlActionRecipeEntry({ + ...recipe, + agentArgs: document.agentArgs, + argumentChoices: document.argumentChoices, + commandInputTemplate: recipe?.commandInputTemplate ?? undefined + }).resolvedChoiceSources return { agentId: recipe?.agentId ?? null, commandInputTemplate: typeof recipe?.commandInputTemplate === 'string' ? recipe.commandInputTemplate.trim() : DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId], - agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : undefined, - launchPreferences: recipe?.launchPreferences - ? JSON.stringify([ - recipe.launchPreferences.agentId, - recipe.launchPreferences.model ?? null, - Object.entries(recipe.launchPreferences.options ?? {}).sort(([a], [b]) => - a.localeCompare(b) - ), - recipe.launchPreferences.permissionMode ?? null - ]) - : '', + agentArgs: document.agentArgs, + argumentChoices: JSON.stringify(document.argumentChoices ?? null), + launchPreferences: + permission || selection + ? JSON.stringify([ + permission?.agentId ?? null, + selection?.base ?? null, + selection?.model ? [selection.model.agentId, selection.model.value] : null, + Object.entries(selection?.options ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => [key, entry.agentId, entry.value]), + permission?.permissionMode ?? null + ]) + : '', surface: recipe?.surface ?? '' } } @@ -66,6 +84,7 @@ function sourceControlActionRecipesMatch( left.commandInputTemplate === right.commandInputTemplate && left.agentArgs === right.agentArgs && left.launchPreferences === right.launchPreferences && + left.argumentChoices === right.argumentChoices && left.surface === right.surface ) } diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts index 45708a54da5..bc54d5c4ffa 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts @@ -1,7 +1,7 @@ +import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection' import type { getAgentCatalog } from '@/lib/agent-catalog' import type { useAppStore } from '@/store' import type { useRepoById } from '@/store/selectors' -import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan' import type { AgentComboboxSurfaceRows } from '@/components/agent/agent-combobox-surface-items' @@ -24,7 +24,7 @@ export type UseSourceControlAgentActionDialogResult = { agentArgs: string /** False when this launch would be a structured native chat session, which reads no CLI arguments. */ agentArgsApply: boolean - recipe: SourceControlActionRecipe + recipe: SelectedSourceControlActionRecipe commandTemplate: string saveLaunchRecipe: boolean saveTargetValue: string diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 844e2818504..2766a96e3e1 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -1,3 +1,4 @@ +import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control-action-selection' import { ensureAgentLaunchPreferences } from '@/components/settings/use-host-agent-launch-preferences' import { AgentLaunchChoiceCancelled, @@ -20,11 +21,8 @@ import { readSourceControlLaunchRecipeAgentId } from '../../../../../../shared/source-control-launch-agent-selection' import { isTuiAgentEnabled } from '../../../../../../shared/tui-agent-selection' -import { projectSourceControlActionLaunch } from '../../../../../../shared/source-control-action-launch-preferences' -import type { - SourceControlActionRecipe, - SourceControlLaunchActionId -} from '../../../../../../shared/source-control-ai-actions' +import { projectSelectedSourceControlActionLaunch } from '../../../../../../shared/source-control-action-launch-preferences' +import type { SourceControlLaunchActionId } from '../../../../../../shared/source-control-ai-actions' import { buildSourceControlRecoveryAgentCommandInput } from '../../../../../../shared/source-control-recovery-agent-command' import { translate } from '@/i18n/i18n' import { agentChoiceMayOpenAsChat } from '@/lib/host-chat-program' @@ -104,7 +102,9 @@ export async function launchSourceControlRecoveryAgentWithDefault({ actionId: SourceControlLaunchActionId basePrompt: string | null promptOverride?: string - getLaunchActionRecipe: (actionId: SourceControlLaunchActionId) => SourceControlActionRecipe + getLaunchActionRecipe: ( + actionId: SourceControlLaunchActionId + ) => SelectedSourceControlActionRecipe getStoreState: () => SourceControlAiLaunchStoreSnapshot copy: SourceControlRecoveryLaunchCopy }): Promise { @@ -116,6 +116,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({ return false } + const savedRecipe = getLaunchActionRecipe(actionId) const workspace = worktreeLaunchWorkspace(activeWorktreeId) const inventory = await Promise.all([ acquireAgentLaunchChoices(workspace, () => @@ -137,7 +138,6 @@ export async function launchSourceControlRecoveryAgentWithDefault({ const [choices, preferences] = inventory const detectedAgents = [...(choices.detected ?? [])] const store = getStoreState() - const savedRecipe = getLaunchActionRecipe(actionId) if (!basePrompt) { toast.error(copy.promptUnavailable) return false @@ -176,10 +176,14 @@ export async function launchSourceControlRecoveryAgentWithDefault({ toast.error(copy.noEnabledAgent) return false } - const { commandInput, ...projection } = projectSourceControlActionLaunch(savedRecipe, agent, { - renderedPrompt: prompt, - settings: store.settings - }) + const { commandInput, ...projection } = projectSelectedSourceControlActionLaunch( + savedRecipe, + agent, + { + renderedPrompt: prompt, + settings: store.settings + } + ) const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), agent, diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx index e7f74ab7295..72340e814a9 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.operation-recovery.test.tsx @@ -1,3 +1,4 @@ +import { sourceControlActionRecipeEntry } from '../../../../../../shared/source-control-action-selection' import { runtimeStatusEntry } from '@/runtime/server-layout-frames.test-fixture' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' @@ -284,11 +285,12 @@ describe('AI operation recovery composer', () => { sourceRepoConnectionId: null, actionId: 'fixCommitFailure', basePrompt: 'fix', - getLaunchActionRecipe: () => ({ - agentId: null, - agentArgs: '--model chosen', - commandInputTemplate: 'Recovery: {basePrompt}' - }), + getLaunchActionRecipe: () => + sourceControlActionRecipeEntry({ + agentId: null, + agentArgs: '--model chosen', + commandInputTemplate: 'Recovery: {basePrompt}' + }), getStoreState: useAppStore.getState, copy: getDefaultSourceControlRecoveryLaunchCopy('commit') }) diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts index 4d9d2e11c75..57d1c7e37ea 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-ai.ts @@ -1,3 +1,5 @@ +import { captureSourceControlActionSelection } from '../../../../../../shared/source-control-ai-operation-policy' +import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control-action-selection' import { useSavedSourceControlActionStart } from '../../use-saved-source-control-action-start' import type { SourceControlAgentActionDialogProps } from '../../SourceControlAgentActionDialog' import { useCallback, useMemo, useState } from 'react' @@ -108,7 +110,7 @@ export function useSourceControlAi({ }, [activeRepo, settings, sourceControlAiDiscoveryHostKey]) const getLaunchActionRecipe = useCallback( - (actionId: SourceControlLaunchActionId): SourceControlActionRecipe => + (actionId: SourceControlLaunchActionId): SelectedSourceControlActionRecipe => resolveSourceControlActionRecipe({ settings, repo: activeRepo, actionId }), [activeRepo, settings] ) @@ -171,7 +173,11 @@ export function useSourceControlAi({ worktreePath, onAdmitted: async (recoveryContext, signal) => { const editor = { - initialRecipe: getLaunchActionRecipe('resolveConflicts'), + initialSelection: captureSourceControlActionSelection({ + settings, + repo: activeRepo, + actionId: 'resolveConflicts' + }), recoveryContext, baseCommandInput: resolveConflictsPrompt, worktreeId: activeWorktreeId, @@ -181,7 +187,7 @@ export function useSourceControlAi({ launchPlatform: activeSourceControlLaunchPlatform } await savedStart.start({ - recipe: editor.initialRecipe, + selection: editor.initialSelection, basePrompt: resolveConflictsPrompt, ...editor, actionId: 'resolveConflicts', diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts index e9268ed12b6..47750626667 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/use-recovery-ai.ts @@ -1,8 +1,6 @@ +import type { SelectedSourceControlActionRecipe } from '../../../../../../shared/source-control-action-selection' import { useCallback, useMemo, useState } from 'react' -import type { - SourceControlActionRecipe, - SourceControlLaunchActionId -} from '../../../../../../shared/source-control-ai-actions' +import type { SourceControlLaunchActionId } from '../../../../../../shared/source-control-ai-actions' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { buildFixCommitFailurePrompt } from './prompts' import { summarizeCommitFailure } from '../commit/commit-failure-summary' @@ -22,7 +20,9 @@ type SourceControlRecoveryAiParams = { commitError: string | null pushRecoveryPrompt: string | null stagedEntries: Pick[] - getLaunchActionRecipe: (actionId: SourceControlLaunchActionId) => SourceControlActionRecipe + getLaunchActionRecipe: ( + actionId: SourceControlLaunchActionId + ) => SelectedSourceControlActionRecipe getStoreState: () => SourceControlAiStoreSnapshot } diff --git a/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts b/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts index d8c81bf71fe..2ef3c139e20 100644 --- a/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts +++ b/src/renderer/src/components/right-sidebar/use-saved-source-control-action-start.ts @@ -1,3 +1,7 @@ +import { + selectSourceControlActionRecipe, + type CurrentActionSelection +} from '../../../../shared/source-control-action-selection' import { AgentLaunchChoiceCancelled, acquireAgentLaunchChoices @@ -13,7 +17,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { ensureDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory' import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { projectSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences' +import { projectSelectedSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences' import { sourceControlLaunchAppliesAgentArgs } from './source-control-launch-agent-args-applicability' import { buildSourceControlAgentDeliveryPlan } from './buildSourceControlAgentDeliveryPlan' import { @@ -24,8 +28,9 @@ import { /** Primary events own saved starts; opening an editor never authorizes execution. */ type SavedStartArgs = Omit< RunSourceControlAgentActionStartArgs, - 'selectedAgent' | 'trimmedCommandInput' | 'saveTargetValue' + 'selectedAgent' | 'trimmedCommandInput' | 'saveTargetValue' | 'recipe' > & { + selection: CurrentActionSelection basePrompt: string connectionId?: string | null onFallback: () => void @@ -52,13 +57,14 @@ export function useSavedSourceControlActionStart() { } signal?.throwIfAborted() const store = useAppStore.getState() - const { recipe, worktreeId } = args + const { worktreeId } = args + const recipe = selectSourceControlActionRecipe(args.selection) const agent = recipe.agentId if (!isTuiAgent(agent) || !worktreeId) { onFallback() return } - const projection = projectSourceControlActionLaunch(recipe, agent, { + const projection = projectSelectedSourceControlActionLaunch(recipe, agent, { basePrompt: args.basePrompt, settings: args.settings }) @@ -107,6 +113,7 @@ export function useSavedSourceControlActionStart() { } const launched = await runSourceControlAgentActionStart({ ...args, + recipe, agentLaunchPreferences, selectedAgent: agent, trimmedCommandInput: projection.commandInput, diff --git a/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts b/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts index 0429e1ab390..c646984fa7d 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts +++ b/src/renderer/src/components/right-sidebar/use-source-control-action-agent-choice.ts @@ -8,7 +8,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec' import { pickSourceControlLaunchAgent } from '../../../../shared/source-control-launch-agent-selection' -import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' +import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -40,7 +40,7 @@ export function useSourceControlActionAgentChoice(args: { worktreeId?: string | null repoId?: string | null repo: Pick | null - recipe: SourceControlActionRecipe + recipe: SelectedSourceControlActionRecipe /** This open's deliberate pick; absent replays the saved recipe. */ livePick: AgentLaunchSurface | undefined settings: GlobalSettings | null diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx index e6646bc3f8e..fca0380afc9 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx @@ -1,21 +1,37 @@ +// @vitest-environment happy-dom + +import { + commitSourceControlActionArguments, + editSourceControlActionArguments +} from '../../../../shared/source-control-action-selection' +import { + captureSourceControlActionSelection, + resolveSourceControlActionRecipe +} from '../../../../shared/source-control-ai-operation-policy' +import { projectSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences' +import { structuredLaunchRequestedOptions } from '../../../../shared/native-chat/input/native-chat-session-option-defaults' +import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift' import type { Repo } from '../../../../shared/repo-types' import { projectAgentLaunchSettings } from '../../../../shared/agent-launch-settings' import { makeWorktree, TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' import { getRuntimeAgentInventoryKey } from '@/store/slices/runtime-agent-inventory-key' import { clearHostAgentMenuSettingsForTests } from '../settings/use-host-agent-launch-preferences' import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' -import { AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY as SURFACE } from '../../../../shared/agent-launch-runtime-capability' -// @vitest-environment happy-dom -import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, renderHook, screen, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' -import { - STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY as CLIENT, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY as CHAT -} from '../../../../shared/protocol-version' import { useAppStore } from '@/store' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog' +import { + mountSourceDialog, + freshDialogRecipe, + pickDialogSaveTarget, + submittedActionStartupPlan, + renderDialog, + pickDialogAgent, + type SaveAction +} from './source-control-action-dialog.test-fixture' const initialState = useAppStore.getState() afterEach(() => { @@ -25,36 +41,6 @@ afterEach(() => { setLocalRuntimeCapabilitiesForTests(null) }) -function renderDialog(chat: boolean, capable = true, initialRecipe?: SourceControlActionRecipe) { - const settings = { ...getDefaultSettings('/tmp/pr-3'), defaultTuiAgent: 'claude' as const } - const launchPreferences = { agentId: 'codex' as const, model: 'chosen' } - settings.sourceControlAi!.actions = { resolveConflicts: { agentId: null, launchPreferences } } - useAppStore.setState({ - settings: { ...settings, experimentalNativeChat: chat }, - detectedAgentIds: ['claude', 'codex'], - localDetectedAgentIdsByContext: { host: ['claude', 'codex'] } - }) - setLocalRuntimeCapabilitiesForTests([CHAT, CLIENT, ...(capable ? [SURFACE] : [])]) - const onStart = vi.fn().mockResolvedValue(true) - const onSaveAgentDefault = vi.fn() - const view = renderHook(() => - useSourceControlAgentActionDialog({ - open: true, - initialRecipe, - onOpenChange: () => {}, - actionId: 'resolveConflicts', - title: '', - description: '', - baseCommandInput: 'fix', - launchSource: 'source_control_recovery', - savedAgentId: null, - onStart, - onSaveAgentDefault - }) - ) - return { ...view, onStart, onSaveAgentDefault, launchPreferences } -} - it('starts and saves a live pick from the unified picker, keeping the agent-bound model', async () => { const { result, onStart, onSaveAgentDefault, launchPreferences } = renderDialog(true) await waitFor(() => expect(result.current.canStart).toBe(true)) @@ -244,3 +230,580 @@ it.each(['codex', null] as const)( expect(useAppStore.getState().settings).toBe(settings) } ) + +it.each([ + { saved: 'claude' as const, model: 'opus', terminal: true }, + { saved: null, model: undefined, terminal: false } +])( + 'keeps captured choices through actual same-agent picker dispatch, complete Save, Start and reopen (%s)', + async ({ saved, model, terminal }) => { + const global: SourceControlActionRecipe = { + agentId: 'claude', + agentArgs: '--verbose', + launchPreferences: { + agentId: 'claude', + ...(model ? { model } : {}), + options: { effort: 'high' } + } + } + const own: SourceControlActionRecipe = { + agentId: saved, + launchPreferences: { agentId: 'claude', permissionMode: 'ask' } + } + const f = mountSourceDialog(global, own) + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled') + ).toBe(false) + ) + await pickDialogAgent('Claude', terminal) + if (terminal) { + expect( + screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value + ).toBe('--verbose') + } else { + expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull() + } + expect(screen.getByText(/High/).textContent).toContain('High') + if (model) { + expect(screen.getByText(/High/).textContent).toContain('Opus') + } + fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + const options = { ...(model ? { model } : {}), effort: 'high', permissionMode: 'ask' } + expect(f.onStart).toHaveBeenCalledWith( + expect.objectContaining({ agent: 'claude', agentArgs: '--verbose', sessionOptions: options }) + ) + expect(f.persist).toHaveBeenCalledOnce() + const savedRecipe = f.persist.mock.calls[0][2] + expect(savedRecipe.launchPreferences).toEqual({ + agentId: 'claude', + permissionMode: 'ask', + modelSelection: { + base: 'default', + ...(model ? { model: { agentId: 'claude', value: model } } : {}), + options: { effort: { agentId: 'claude', value: 'high' } } + } + }) + expect( + projectSourceControlActionLaunch(freshDialogRecipe(f.repo.id), 'claude', { + basePrompt: 'fix' + }) + ).toMatchObject({ agentArgs: '--verbose', sessionOptions: options }) + f.view.unmount() + const reopened = f.mount() + expect(screen.getByText(/High/).textContent).toContain('High') + reopened.unmount() + } +) + +it('distinguishes masked eligibility from owning reset and uses the captured parent through Save/Start', async () => { + const global: SourceControlActionRecipe = { + agentId: 'codex', + agentArgs: '', + launchPreferences: { + agentId: 'codex', + permissionMode: 'own', + modelSelection: { + base: 'default', + model: { agentId: 'codex', value: 'o3' } + } + } + } + const own: SourceControlActionRecipe = { + agentId: 'claude', + launchPreferences: { + agentId: 'claude', + permissionMode: 'ask', + modelSelection: { + base: 'inherit', + model: { agentId: 'claude', value: 'opus' } + } + } + } + const f = mountSourceDialog(global, own) + const captured = resolveSourceControlActionRecipe({ + settings: useAppStore.getState().settings, + repo: f.repo, + actionId: 'resolveConflicts' + }) + expect( + projectSourceControlActionLaunch(captured, 'codex', { + basePrompt: 'fix' + }).sessionOptions + ).toBeUndefined() + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled') + ).toBe(false) + ) + act(() => + useAppStore.setState((state) => ({ + settings: { + ...state.settings!, + sourceControlAi: { + ...state.settings!.sourceControlAi!, + actions: { + resolveConflicts: { ...global, launchPreferences: { agentId: 'codex', model: 'later' } } + } + } + } + })) + ) + await pickDialogAgent('Codex', true) + expect(screen.getByText(/Model from Settings: o3/)).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart).toHaveBeenCalledWith( + expect.objectContaining({ agent: 'codex', agentArgs: '', sessionOptions: { model: 'o3' } }) + ) + expect(f.persist.mock.calls[0][2]).toMatchObject({ + agentId: 'codex', + agentArgs: '', + launchPreferences: { + agentId: 'codex', + modelSelection: { + base: 'default', + model: { agentId: 'codex', value: 'o3' } + } + } + }) + expect(f.persist.mock.calls[0][2].launchPreferences).toEqual({ + agentId: 'codex', + modelSelection: { base: 'default', model: { agentId: 'codex', value: 'o3' } } + }) + expect(f.persist.mock.calls[0][2]).not.toHaveProperty('resolvedChoiceSources') + expect( + projectSourceControlActionLaunch(freshDialogRecipe(f.repo.id), 'codex', { basePrompt: 'fix' }) + .sessionOptions + ).toEqual({ model: 'o3' }) + await pickDialogAgent('Claude', false) + await pickDialogAgent('Codex', true) + expect(screen.queryByText(/Opus/)).toBeNull() + f.view.unmount() + const reopened = f.mount() + expect(screen.getByText(/Model from Settings: o3/)).toBeDefined() + reopened.unmount() + useAppStore.setState((state) => ({ + repos: state.repos.map((row) => + row.id === f.repo.id + ? { + ...row, + sourceControlAi: { actionOverrides: { resolveConflicts: { agentId: 'codex' } } } + } + : row + ) + })) + const fresh = f.mount() + expect(screen.getByText(/Model from Settings: later/)).toBeDefined() + fresh.unmount() +}) + +it('retains newly eligible literal syntax, demotes obsolete syntax once and does not restore it after switching back', async () => { + const global = liftActionPermissionArgs({ + agentId: 'claude' as const, + agentArgs: '--model opus --effort high --verbose' + }) + const f = mountSourceDialog(global, { + agentId: 'codex', + launchPreferences: { agentId: 'codex', permissionMode: 'ask' } + }) + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled') + ).toBe(false) + ) + await pickDialogAgent('Claude', true) + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe( + '--verbose' + ) + expect(screen.getByText(/Opus/).textContent).toContain('High') + fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'claude', + agentArgs: '--verbose', + sessionOptions: { model: 'opus', effort: 'high' } + }) + ) + const saved = f.persist.mock.calls[0][2] + expect(saved.argumentChoices).toEqual(global.argumentChoices) + expect(saved.launchPreferences).toBeUndefined() + await pickDialogAgent('Codex', true) + const text = screen.getByRole('textbox', { name: 'Extra CLI arguments' }) + expect(text.value).toBe('--model opus --effort high --verbose') + await pickDialogAgent('Claude', true) + expect(screen.queryByText(/Model from Settings/)).toBeNull() + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe( + '--model opus --effort high --verbose' + ) +}) + +it('keeps an owned empty-Args default barrier and submitted choices across failed Save, newer edits and Discard', async () => { + const done = Promise.withResolvers() + const save = vi.fn(() => done.promise) + const f = mountSourceDialog( + { agentId: 'codex', launchPreferences: { agentId: 'codex', model: 'o3' } }, + { + agentId: 'claude', + agentArgs: '', + launchPreferences: { + agentId: 'claude', + modelSelection: { base: 'inherit', model: { agentId: 'claude', value: null } } + } + }, + save + ) + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Save & start agent' }).hasAttribute('disabled') + ).toBe(false) + ) + await pickDialogAgent('Codex', true) + expect(screen.queryByText(/Model from Settings/)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Save & start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart.mock.calls[0][0]).toMatchObject({ + agent: 'codex', + agentArgs: '', + commandInput: 'fix' + }) + expect(f.onStart.mock.calls[0][0].sessionOptions).toBeUndefined() + fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), { + target: { value: '--newer' } + }) + await act(async () => done.reject(new Error('write failed'))) + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe( + '--newer' + ) + expect(save.mock.calls[0][2].agentArgs).toBe('') + expect(f.onStart).toHaveBeenCalledOnce() + expect(useAppStore.getState().repos[0].sourceControlAi).toEqual(f.repo.sourceControlAi) + expect(freshDialogRecipe(f.repo.id).agentId).toBe('claude') + f.view.unmount() + const fresh = f.mount() + await pickDialogAgent('Claude', true) + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe( + '' + ) + fresh.unmount() +}) + +it.each([ + { agent: 'claude', pair: false, save: false, native: false, scope: 'global' }, + { agent: 'claude', pair: true, save: true, native: false, scope: 'repo' }, + { agent: 'claude', pair: false, save: true, native: false, scope: 'global' }, + { agent: 'claude', pair: true, save: true, native: true, scope: 'repo' }, + { agent: 'codex', pair: true, save: false, native: true, scope: 'repo' }, + { agent: 'codex', pair: true, save: false, native: true, scope: 'repo', foreignDocument: true }, + { agent: 'claude', pair: true, save: false, native: true, scope: 'repo', defaultGroup: true }, + { agent: 'claude', pair: false, save: false, native: true, scope: 'repo', defaultGroup: true }, + { agent: 'claude', pair: false, save: true, native: false, scope: 'repo', typedDefault: true }, + { agent: 'claude', pair: false, save: true, native: true, scope: 'repo', typedDefault: true } +] as const)( + 'commits edited $agent literals once through Start/save=$save/native=$native/$scope and fresh reopen', + async (row) => { + const { agent, pair, save, native, scope } = row + const original = + agent === 'claude' + ? `--model opus ${pair || save || 'defaultGroup' in row ? '--effort high ' : ''}--verbose` + : '-m gpt-5.4 -c model_reasoning_effort=high --verbose' + const replacement = + agent === 'claude' + ? `--model sonnet ${pair ? '--effort low ' : ''}--verbose` + : '-m gpt-5.5 -c model_reasoning_effort=low --verbose' + const global = liftActionPermissionArgs({ + agentId: 'foreignDocument' in row ? 'claude' : agent, + surface: 'terminal', + agentArgs: 'foreignDocument' in row ? '--model opus --effort high --verbose' : original + }) + const f = mountSourceDialog( + 'typedDefault' in row + ? { + ...global, + argumentChoices: undefined, + launchPreferences: { + agentId: agent, + modelSelection: { + base: 'default', + model: { agentId: agent, value: 'opus' }, + options: { effort: { agentId: agent, value: 'high' } } + } + } + } + : global, + { + agentId: agent, + surface: 'terminal', + ...('defaultGroup' in row || 'typedDefault' in row + ? { + launchPreferences: { agentId: agent, modelSelection: { base: 'default' as const } } + } + : {}) + }, + undefined, + undefined, + scope + ) + await waitFor(() => + expect( + screen + .getByRole('button', { + name: /^(Save & start agent|Start agent)$/ + }) + .hasAttribute('disabled') + ).toBe(false) + ) + if ('typedDefault' in row) { + const state = useAppStore.getState() + const captured = captureSourceControlActionSelection({ + settings: state.settings, + repo: state.repos[0], + actionId: 'resolveConflicts' + }) + const committed = commitSourceControlActionArguments( + editSourceControlActionArguments(captured, replacement), + agent + ) + expect(committed.selection.source).toEqual(captured.source) + expect(committed.selection.source).toMatchObject({ + scope: 'repo', + ownsArgs: false, + authoredPreferences: { agentId: agent, modelSelection: { base: 'default' } }, + parentPreferences: { + modelSelection: { model: { value: 'opus' }, options: { effort: { value: 'high' } } } + } + }) + expect(committed.recipe.resolvedChoiceSources).toMatchObject({ + model: { base: 'default' }, + literalKeys: ['model'], + permission: captured.source.authoredPreferences + }) + } + fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), { + target: { value: replacement } + }) + if (native) { + await pickDialogAgent(agent === 'claude' ? 'Claude' : 'Codex', false) + expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull() + } + if (save && scope === 'repo') { + await pickDialogSaveTarget('This repository') + } + if (!save) { + fireEvent.click(screen.getByRole('checkbox')) + } + // No blur: submission itself must settle the pending edit. + fireEvent.click( + screen.getByRole('button', { name: save ? 'Save & start agent' : 'Start agent' }) + ) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + const options = { + model: agent === 'claude' ? 'sonnet' : 'gpt-5.5', + ...(pair ? { effort: 'low' } : {}) + } + const submitted = f.onStart.mock.calls[0][0] + const residual = + 'foreignDocument' in row + ? '--model opus --effort high --verbose' + : 'defaultGroup' in row && !pair + ? '--effort high --verbose' + : '--verbose' + expect(submitted).toMatchObject({ agent, agentArgs: residual, sessionOptions: options }) + expect(submitted.sessionOptions).toEqual(options) + const plan = submittedActionStartupPlan(submitted) + expect(plan?.launchCommand).toBe( + agent === 'claude' + ? `claude ${'defaultGroup' in row && !pair ? "'--effort' 'high' " : ''}'--verbose' '--model' 'sonnet'${pair ? " '--effort' 'low'" : ''} 'fix'` + : `codex ${'foreignDocument' in row ? "'--effort' 'high' " : ''}'--verbose' '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low' 'fix'` + ) + if (native) { + expect(submitted.launchChoice?.requestedSurface).toBe('chat') + expect( + structuredLaunchRequestedOptions({ + explicit: submitted.sessionOptions, + seed: { model: 'remembered', effort: 'high' }, + held: {} + }) + ).toEqual(options) + expect( + structuredLaunchRequestedOptions({ + explicit: submitted.sessionOptions, + seed: { effort: 'high' }, + held: { effort: 'medium' } + }) + ).toEqual({ ...options, effort: 'medium' }) + } + expect(f.persist).toHaveBeenCalledTimes(save ? 1 : 0) + if (save) { + const saved = f.persist.mock.calls[0][2] + expect(saved.agentArgs).toBe('--verbose') + expect(saved.argumentChoices?.nodes.map((node) => node.text)).toEqual([ + '--model sonnet', + ...(pair ? ['--effort low'] : []) + ]) + expect(saved).not.toHaveProperty('resolvedChoiceSources') + const state = useAppStore.getState() + const fresh = resolveSourceControlActionRecipe({ + settings: state.settings, + repo: scope === 'repo' ? state.repos[0] : undefined, + actionId: 'resolveConflicts' + }) + expect(projectSourceControlActionLaunch(fresh, agent, { basePrompt: 'fix' })).toMatchObject({ + agentArgs: '--verbose', + sessionOptions: options + }) + f.view.unmount() + const reopened = f.mount() + if (native) { + await waitFor(() => + expect(screen.queryByRole('textbox', { name: 'Extra CLI arguments' })).toBeNull() + ) + } else { + expect( + screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value + ).toBe('--verbose') + } + reopened.unmount() + } + } +) + +it.each([ + { edit: undefined, raw: '--model sonnet --verbose' }, + { edit: '--unrelated', raw: '--unrelated' }, + { edit: '', raw: '' } +])('preserves untouched conflict or untouched nodes for residual $edit', async ({ edit, raw }) => { + const global = liftActionPermissionArgs({ + agentId: 'claude', + surface: 'terminal', + agentArgs: '--model opus --effort high --verbose' + }) + const f = mountSourceDialog( + { ...global, agentArgs: edit === undefined ? raw : global.agentArgs }, + {} + ) + await waitFor(() => expect(screen.getByRole('checkbox')).toBeDefined()) + if (edit !== undefined) { + fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), { + target: { value: edit } + }) + } + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart.mock.calls[0][0]).toMatchObject({ + agentArgs: raw, + sessionOptions: { model: 'opus', effort: 'high' } + }) + expect(f.persist).not.toHaveBeenCalled() +}) + +it('replaces the selected captured parent key without flattening sparse ownership or permission', async () => { + const f = mountSourceDialog( + { + agentId: 'claude', + surface: 'terminal', + agentArgs: '--verbose', + launchPreferences: { agentId: 'claude', model: 'opus', options: { effort: 'high' } } + }, + { + launchPreferences: { + agentId: 'claude', + permissionMode: 'ask', + modelSelection: { + base: 'inherit', + options: { fastMode: { agentId: 'codex', value: 'true' } } + } + } + } + ) + await waitFor(() => expect(screen.getByRole('checkbox')).toBeDefined()) + act(() => + useAppStore.setState((state) => ({ + settings: { + ...state.settings!, + sourceControlAi: { + ...state.settings!.sourceControlAi!, + actions: { + resolveConflicts: { + agentId: 'claude', + launchPreferences: { agentId: 'claude', model: 'later' } + } + } + } + } + })) + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), { + target: { value: '--model sonnet --verbose' } + }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart.mock.calls[0][0]).toMatchObject({ + agentArgs: '--verbose', + sessionOptions: { model: 'sonnet', permissionMode: 'ask' } + }) + expect(f.persist).not.toHaveBeenCalled() +}) + +it('settles pending replacement in its old grammar before a genuine agent demotion', async () => { + const f = mountSourceDialog( + liftActionPermissionArgs({ + agentId: 'claude', + surface: 'terminal', + agentArgs: '--model opus --effort high --verbose' + }), + {} + ) + await waitFor(() => + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' })).toBeDefined() + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), { + target: { value: '--model sonnet --verbose' } + }) + await pickDialogAgent('Codex', true) + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' }).value).toBe( + '--model sonnet --verbose' + ) + await pickDialogAgent('Claude', true) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart.mock.calls[0][0].sessionOptions).toBeUndefined() + expect(f.onStart.mock.calls[0][0].agentArgs).toBe('--model sonnet --verbose') +}) + +it.each([ + { raw: 'acceptEdits', permission: 'accept-edits', residual: '--verbose' }, + { raw: 'plan', permission: 'ask', residual: '--permission-mode plan --verbose' } +] as const)( + 'keeps independent effort and separately selected permission for raw $raw', + async ({ raw, permission, residual }) => { + const f = mountSourceDialog( + { + agentId: 'claude', + surface: 'terminal', + agentArgs: '--verbose', + launchPreferences: { agentId: 'claude', options: { effort: 'high' }, permissionMode: 'ask' } + }, + {} + ) + await waitFor(() => + expect(screen.getByRole('textbox', { name: 'Extra CLI arguments' })).toBeDefined() + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Extra CLI arguments' }), { + target: { value: `--model sonnet --permission-mode ${raw} --verbose` } + }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Start agent' })) + await waitFor(() => expect(f.onStart).toHaveBeenCalledOnce()) + expect(f.onStart.mock.calls[0][0]).toMatchObject({ + agentArgs: residual, + sessionOptions: { model: 'sonnet', effort: 'high', permissionMode: permission } + }) + expect(f.persist).not.toHaveBeenCalled() + } +) diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 061641b452a..f33b8ea1e00 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -1,9 +1,16 @@ +import { + canonicalSourceControlActionSelection, + commitSourceControlActionArguments, + editSourceControlActionArguments, + pickSourceControlActionAgent, + selectSourceControlActionRecipe, + type CurrentActionSelection +} from '../../../../shared/source-control-action-selection' +import { captureSourceControlActionSelection } from '../../../../shared/source-control-ai-operation-policy' import { useCallback, useMemo, useState } from 'react' import { resolveSourceControlLaunchAgentScope } from '../../../../shared/source-control-launch-agent-selection' import { useAppStore } from '@/store' import { useRepoById } from '@/store/selectors' -import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai' -import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions' import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan' @@ -23,11 +30,10 @@ const DEFAULT_SAVE_TARGET_VALUE = 'global' export function useSourceControlAgentActionDialog({ open, initialRecipe: capturedRecipe, + initialSelection, onOpenChange, actionId, baseCommandInput, - savedCommandInputTemplate, - savedAgentArgs, worktreeId, groupId, connectionId, @@ -36,7 +42,6 @@ export function useSourceControlAgentActionDialog({ launchPlatform, launchSource, recoveryContext, - savedAgentId, onSaveAgentDefault, onLaunchAccepted, onLaunchAborted, @@ -45,30 +50,18 @@ export function useSourceControlAgentActionDialog({ }: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult { const settings = useAppStore((state) => state.settings) const repo = useRepoById(repoId ?? null) - const initialRecipe = useCallback((): SourceControlActionRecipe => { - if (capturedRecipe) { - return capturedRecipe - } - const { surface, ...resolved } = resolveSourceControlActionRecipe({ settings, repo, actionId }) - const agentId = savedAgentId ?? null - return { - ...resolved, - // A saved chat-or-CLI pick belongs to the saved agent, like its model. - ...(surface && resolved.agentId === agentId ? { surface } : {}), - agentId, - commandInputTemplate: savedCommandInputTemplate ?? '{basePrompt}', - ...(savedAgentArgs != null ? { agentArgs: savedAgentArgs } : {}) - } - }, [ - capturedRecipe, - settings, - repo, - actionId, - savedAgentId, - savedCommandInputTemplate, - savedAgentArgs - ]) - const [recipe, setRecipe] = useState(initialRecipe) + const [currentSelection, setCurrentSelection] = useState( + () => + initialSelection ?? + (capturedRecipe + ? canonicalSourceControlActionSelection(capturedRecipe) + : captureSourceControlActionSelection({ settings, repo, actionId })) + ) + const recipe = useMemo( + () => selectSourceControlActionRecipe(currentSelection), + [currentSelection] + ) + const [argumentsEdited, setArgumentsEdited] = useState(false) const [livePick, setLivePick] = useState(undefined) const commandTemplate = recipe.commandInputTemplate ?? '{basePrompt}' const agentArgs = recipe.agentArgs ?? '' @@ -113,9 +106,16 @@ export function useSourceControlAgentActionDialog({ selectedAgent, commandInput, trimmedCommandInput, - agentArgs: recipe.agentArgs, agentArgsApply: choice.launchAppliesAgentArgs, recipe: choice.canSaveSurface ? recipe : (({ surface: _surface, ...rest }) => rest)(recipe), + prepareRecipe: (agent) => { + const submitted = argumentsEdited + ? commitSourceControlActionArguments(currentSelection, agent).recipe + : recipe + return choice.canSaveSurface + ? submitted + : (({ surface: _surface, ...rest }) => rest)(submitted) + }, saveLaunchRecipe, saveTargetValue, actionId, @@ -173,19 +173,30 @@ export function useSourceControlAgentActionDialog({ const onAgentPick = (agent: TuiAgent, surface?: AgentLaunchSurface): void => { const saved = savedSurfaceForPick(choice.observation.model, agent, surface) setLivePick(surface) - setRecipe(({ launchPreferences, surface: _previous, ...current }) => ({ - ...current, - agentId: agent, - ...(launchPreferences?.agentId === agent ? { launchPreferences } : {}), - ...(saved ? { surface: saved } : {}) - })) + setCurrentSelection((previous) => + pickSourceControlActionAgent( + argumentsEdited && selectedAgent !== agent + ? commitSourceControlActionArguments(previous, selectedAgent ?? agent).selection + : previous, + agent, + selectedAgent, + saved + ) + ) + if (selectedAgent !== agent) { + setArgumentsEdited(false) + } resetDeliveryPlan() } - const onAgentArgsChange = resetPlanAfter((agentArgs: string) => - setRecipe((current) => ({ ...current, agentArgs })) - ) + const onAgentArgsChange = resetPlanAfter((agentArgs: string) => { + setArgumentsEdited(true) + setCurrentSelection((current) => editSourceControlActionArguments(current, agentArgs)) + }) const onCommandTemplateChange = resetPlanAfter((commandInputTemplate: string) => - setRecipe((current) => ({ ...current, commandInputTemplate })) + setCurrentSelection((selection) => ({ + ...selection, + current: { ...selection.current, commandInputTemplate } + })) ) const onSaveLaunchRecipeChange = useMemo( () => resetPlanAfter(setSaveLaunchRecipe), diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 856339fc31c..2b9f04c963f 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -1,10 +1,12 @@ +import { projectSelectedSourceControlActionLaunch } from '../../../../shared/source-control-action-launch-preferences' +import type { SelectedSourceControlActionRecipe } from '../../../../shared/source-control-action-selection' import type { AgentLaunchPreferences } from '@/components/settings/use-host-agent-launch-preferences' import { useCallback, useEffect, useRef, useState } from 'react' import type { RuntimeGitContext } from '@/runtime/runtime-git-client-context' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { - SourceControlActionRecipe, - SourceControlLaunchActionId + SourceControlLaunchActionId, + SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -25,10 +27,10 @@ type UseSourceControlAgentActionStartArgs = { selectedAgent: TuiAgent | null commandInput: string trimmedCommandInput: string - agentArgs?: string /** False when the launch would be structured native chat, which reads no CLI arguments. */ agentArgsApply: boolean - recipe: SourceControlActionRecipe + prepareRecipe?: (agent: TuiAgent) => SelectedSourceControlActionRecipe + recipe: SelectedSourceControlActionRecipe saveLaunchRecipe: boolean saveTargetValue: string actionId: SourceControlLaunchActionId @@ -82,9 +84,9 @@ export function useSourceControlAgentActionStart({ selectedAgent, commandInput, trimmedCommandInput, - agentArgs, agentArgsApply, recipe, + prepareRecipe, saveLaunchRecipe, saveTargetValue, actionId, @@ -123,14 +125,23 @@ export function useSourceControlAgentActionStart({ useEffect(() => cancelStart, [open, worktreeId, connectionId, actionId, cancelStart]) const buildPlan = useCallback( - (currentDetectedAgents: TuiAgent[]): SourceControlAgentActionDeliveryPlanState => + ( + currentDetectedAgents: TuiAgent[], + submitted: SelectedSourceControlActionRecipe + ): SourceControlAgentActionDeliveryPlanState => buildSourceControlAgentDeliveryPlan({ settings, agentLaunchPreferences, selectedAgent, commandInput, // Why: the check must judge the arguments the launch will really apply. - agentArgs: agentArgsApply ? agentArgs : undefined, + agentArgs: + agentArgsApply && selectedAgent + ? projectSelectedSourceControlActionLaunch(submitted, selectedAgent, { + renderedPrompt: commandInput, + settings + }).agentArgs + : undefined, detectedAgents: currentDetectedAgents, connectionUnavailable, launchPlatform, @@ -139,7 +150,6 @@ export function useSourceControlAgentActionStart({ [ settings, agentLaunchPreferences, - agentArgs, agentArgsApply, commandInput, connectionUnavailable, @@ -164,7 +174,8 @@ export function useSourceControlAgentActionStart({ attemptRef.current = attempt setIsStarting(true) try { - const nextPlan = buildPlan(nextAgents) + const submitted = prepareRecipe ? prepareRecipe(selectedAgent) : recipe + const nextPlan = buildPlan(nextAgents, submitted) if (nextPlan.status === 'error') { setDeliveryPlan(nextPlan) return false @@ -174,7 +185,7 @@ export function useSourceControlAgentActionStart({ selectedAgent, trimmedCommandInput, launchChoice, - recipe, + recipe: submitted, saveTargetValue: saveLaunchRecipe ? saveTargetValue : 'none', actionId, repoId, @@ -211,6 +222,7 @@ export function useSourceControlAgentActionStart({ buildPlan, launchChoice, recipe, + prepareRecipe, connectionUnavailable, groupId, launchSource, diff --git a/src/renderer/src/components/settings/AgentCatalogRow.tsx b/src/renderer/src/components/settings/AgentCatalogRow.tsx index 57c3933563e..2d53497924e 100644 --- a/src/renderer/src/components/settings/AgentCatalogRow.tsx +++ b/src/renderer/src/components/settings/AgentCatalogRow.tsx @@ -297,17 +297,20 @@ export function AgentCatalogRow({ defaultArgs={defaultArgs} argsOverride={argsOverride} onSaveArgs={onSaveArgs} - permissionChip={ + choiceChips={ permissionChoice?.flag && !permissionChoice.argsSetPermission - ? { - label, - flag: permissionChoice.flag, - removeRecipe: (text) => { - const lifted = liftAgentPermissionArgs(agentId, text) - return lifted.choice !== null ? lifted.args : null - }, - onRemove: () => permissionChoice.onSelect('own') - } + ? [ + { + key: 'permissionMode', + label, + flag: permissionChoice.flag, + onRemove: (text) => { + const lifted = liftAgentPermissionArgs(agentId, text) + permissionChoice.onSelect('own') + return lifted.choice !== null ? lifted.args : text + } + } + ] : undefined } note={ diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx index d96c66bb090..88f792001e4 100644 --- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx +++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx @@ -16,14 +16,16 @@ it.each(['', '--default'])( defaultArgs={defaultArgs} argsOverride="" onSaveArgs={vi.fn()} - permissionChip={ + choiceChips={ withChip - ? { - label: 'Claude', - flag: '--permission-mode default', - removeRecipe: () => null, - onRemove: vi.fn() - } + ? [ + { + key: 'permissionMode', + label: 'Claude', + flag: '--permission-mode default', + onRemove: (text) => text + } + ] : undefined } /> diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx index 085d8d626ae..58e75ba8607 100644 --- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx +++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx @@ -9,7 +9,7 @@ import { type AgentDefaultEnvDraftInvalidReason } from './agent-default-env-draft' import { useAgentLaunchFieldWrite } from './use-agent-launch-field-write' -import { AgentPermissionArgsChip } from './AgentPermissionChoiceControls' +import { AgentArgsChoiceChips, type AgentArgsChoice } from './AgentPermissionChoiceControls' export function AgentCommandOverrideInput({ defaultCmd, @@ -87,20 +87,19 @@ export function AgentDefaultArgsInput({ defaultArgs, argsOverride, onSaveArgs, - permissionChip, + choiceChips, note, disabled = false, - onDraftChange + onDraftChange, + placeholder, + onResetArgs }: { defaultArgs: string + placeholder?: string + onResetArgs?: () => void | Promise argsOverride: string onSaveArgs: (value: string) => void | Promise - permissionChip?: { - label: string - flag: string - removeRecipe: (text: string) => string | null - onRemove: () => void - } + choiceChips?: readonly AgentArgsChoice[] note?: string disabled?: boolean onDraftChange?: (value: string) => void @@ -111,6 +110,7 @@ export function AgentDefaultArgsInput({ const chipButtonRef = useRef(null) const argsDraft = onDraftChange ? argsOverride : (draft ?? argsOverride) const { pending, write } = useAgentLaunchFieldWrite() + const chipFlags = choiceChips?.map((chip) => chip.flag).join(' ') useLayoutEffect(() => { const input = inputRef.current const chip = chipRef.current @@ -128,7 +128,7 @@ export function AgentDefaultArgsInput({ const observer = new ResizeObserver(measure) observer.observe(chip) return () => observer.disconnect() - }, [permissionChip?.flag]) + }, [chipFlags]) const commitArgs = (): void => { if (disabled || pending || (!onDraftChange && draft === null)) { return @@ -164,26 +164,24 @@ export function AgentDefaultArgsInput({ } }} > - {permissionChip && ( - { + onRemove={(chip) => { if (disabled || pending) { return } - if (onDraftChange) { - onDraftChange(permissionChip.removeRecipe(argsDraft) ?? argsDraft) - } else if (draft !== null) { - setDraft(permissionChip.removeRecipe(draft) ?? draft) + const next = chip.onRemove(argsDraft) + if (!onDraftChange && draft !== null) { + setDraft(next) } inputRef.current?.focus() - permissionChip.onRemove() }} /> - )} + ) : null}