Unify Source Control action permissions and card layout (#30449)

* Source control actions start chats and terminals on their saved model

A "start an agent" action's model lived only in free-text CLI arguments, which a native chat
never reads, so AI Fix opened a chat on the default model. Each action now saves a structured
model and effort that both routes apply: chat create options, or the agent's own flags in a
terminal (winning over the same flag in CLI arguments). Desktop, dialog, fix-checks and mobile
launches carry it; Settings rows get Model/Effort selects and the CLI arguments field says it is
terminal-only.

Tracked in STA-11102.

* Name the model in the repository override note

* Type the dialog's model hand-off; keep Agent in English in Japanese copy

* fix: preserve action chat model choices through launch

* Preserve action selection ownership across chat launches

* Keep terminal host contract fixtures aligned with preflight

* fix: wait for action Settings agent inventory

* fix: consolidate direct agent routing imports

* fix: use standard action option labels

* fix: align action launch contracts with shared types

* fix: sync action option localization metadata

* test: provide action Settings inventory in mounted fixtures

* Keep action options row height stable while loading

* Let each Source Control action choose its chat permission mode

An action's saved chat choice may now carry a permission mode, with or without a model;
"Same as new chats" saves nothing. A model is replaced only when the action names one,
so a permission-only choice keeps the chat's remembered model and options.

* fix(native-chat): leave older hosts their own permission mode and keep action options readable

- An action's saved permission mode is sent only to hosts that advertise
  agentSession.create.options-mode.v1. Older hosts get no mode, in create
  options or a later setOption, so the chat starts on that host's new-chat
  default, as a phone launch to such a host already does. Hosts from
  1.4.225 to 1.4.227 refused a mode their tables lacked (OpenCode, Grok,
  OMP), leaving the action unable to start.
- Settings action rows give Model and Permissions full rows in the options
  column so saved values are readable; the loading placeholder reserves the
  same rows.
- Remove two unused locale keys; fix two stale comments.
- Tests: the older-host create carries no permission mode; a permission
  mode picked alone keeps the remembered model on an acquired create.

* fix(settings): reserve one options row while loading an action with nothing saved

Terminal and permissions-only rows no longer shrink when loading ends; a chat
agent with a model list may grow by its Model row once known.

* fix(native-chat): keep the create options mode a checked literal type

* fix(settings): share verified action option fields

* fix(settings): keep action selectors on standard typography

* Keep the launch files under their length limits after the main merge

The structured route imports its result types where they are defined, and the two older-host
action-launch tests share one setup helper.

* One action settings section for chat and terminal launches

A Source Control action's Model, model options and Permissions now apply whether it opens a
chat or a terminal; terminals receive them as the agent's own flags, and a repository's own
CLI arguments keep the flags it sets. Extra CLI arguments stay terminal-only.

* fix(source-control-ai): one terminal projection for saved action choices

Ask in a terminal now states its permission instead of leaving it to the
user's own config: Claude and Grok pass --permission-mode default, Codex
passes -a on-request -s read-only -c approvals_reviewer="user" (the
interactive CLI has no untrusted policy, so a read-only sandbox is what
asks before edits). Agents whose CLI has no flag for a mode show the
existing "Applies to chats only" hint.

The strict launch-preferences projection now requires the launch agent at
every caller, composes model ids through the resolver's own step, carries
a mode only where the agent's terminal has a flag for it, and the local
string wire keeps booleans as 'true'/'false', so Cursor Thinking/Fast reach
the terminal and an OpenCode permission-only choice is not refused.

The argument parser knows flag arity, so removing a boolean permission
flag keeps other options' values, following positionals and '--'.
Pre-admission and executor share one terminal admission check on the
options the terminal will receive. Customize with Save launches the recipe
as it will resolve once saved; legacy partial repository overrides are
edited as the complete recipe they resolve to. Empty Extra CLI arguments
start collapsed.

* fix(source-control-ai): an edited repository action saves complete

Every Settings edit of a repository action (agent, model, permission,
prompt or arguments) now starts from the complete recipe the action
resolves to, whether its stored override is owned or a legacy partial.
The resolved agent selection is kept (never an agent only detected to run
a default), an explicit null args clear stays null, and empty args stay
empty instead of being dropped. Clearing the last preference therefore
means the action agent's own defaults rather than re-inheriting Global.

The action text draft is lossless: unset or cleared prompt and args stay
so until the user types, and the inputs show them as empty, so saving a
prompt no longer writes empty args that suppress the agent's default
flags. Untouched sibling actions are not rewritten by the edit.

* fix(settings): style the Extra CLI arguments toggle through Button's own variant and size

CI's changed-code quality check refuses restyle classes on Button and
CollapsibleContent. Use the ghost variant at size xs with a margin, and
put the content spacing on a plain inner wrapper.

* fix: keep saved action and restart launch recipes consistent

* fix: describe inherited arguments and chat-only permissions accurately

* test: check repository action settings fixture type

* test: map saved action options into terminal compiler inputs

* fix: retain saved action controls during agent detection

* fix(i18n): retire obsolete Portuguese action labels

* test: align folder launch fallback mock with outcome export

* fix: carry resolved connector launch intent and mode together

* test: pin shared action launch branches to recorded agent output

* test: preserve saved source control choices through detection and migration

* test: align launch fixtures with integrated result contract

* test: retain recipe and restart contracts after main integration

* Unify Source Control action permission settings

* Keep native Ask copy with its owning change

* Preserve action drafts and old-host permission intent

* Consume rejected Source Control action saves

* Follow the moved Settings persistence fixture

* Keep permission integration contracts in the cross-version test project

* Include the action permission inheritance label in English catalogs

* Align launch contracts with shared permission recipes

* Keep Source Control action controls within their Settings columns

* Use primitive variants for compact action settings controls

* Show action arguments only for terminal defaults and widen recipe rows

* Apply terminal-only action arguments visibility to repository overrides

* Align action permission control with model controls

* Use shared settings label for action permissions

* Hide action model controls until argument migration ships
This commit is contained in:
Brennan Benson
2026-10-26 16:01:56 -07:00
committed by GitHub
parent aec426c2fa
commit f06f737802
210 changed files with 6519 additions and 1144 deletions
@@ -6,7 +6,7 @@ import {
collectUnitDependencyGraph,
unitConsumers
} from './ci-unit-dependency-graph.mjs'
import { discoverUnitFiles } from './ci-unit-files.mjs'
import { CROSS_VERSION_WIRE_DIR, discoverUnitFiles } from './ci-unit-files.mjs'
import { planUnitSelection } from './ci-unit-selection.mjs'
const harness = 'src/renderer/src/runtime/web-session-tabs-sync-test-harness.ts'
@@ -34,7 +34,11 @@ const supportBridges = new Set([
function productConsumers(file, graph, unitFiles) {
return [...unitConsumers([file], graph.reverse)].filter(
(consumer) => consumer !== file && !unitFiles.has(consumer) && !supportBridges.has(consumer)
(consumer) =>
consumer !== file &&
!unitFiles.has(consumer) &&
!(consumer.startsWith(CROSS_VERSION_WIRE_DIR) && consumer.endsWith('.unit.test.ts')) &&
!supportBridges.has(consumer)
)
}
@@ -209,8 +209,11 @@ describe('launchAgentInExistingWorkspace', () => {
})
it('treats a refusal of the first send as an unsupported host', async () => {
const { client } = scriptedClient(refused('agent_launch_replay_unsupported'))
await expect(launch(client)).resolves.toEqual({ kind: 'unsupported' })
const { client, sendRequest } = scriptedClient(refused('agent_launch_replay_unsupported'))
await expect(
launch(client, { chatOptions: { model: 'opus', fastMode: 'false' } })
).resolves.toEqual({ kind: 'unsupported' })
expect(sendRequest.mock.calls[0]?.[1]).not.toHaveProperty('chatOptions')
})
it('never reads a refusal after a replay as unsupported: the first attempt may have run', async () => {
@@ -218,10 +221,23 @@ describe('launchAgentInExistingWorkspace', () => {
{ throws: markRpcDeliveryUnknown(new Error('Connection interrupted')) },
refused('agent_launch_replay_unsupported')
)
await expect(launch(client)).resolves.toEqual({
const chatOptions = { model: 'opus', fastMode: 'false' }
const capabilities = [...LAUNCH_CAPABILITIES, 'agent.launch.chat-options.v1']
const pending = launch(client, { hostCapabilities: capabilities, chatOptions })
chatOptions.model = 'changed-after-send'
capabilities.length = 0
await expect(pending).resolves.toEqual({
kind: 'unknown',
message: AGENT_LAUNCH_UNCONFIRMED_MESSAGE
})
expect(sendRequest.mock.calls.map(([, params]) => requestParam(params, 'chatOptions'))).toEqual(
[
{ model: 'opus', fastMode: 'false' },
{ model: 'opus', fastMode: 'false' }
]
)
expect(sendRequest.mock.calls[0]?.[1]).toBe(sendRequest.mock.calls[1]?.[1])
// The replay reuses the same operation id.
expect(sendRequest.mock.calls.map(([, params]) => requestParam(params, 'operationId'))).toEqual(
['op-1', 'op-1']
)
@@ -1,5 +1,6 @@
// Send launch intent to the host, which selects terminal versus structured execution and prompt delivery.
import { AGENT_LAUNCH_CHAT_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-launch-runtime-capability'
import type {
AgentLaunchPlacement,
AgentLaunchPrompt,
@@ -76,6 +77,9 @@ export async function launchAgentInExistingWorkspace(args: {
worktreeId: string
agent: TuiAgent
prompt?: AgentLaunchPrompt
/** Model and effort the host applies on whichever route it picks. */
sessionOptions?: Readonly<Record<string, string>>
chatOptions?: Readonly<Record<string, string>>
launchSource?: string
reservation?: MobileAgentLaunchReservation
placement?: AgentLaunchPlacement
@@ -89,6 +93,11 @@ export async function launchAgentInExistingWorkspace(args: {
worktreeId: args.worktreeId,
operationId: (args.mintOperationId ?? structuredSessionOperationId)(),
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.sessionOptions ? { sessionOptions: args.sessionOptions } : {}),
...(args.chatOptions &&
args.hostCapabilities?.includes(AGENT_LAUNCH_CHAT_OPTIONS_RUNTIME_CAPABILITY)
? { chatOptions: args.chatOptions }
: {}),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.placement ? { placement: args.placement } : {}),
...(args.reservation
@@ -11,6 +11,7 @@ import {
loadMobileAgentLaunchContext
} from './mobile-new-tab-agent-loader'
import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-launch-agent'
import { resolveSourceControlActionSessionOptions } from '../../../src/shared/source-control-action-launch-preferences'
// Have the host launch and deliver AI prompts; mobile must never type them into a shell.
@@ -68,11 +69,15 @@ export async function launchAgentWithPrompt(args: {
"This action's saved prompt is empty. Update Source Control AI settings on your computer."
}
}
const sessionOptions = recipe
? resolveSourceControlActionSessionOptions(recipe, resolved.agent)
: undefined
const launched = await launchAgentInExistingWorkspace({
client: args.client,
hostCapabilities: args.hostCapabilities,
worktreeId: args.worktreeId,
agent: resolved.agent,
...(sessionOptions ? { chatOptions: sessionOptions } : {}),
// Leave agent arguments to host defaults because applicability depends on the host-selected route and shell.
prompt: { text, delivery: 'submit' },
launchSource: args.launchSource
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
agentLaunchCreateParams,
agentLaunchExistingParams,
isAgentLaunchUnsupportedRefusal,
readAgentLaunchCreateOutcome
} from './agent-launch-request'
@@ -36,6 +37,23 @@ describe('agentLaunchCreateParams', () => {
})
})
describe('agentLaunchExistingParams', () => {
const launch = { agent: 'codex', worktreeId: 'wt-1', operationId: 'op-1' } as const
it("sends an action's model and effort as session options", () => {
expect(
agentLaunchExistingParams({
...launch,
sessionOptions: { model: 'gpt-5.6-terra', effort: 'high' }
}).sessionOptions
).toEqual({ model: 'gpt-5.6-terra', effort: 'high' })
})
it('omits session options when the action saved no model', () => {
expect(agentLaunchExistingParams(launch)).not.toHaveProperty('sessionOptions')
})
})
describe('readAgentLaunchCreateOutcome', () => {
it.each([
{
+4
View File
@@ -61,12 +61,16 @@ export function agentLaunchExistingParams(args: {
paneKey?: string
sessionId?: string
placement?: AgentLaunchPlacement
sessionOptions?: Readonly<Record<string, string>>
chatOptions?: Readonly<Record<string, string>>
}): RpcSendParams<'agent.launchReplay'> {
return {
agent: args.agent,
operationId: args.operationId,
target: { kind: 'existing', worktree: `id:${args.worktreeId}` },
...(args.prompt ? { prompt: args.prompt } : {}),
...(args.sessionOptions ? { sessionOptions: { ...args.sessionOptions } } : {}),
...(args.chatOptions ? { chatOptions: { ...args.chatOptions } } : {}),
...(args.launchSource ? { launchSource: args.launchSource } : {}),
...(args.paneKey ? { paneKey: args.paneKey } : {}),
...(args.sessionId ? { sessionId: args.sessionId } : {}),
@@ -375,10 +375,17 @@ describe("a caller's chat default in place of the host's", () => {
describe('a launch into a workspace that already exists', () => {
it('opens a session without creating anything', async () => {
const h = harness({})
const result = await h.run({ agent: 'codex', target: { kind: 'existing', worktree: 'wt-7' } })
const result = await h.run({
agent: 'codex',
target: { kind: 'existing', worktree: 'wt-7' },
chatOptions: { model: 'action-model', fastMode: 'false' }
})
expect(h.calls).toEqual(['createSupport', 'createStructuredSession'])
expect(h.createWorktree).not.toHaveBeenCalled()
expect(result.worktreeId).toBe('wt-7')
expect(h.createStructuredSession).toHaveBeenCalledWith(
expect.objectContaining({ chatOptions: { model: 'action-model', fastMode: 'false' } })
)
})
it.each(['claude', 'grok'] as const)(
@@ -400,7 +407,16 @@ describe('a launch into a workspace that already exists', () => {
describe('an agent with no structured session', () => {
it('stays a terminal without asking the host', async () => {
const h = harness({})
const result = await h.run({ agent: 'gemini', target: { kind: 'existing', worktree: 'wt-7' } })
const result = await h.run({
agent: 'gemini',
target: { kind: 'existing', worktree: 'wt-7' },
chatOptions: { model: 'action-model', fastMode: 'false' }
})
expect(h.createTerminalAgent.mock.calls[0]?.[0]).not.toHaveProperty('chatOptions')
// The saved choice reaches the terminal too; its converter keeps only what has a flag.
expect(h.createTerminalAgent.mock.calls[0]?.[0]).toMatchObject({
options: { model: 'action-model', fastMode: 'false' }
})
expect(h.calls).toEqual(['createTerminalAgent'])
expect(result.receipt).toMatchObject({ reason: 'agent_without_structured_session' })
})
+8 -29
View File
@@ -1,6 +1,7 @@
/** Ask after workspace creation: the host needs resolved metadata to decide chat support. */
import { decideAgentLaunchIntentMode } from './agent-launch-intent-mode'
/** Structured launches create the workspace without an agent, then ask its host for chat support.
* The host needs the created workspace to answer; a definitive refusal starts a terminal agent. */
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
import { assertAgentPromptRequestActive } from '../runtime/orca-runtime-core'
import { parsePaneKey } from '../../shared/stable-pane-id'
import type { AgentLaunchResult } from '../../shared/agent-launch-intent'
@@ -18,11 +19,8 @@ import {
settledAtCreation,
settleLaunchPromptDisposal
} from './agent-launch-prompt-delivery'
import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind'
import { isDefinitiveAgentSessionCreateRefusal } from '../../shared/agent-session-definitive-refusal'
import {
decideAgentLaunchMode,
readAgentLaunchModeSettings,
resolveAgentLaunchModeOnHost,
type AgentLaunchModeReceipt,
type AgentLaunchModeSettlement,
@@ -37,6 +35,7 @@ import {
} from './agent-launch-legacy-host'
import {
AgentLaunchStructuredSessionRefusedError,
assertTerminalLaunchAdmissible,
terminalLaunchInputs,
type AgentLaunchStructuredSurface,
type AgentLaunchWorkspaceFactory
@@ -53,31 +52,10 @@ export async function executeAgentLaunch(
execution: AgentLaunchExecution
): Promise<AgentLaunchResult> {
const { intent, runtime } = execution
if (intent.reuseTerminal || intent.target.kind === 'create-worktree') {
assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, intent.sessionOptions)
}
const vocabulary = execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
const preflight =
execution.decidedMode ??
decideAgentLaunchMode({
placement: {
agent: intent.agent,
workspaceKind: workspaceKindForLaunchTarget(intent.target),
...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.target.kind === 'existing' && intent.target.workspacePath
? { workspacePath: intent.target.workspacePath }
: {}),
...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
},
settings: execution.terminalOnly
? undefined
: readAgentLaunchModeSettings(runtime, intent.nativeChatDefault),
...(execution.terminalOnly ? { terminalOnly: true } : {}),
...(intent.surface ? { surface: intent.surface } : {}),
vocabulary
})
// Intent preparation may have outlived the request.
const preflight = execution.decidedMode ?? decideAgentLaunchIntentMode(execution)
assertTerminalLaunchAdmissible(intent, preflight.mode)
// A caller's own preparation (intent resolution) may have outlived its request.
assertAgentPromptRequestActive(execution.signal)
if (execution.promptPolicy === 'legacy-host') {
assertLegacyHostTarget(execution)
@@ -271,6 +249,7 @@ async function createSurface(
worktreeId: workspace.worktreeId,
agent: intent.agent,
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
...(intent.chatOptions ? { chatOptions: intent.chatOptions } : {}),
...(intent.sessionId ? { sessionId: intent.sessionId } : {}),
...(reservedTabId ? { tabId: reservedTabId } : {}),
...launchPromptCreateInputs(intent, execution.signal),
@@ -0,0 +1,38 @@
import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
import { workspaceKindForLaunchTarget } from '../../shared/workspace-launch-kind'
import {
decideAgentLaunchMode,
readAgentLaunchModeSettings,
DEFAULT_LAUNCH_VOCABULARY,
type AgentLaunchModeReceipt,
type AgentLaunchModeVocabulary
} from './agent-launch-mode'
/** The same preflight is used for admission and execution, before workspace creation. */
export function decideAgentLaunchIntentMode(execution: {
intent: AgentLaunchIntent
runtime: Parameters<typeof readAgentLaunchModeSettings>[0]
terminalOnly?: boolean
callerRendersStructured?: boolean
vocabulary?: AgentLaunchModeVocabulary
}): AgentLaunchModeReceipt {
const { intent, runtime } = execution
return decideAgentLaunchMode({
placement: {
agent: intent.agent,
workspaceKind: workspaceKindForLaunchTarget(intent.target),
...(intent.reuseTerminal ? { terminal: intent.reuseTerminal.handle } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.target.kind === 'existing' && intent.target.workspacePath
? { workspacePath: intent.target.workspacePath }
: {}),
...(execution.callerRendersStructured === false ? { callerRendersStructured: false } : {})
},
settings: execution.terminalOnly
? undefined
: readAgentLaunchModeSettings(runtime, intent.nativeChatDefault),
...(execution.terminalOnly ? { terminalOnly: true } : {}),
...(intent.surface ? { surface: intent.surface } : {}),
vocabulary: execution.vocabulary ?? DEFAULT_LAUNCH_VOCABULARY
})
}
@@ -1,10 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import recordings from '../../shared/fixtures/source-control-launch-recordings.json'
import type { AgentLaunchIntent } from '../../shared/agent-launch-intent'
import { executeAgentLaunch } from './agent-launch-executor'
import { toAgentLaunchPreferences } from '../../shared/agent-launch-preferences'
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
import { agentPermissionChoices } from '../../shared/tui-agent-permission-choice'
function harness() {
const readSettings = vi.fn(() => {
throw new Error('settings_not_needed_for_refusal')
throw new Error('settings_unavailable')
})
const createSupport = vi.fn(async () => {
throw new Error('support_not_needed_for_refusal')
@@ -59,12 +63,39 @@ const creating: AgentLaunchIntent = {
}
describe('OpenCode model preferences on unsupported launch placements', () => {
it('keeps own selectable without projecting a meaningless permission into either terminal guard', async () => {
const sessionOptions = { permissionMode: 'own' }
const projected = toAgentLaunchPreferences(sessionOptions, 'opencode')
expect(agentPermissionChoices('opencode')).toContain('own')
expect(projected).toBeUndefined()
// Workspace creation consumes this projection through the same unchanged broad guard.
expect(() => assertOpenCodeModelLaunchPreferencesAbsent('opencode', projected)).not.toThrow()
for (const intent of [reused, creating]) {
const h = harness()
await expect(h.run({ ...intent, sessionOptions })).resolves.toBeDefined()
}
})
it('refuses the real OpenCode model readback on a reused terminal before prompt delivery', async () => {
const model = recordings.opencode.update.message.params.update.configOptions.find(
(option) => option.category === 'model'
)?.currentValue
expect(model).toBe('opencode/big-pickle')
const h = harness()
await expect(h.run({ ...reused, sessionOptions: { model } })).rejects.toMatchObject({
code: 'capability_unsupported'
})
expect(h.createTerminal).not.toHaveBeenCalled()
expect(h.createWorktree).not.toHaveBeenCalled()
expect(h.deliverPrompt).not.toHaveBeenCalled()
})
it.each([reused, creating])(
'refuses before reading settings or creating or delivering',
'preserves definitive refusal without creating or delivering when the route settings read throws',
async (intent) => {
const h = harness()
await expect(h.run(intent)).rejects.toMatchObject({ code: 'capability_unsupported' })
expect(h.readSettings).not.toHaveBeenCalled()
expect(h.readSettings).toHaveBeenCalledOnce()
expect(h.createTerminal).not.toHaveBeenCalled()
expect(h.createWorktree).not.toHaveBeenCalled()
expect(h.deliverPrompt).not.toHaveBeenCalled()
@@ -1,13 +1,27 @@
import type { AgentLaunchIntent, AgentLaunchPrompt } from '../../shared/agent-launch-intent'
/**
* The factories `executeAgentLaunch` is handed: how a surface and a workspace are BUILT once the
* executor has decided which. Separate from the executor because they are the contract each caller
* implements, not part of the sequencing it runs.
*/
import type {
AgentLaunchIntent,
AgentLaunchPrompt,
AgentLaunchMode
} from '../../shared/agent-launch-intent'
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionFirstMessage } from '../../shared/structured-agent-session-create'
import type { TuiAgent } from '../../shared/tui-agent'
import type { AgentLaunchParams } from '../../shared/rpc-contract/agent-launch-params'
import { toAgentLaunchPreferences } from '../../shared/agent-launch-preferences'
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../opencode/opencode-model-startup-plan'
/** Inject creation so orchestration can add subscriptions without duplicating launch sequencing. */
export type AgentLaunchSurfaceFactory = {
createStructuredSession(args: {
worktreeId: string
agent: StructuredAgentId
chatOptions?: Readonly<Record<string, string>>
options?: Readonly<Record<string, unknown>>
/** Caller-minted ID; reject with AgentLaunchSessionAlreadyExistsError if taken. */
sessionId?: string
@@ -111,10 +125,31 @@ export type AgentLaunchWorkspaceFactory = {
}): Promise<{ worktreeId: string; workspacePath?: string; connectionId: string | null }>
}
/** The options a terminal leg starts with: a caller's saved chat choice applies to it too. */
function terminalLaunchOptions(intent: Pick<AgentLaunchIntent, 'sessionOptions' | 'chatOptions'>) {
return intent.sessionOptions ?? intent.chatOptions
}
/** Refuses, before anything exists, an OpenCode terminal its host cannot verify, read as that
* terminal will receive it, after the shared preflight determines the actual route. */
export function assertTerminalLaunchAdmissible(
intent: AgentLaunchIntent | AgentLaunchParams,
mode: AgentLaunchMode
): void {
if (intent.reuseTerminal || intent.target.kind === 'create-worktree') {
const options = mode === 'terminal' ? terminalLaunchOptions(intent) : intent.sessionOptions
const preferences = toAgentLaunchPreferences(options, intent.agent)
assertOpenCodeModelLaunchPreferencesAbsent(intent.agent, preferences)
}
}
/** What every route that builds a terminal agent passes on, so the startup terminal of a new
* workspace and the terminal of an existing one start the same agent. */
export function terminalLaunchInputs(intent: AgentLaunchIntent) {
return {
...(intent.sessionOptions ? { options: intent.sessionOptions } : {}),
// Null explicitly suppresses default arguments.
// Projected for this agent by each terminal builder, which keeps only what has a flag.
options: terminalLaunchOptions(intent),
// `null` is a value the caller meant, so this tests for absence rather than falsiness.
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.launchSource ? { launchSource: intent.launchSource } : {}),
@@ -4,7 +4,7 @@ import { resolveCodexCommand } from '../codex-cli/command'
import { resolveAgainAfterRefreshOnMissingProgram } from '../native-chat/structured-agent-command-resolution'
import type { StructuredAgentLaunchPartResolver } from '../runtime/structured-agent-launch-composition'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { codexStructuredLaunchArgs } from './codex-structured-launch-args'
import {
LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV,
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
import { resolveCodexCommand } from '../codex-cli/command'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { openCodexThread } from './codex-structured-thread-open'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
// The probe is a POSIX shell command.
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1' && process.platform !== 'win32'
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode'
import { resolveCodexCommand } from '../codex-cli/command'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { openCodexThread } from './codex-structured-thread-open'
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1'
@@ -6,7 +6,7 @@ import { resolveCodexCommand } from '../codex-cli/command'
import { NATIVE_CHAT_VISUALS_SKILL_NAME } from '../native-chat/native-chat-visuals-skill-location'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { withCodexVisualsThreadConfig } from './codex-structured-visuals'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
const enabled = process.env.ORCA_REAL_CODEX_CLI_TEST === '1'
const WORKSPACE_POLICY = codexStructuredPermissionPolicy('auto')
@@ -23,7 +23,7 @@ import type { CodexBackgroundTaskTracker } from './codex-background-task-tracker
import type { CodexSkillReport } from './codex-structured-skill-report'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import type { CodexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import type { StructuredLaunchRequest } from '../runtime/structured-agent-launch-composition'
import type {
AgentModelCatalogSessionAccess,
@@ -4,7 +4,7 @@ import { CodexAppServerFrameSizeError } from './app-server/codex-app-server-fram
import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { openCodexThread } from './codex-structured-thread-open'
function connectionFor(
@@ -3,7 +3,7 @@ import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-s
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import type { CodexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import { readCodexThreadId } from './codex-structured-thread-facts'
import { readCodexSandboxPolicy, type CodexSandboxPolicy } from './codex-structured-sandbox-policy'
@@ -8,7 +8,7 @@ import {
import type { AgentChatPermissionChoice } from '../../shared/agent-chat-permission-mode'
import type { CodexSession } from './codex-structured-session-state'
import { readCodexAutoSandbox } from './codex-structured-auto-sandbox'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
import type { CodexSandboxPolicy } from './codex-structured-sandbox-policy'
type PermissionHost = {
@@ -19,7 +19,7 @@ import {
CODEX_VISUALS_SETUP_BUDGET_MS,
prepareCodexThreadForVisuals
} from './codex-structured-visuals'
import { codexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
const VISUALS: NativeChatVisualsLaunch = {
folder: '/state/chat-scratch/abc',
+1 -1
View File
@@ -3,7 +3,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { isCodexMethodNotFoundError } from './codex-app-server-session'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
import type { CodexStructuredPermissionPolicy } from '../../shared/codex-permission-policy'
/** Bound local setup requests so optional visuals do not delay the first prompt indefinitely. */
export const CODEX_VISUALS_SETUP_BUDGET_MS = 2_000
@@ -0,0 +1,50 @@
import { expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { runtimeConversationLauncher } from './external-conversation-start'
it('starts the requested connector agent in a background terminal even with chat enabled', async () => {
const createTerminal = vi.fn(
async (_workspace: string, options: { onStartupPromptCarry?: (carried: boolean) => void }) => {
options.onStartupPromptCarry?.(true)
return { handle: 'connector-terminal' }
}
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real launcher reaches only these runtime I/O boundaries; no agent process is started.
const runtime = {
showTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: 'workspace-1',
path: '/repo/task',
connectionId: null
})),
requireStore: () => ({ getSettings: () => ({ experimentalNativeChat: true }) }),
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
createTerminal
} as unknown as OrcaRuntimeService
const published = vi.fn()
const result = await runtimeConversationLauncher(runtime).launch(
{
agent: 'codex',
target: { kind: 'existing', worktree: 'id:workspace-1' },
prompt: 'fix the test'
},
published,
new AbortController().signal
)
expect(createTerminal).toHaveBeenCalledExactlyOnceWith(
'id:workspace-1',
expect.objectContaining({
startupAgent: 'codex',
startupPrompt: 'fix the test',
presentation: 'background'
})
)
expect(result).toMatchObject({
worktreeId: 'workspace-1',
outcome: { kind: 'terminal', handle: 'connector-terminal' },
receipt: { mode: 'terminal', preferred: 'terminal' },
prompt: { outcome: 'handed-to-terminal' }
})
expect(published).toHaveBeenCalledExactlyOnceWith(result)
})
@@ -34,15 +34,16 @@ export function runtimeConversationLauncher(runtime: OrcaRuntimeService): Conver
const context = { runtime }
return {
launch: async ({ agent, target, prompt }, onSurfacePublished, signal) => {
const intent = await resolveUnlaunchedIntent(
const { intent, decidedMode } = await resolveUnlaunchedIntent(
{ agent, target, prompt: { text: prompt, delivery: 'submit' } },
runtime,
null
null,
{ terminalOnly: true }
)
return executeAgentLaunch({
runtime,
intent,
terminalOnly: true,
decidedMode,
surfaces: agentLaunchSurfaceFactory(context, undefined, undefined, false, undefined, {
early: null,
presentation: 'background'
+4 -2
View File
@@ -109,11 +109,13 @@ export async function planPaneAgentLaunch(ctx: PtyIpcSpawnState): Promise<void>
}
}
}
const intent = await resolveUnlaunchedIntent(params.data, runtime, null)
const { intent } = await resolveUnlaunchedIntent(params.data, runtime, null, {
terminalOnly: true
})
if (intent.target.kind !== 'existing') {
throw new Error('agent_launch_request_invalid')
}
const launchPreferences = toAgentLaunchPreferences(intent.sessionOptions)
const launchPreferences = toAgentLaunchPreferences(intent.sessionOptions, intent.agent)
const planned = await runtime.planAgentPaneLaunch(intent.target.worktree, {
startupAgent: intent.agent,
...(intent.agentArgs !== undefined ? { agentArgs: intent.agentArgs } : {}),
@@ -11,7 +11,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { CodexAppServerRequestError } from '../../codex/app-server/codex-app-server-request-error'
import { codexStructuredPermissionPolicy } from '../../codex/codex-structured-permission-policy'
import { codexStructuredPermissionPolicy } from '../../../shared/codex-permission-policy'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
import { agentSessionSendSubmission as submission } from '../../../shared/agent-session-wire'
@@ -107,6 +107,47 @@ describe('Store', () => {
expect(store.getRepos()).toHaveLength(1)
})
it('keeps saved action choices and sparse repo args through old-profile migration and re-read', async () => {
const recipe = {
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
agentArgs: '',
launchPreferences: {
agentId: 'codex',
model: 'gpt-6-sol',
permissionMode: 'ask',
options: { thinkingEffort: 'high' }
}
}
const sparse = { agentArgs: '--model gpt-6-sol' }
const repo = makeRepo()
writeDataFile({
schemaVersion: 1,
repos: [{ ...repo, sourceControlAi: { actionOverrides: { resolveConflicts: sparse } } }],
worktreeMeta: {},
settings: {
defaultTuiAgent: 'claude',
sourceControlAi: { actions: { fixCommitFailure: recipe } }
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe)
expect(store.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual(sparse)
store.updateSettings({ theme: 'light' })
store.flush()
await closeTestStores()
const reopened = await createStore()
expect(reopened.getSettings().sourceControlAi?.actions?.fixCommitFailure).toEqual(recipe)
expect(reopened.getRepos()[0]?.sourceControlAi?.actionOverrides?.resolveConflicts).toEqual(
sparse
)
})
it('migrates legacy commit-message AI settings to source-control AI on load', async () => {
writeDataFile({
schemaVersion: 1,
@@ -1,3 +1,4 @@
import { migrateActionPermissionArgs } from '../../../shared/source-control-action-permission-lift'
import { homedir } from 'node:os'
import type { PersistedState } from '../../../shared/persisted-state-types'
import type {
@@ -89,12 +90,25 @@ export function prepareLoadedTerminalSettings(
markNeedsSave()
}
const legacyCommitMessageAi = parsed.settings?.commitMessageAi
const migratedSourceControlAi = rawSourceControlAiMissing
let migratedSourceControlAi = rawSourceControlAiMissing
? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi)
: mergeLegacyCommitMessageAiIntoSourceControlAi(
parsed.settings?.sourceControlAi,
legacyCommitMessageAi
)
const actions = migrateActionPermissionArgs(migratedSourceControlAi.actions)
if (actions !== migratedSourceControlAi.actions) {
migratedSourceControlAi = { ...migratedSourceControlAi, actions }
markNeedsSave()
}
migratedExternalVisibility.repos = migratedExternalVisibility.repos.map((repo) => {
const actionOverrides = migrateActionPermissionArgs(repo.sourceControlAi?.actionOverrides)
if (actionOverrides === repo.sourceControlAi?.actionOverrides) {
return repo
}
markNeedsSave()
return { ...repo, sourceControlAi: { ...repo.sourceControlAi, actionOverrides } }
})
// Legacy Option-as-Alt true broke non-US characters; let the layout probe choose once.
const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt
const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true
@@ -1,3 +1,9 @@
import * as permissionLift from '../../shared/tui-agent-permission-lift'
import * as terminalPreparation from './loading-store/prepare-loaded-terminal-settings'
import * as domainWrites from './profile-state/profile-state-domain-writes'
import * as permissionArgs from '../../shared/agent-permission-args'
import * as shell from '../../shared/tui-agent-startup-shell'
import { agentPermissionChoices } from '../../shared/tui-agent-permission-choice'
import {
closeTestStores,
testState,
@@ -80,9 +86,158 @@ describe('load-time normalization durability', () => {
afterEach(async () => {
await closeTestStores()
vi.restoreAllMocks()
rmSync(testState.dir, { recursive: true, force: true })
})
const loadWork: number[][] = []
it.each([32, 320])(
'migrates %i owned rows through actual typed-profile load, flush and no-op reopen',
async (n) => {
const settled = await settledProfile()
await closeTestStores()
const literal = ' --yolo --search '
const conflict = {
agentId: 'claude',
agentArgs: ' --dangerously-skip-permissions ',
launchPreferences: { agentId: 'claude', permissionMode: 'ask' }
} as const
settled.settings.agentPermissionDefault = 'own'
const source = settled.settings.sourceControlAi
if (!source) {
throw new Error('Missing settled Source Control settings')
}
settled.settings.sourceControlAi = {
...source,
actions: {
fixChecks: {
agentId: 'claude',
agentArgs: '--dangerously-skip-permissions --verbose',
launchPreferences: { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } }
},
resolveComments: { agentId: 'codex', agentArgs: literal },
resolveConflicts: { agentId: 'gemini', agentArgs: '--yolo' },
fixPushFailure: { agentArgs: ' --dangerously-skip-permissions ' },
fixCommitFailure: conflict,
commitMessage: { agentId: 'claude', agentArgs: '--dangerously-skip-permissions' }
}
}
settled.repos = Array.from({ length: n }, (_, i) => ({
id: `recipe-${i}`,
displayName: 'recipe',
badgeColor: '',
addedAt: 0,
path: '/tmp/recipe',
sourceControlAi: {
actionOverrides: {
fixChecks: {
agentId: i % 2 ? 'claude' : 'codex',
agentArgs: (i % 2
? '--dangerously-skip-permissions'
: '--dangerously-bypass-approvals-and-sandbox'
).padEnd(64)
},
resolveComments: { agentArgs: ' --dangerously-bypass-approvals-and-sandbox ' },
fixPushFailure: { agentId: null, agentArgs: literal }
}
}
}))
const lift = vi.spyOn(permissionLift, 'liftAgentPermissionArgs')
const cut = vi.spyOn(permissionArgs, 'permissionRecipeCut')
const tokenize = vi.spyOn(shell, 'tokenizeStartupCommand')
const prepare = terminalPreparation.prepareLoadedTerminalSettings
const marks = vi.fn()
vi.spyOn(terminalPreparation, 'prepareLoadedTerminalSettings').mockImplementation(
(parsed, mark) =>
prepare(parsed, () => {
marks()
mark()
})
)
writeDataFile(settled)
const store = createStore()
expect(lift).toHaveBeenCalledTimes(n + 4)
const work = [
cut.mock.calls.length,
tokenize.mock.calls.length,
tokenize.mock.calls.reduce((sum, [text]) => sum + text.length, 0)
]
const choices = Math.max(
...(['claude', 'codex'] as const).map((agent) => agentPermissionChoices(agent).length - 1)
)
expect(work[0]).toBeGreaterThanOrEqual(n)
// Per choice: recipe + choice inventory + three original/cut shell grammars.
expect(work[1]).toBeLessThanOrEqual((n + 4) * choices * (1 + choices + 6))
if (n === 320) {
work.forEach((count, index) => expect(count).toBeLessThanOrEqual(10 * loadWork[0][index]))
}
loadWork.push(work)
process.stdout.write(
`${JSON.stringify({
metric: 'permission-load-work',
rows: n,
lifts: lift.mock.calls.length,
cuts: work[0],
tokens: work[1],
chars: work[2]
})}\n`
)
expect(store.getSettings().sourceControlAi?.actions?.fixChecks).toMatchObject({
agentArgs: '--verbose',
launchPreferences: {
agentId: 'claude',
model: 'sonnet',
options: { effort: 'high' },
permissionMode: 'full-access'
}
})
store.flush()
await closeTestStores()
marks.mockClear()
const reopened = createStore()
expect(marks).not.toHaveBeenCalled()
for (const row of reopened.getRepos()) {
expect(row.sourceControlAi?.actionOverrides?.fixChecks).toMatchObject({
agentArgs: '',
launchPreferences: {
agentId: row.sourceControlAi?.actionOverrides?.fixChecks?.agentId,
permissionMode: 'full-access'
}
})
}
const write = vi.spyOn(domainWrites, 'writeProfileStateDomains')
const actions = reopened.getSettings().sourceControlAi?.actions
expect(actions?.fixChecks?.launchPreferences?.permissionMode).toBe('full-access')
expect(actions?.resolveConflicts).toMatchObject({
agentArgs: '',
launchPreferences: { agentId: 'gemini', permissionMode: 'full-access' }
})
expect(actions?.resolveComments?.agentArgs).toBe(literal)
expect(actions?.fixPushFailure?.agentArgs).toBe(' --dangerously-skip-permissions ')
expect(actions?.fixCommitFailure).toEqual(conflict)
expect(actions?.commitMessage?.agentArgs).toBe('--dangerously-skip-permissions')
const rows = reopened.getRepos()[0]?.sourceControlAi?.actionOverrides
expect(rows?.resolveComments).toEqual(
settled.repos[0]?.sourceControlAi?.actionOverrides?.resolveComments
)
expect(rows?.fixPushFailure).toEqual(
settled.repos[0]?.sourceControlAi?.actionOverrides?.fixPushFailure
)
const stable = readDataFile()
reopened.flush()
expect(readDataFile()).toEqual(stable)
expect(write).toHaveReturnedWith({
changed: false,
revision: expect.any(Number),
changedDomains: []
})
write.mockClear()
reopened.flush()
expect(write).not.toHaveBeenCalled()
}
)
it('persists the legacy search-tab explorer view repair', async () => {
const settled = await settledProfile()
delete (settled.ui as Record<string, unknown>).rightSidebarExplorerView
@@ -17,6 +17,7 @@ import {
} from '../../shared/protocol-version'
import {
resolveWorkspaceAgentDetectionHost,
detectProjectLaunchContext,
type WorkspaceAgentDetectionStore
} from './workspace-agent-detection'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
@@ -142,6 +143,22 @@ describe('resolveWorkspaceAgentDetectionHost on a Windows host', () => {
_resetWslCachesForTests()
})
it('prospective detection owns the project runtime before a workspace exists', async () => {
detectInstalledMock.mockResolvedValueOnce(['codex'])
const target = repo({ id: 'prospective', path: WSL_REPO_PATH })
const result = await detectProjectLaunchContext(store({ repos: [target] }), target)
expect(result).toMatchObject({
workspaceKind: 'git-worktree',
executionContext: {
kind: 'host',
projectRuntime: { status: 'resolved', runtime: { kind: 'wsl' } }
}
})
expect(detectInstalledMock).toHaveBeenCalledWith(
wslContext('Ubuntu-24.04', 'prospective', 'project-override')
)
})
it('keeps the host default for a workspace no repo row owns', () => {
const s = store({ repos: [repo({ id: 'r1', path: 'C:\\r1' })] })
expect(resolveFor(s, 'missing::C:\\x')).toEqual({ kind: 'local' })
@@ -1,3 +1,6 @@
import { isFolderRepo } from '../../shared/repo-kind'
import type { ProjectLaunchContext } from '../../shared/rpc-contract/preflight-params'
import { isTuiAgent } from '../../shared/tui-agent-config'
import { WORKSPACE_ON_OTHER_RUNTIME } from '../../shared/protocol-version'
import { refreshAtRestCommandContext } from '../native-chat/agent-session-wire/at-rest-command-context'
import { captureHostShellEnvironment } from '../runtime/structured-agent-shell-environment'
@@ -157,3 +160,27 @@ export async function refreshAgentsOnHost(host: AgentDetectionHost): Promise<Ref
}
return wsl ? refresh() : refreshAtRestCommandContext(refresh)
}
/** A prospective workspace uses the create route's resolved repo, never a client host guess. */
export async function detectProjectLaunchContext(
store: WorkspaceAgentDetectionStore,
repo: Repo
): Promise<ProjectLaunchContext> {
const host = getRepoExecutionHostId(repo)
const ssh = getSshTargetIdForExecutionHost(host)
if (parseExecutionHostId(host)?.kind === 'runtime') {
throw new Error(WORKSPACE_ON_OTHER_RUNTIME)
}
const projectRuntime = ssh ? undefined : resolveLocalWorkspaceRuntime(store, repo, repo.path)
if (!ssh && !projectRuntime) {
throw new Error('worktree_execution_host_unresolved')
}
const agents = await detectAgentsOnHost(
ssh ? { kind: 'ssh', connectionId: ssh } : { kind: 'local', context: { projectRuntime } }
)
return {
agents: agents.filter(isTuiAgent),
workspaceKind: isFolderRepo(repo) ? 'folder' : 'git-worktree',
executionContext: projectRuntime ? { kind: 'host', projectRuntime } : { kind: 'ssh' }
}
}
@@ -8,6 +8,8 @@ import type {
RuntimeCreateAgentSessionResult
} from '../../shared/agent-session-host-authority'
import { OrcaRuntimeService } from './orca-runtime'
import { buildAgentStartupPlan, buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell'
import type { TerminalCreateOptions } from './runtime-terminal-contracts'
import type { IPtyProvider } from '../providers/pty-provider-contract'
@@ -140,6 +142,69 @@ describe('agent-session execution namespace', () => {
)
})
it('retains compiled policy through args-only host reconstruction and repeated recorded resume', async () => {
const launch = buildAgentStartupPlan({
agent: 'codex',
prompt: '',
allowEmptyPromptLaunch: true,
cmdOverrides: {},
platform: 'linux',
agentArgs: '-m old-model -c model_reasoning_effort=low -c model_verbosity=low',
sessionOptions: { model: 'gpt-5.5', effort: 'high', permissionMode: 'ask' },
sessionOptionsOverrideAgentArgs: true
})
expect(launch?.sessionOptions).toEqual({
model: 'gpt-5.5',
effort: 'high',
permissionMode: 'ask'
})
expect(launch?.launchCommand).not.toContain('old-model')
const policyTokens = [
'codex',
'-c',
'model_verbosity=low',
'-a',
'on-request',
'-s',
'read-only',
'-c',
'approvals_reviewer=user'
]
expect(tokenizeStartupCommand(launch?.launchConfig.agentCommand ?? '', 'posix')).toMatchObject({
ok: true,
tokens: policyTokens
})
const runtime = createRuntime()
const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal())
const providerSession = { key: 'session_id' as const, id: 'provider-session-1' }
await runtime.ensureAgentSession({
kind: 'explicit',
worktree: 'id:worktree-1',
agent: 'codex',
providerSession,
agentArgs: launch?.launchConfig.agentArgs
})
const host = createTerminal.mock.calls[0]?.[1]
expect(tokenizeStartupCommand(host?.command ?? '', 'posix')).toMatchObject({
ok: true,
tokens: [...policyTokens, 'resume', providerSession.id]
})
expect(host?.launchConfig).toEqual(launch?.launchConfig)
const resumeInputs = {
agent: 'codex' as const,
providerSession,
platform: 'linux' as const,
cmdOverrides: {}
}
const resumed = buildAgentResumeStartupPlan({ ...resumeInputs, ...host?.launchConfig })
const resumedAgain = buildAgentResumeStartupPlan({ ...resumeInputs, ...resumed?.launchConfig })
expect(resumedAgain?.launchConfig).toEqual(launch?.launchConfig)
expect(tokenizeStartupCommand(resumedAgain?.launchCommand ?? '', 'posix')).toMatchObject({
ok: true,
tokens: [...policyTokens, 'resume', providerSession.id]
})
})
it('propagates probe getter failure before entering the probe failure fallback', async () => {
const failure = new Error('probe getter failed')
const runtime = createRuntime({
@@ -160,9 +160,16 @@ it('a whitespace-only submit creates an eager empty chat without sending a turn'
expect(rig.adapter.dispatch).not.toHaveBeenCalled()
})
it('the opening prompt owns retry for a legacy-capability CLI caller', async () => {
it('the opening prompt owns retry with explicit model and permission for a legacy-capability CLI caller', async () => {
holdStarts()
const result = await launch(CLI, 'fix the build')
const params = AGENT_LAUNCH.params.parse({
agent: 'codex',
target: { kind: 'existing', worktree: 'id:wt-7' },
sessionId: SESSION,
chatOptions: { model: 'explicit-model', permissionMode: 'full-access' },
prompt: { text: 'fix the build', delivery: 'submit' }
})
const result = await AGENT_LAUNCH.handler(params, rpcContext(launchRuntime(), CLI))
expect(result.prompt).toEqual({
delivery: 'submit',
outcome: 'journaled',
@@ -185,6 +192,11 @@ it('the opening prompt owns retry for a legacy-capability CLI caller', async ()
const snapshot = await rig.host.journalSnapshot(SESSION)
expect(snapshot.submissions).toMatchObject([{ dispatchState: 'accepted' }])
expect(snapshot.items.filter((item) => item.body.kind === 'status')).toEqual([])
expect(rig.store.getRecord(SESSION)).toMatchObject({
options: { model: 'explicit-model' },
permissionMode: 'full-access'
})
expect(rig.store.getRecord(SESSION)?.options).not.toHaveProperty('effort')
})
it('rejects one requester with one failure row when all five children exit', async () => {
@@ -7,6 +7,7 @@ import type { AgentLaunchPublishedSurface } from '../../../agent-launch/agent-la
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcContext } from '../core'
import { clientRendersStructuredAgent } from './structured-agent-session-policy'
/** The paired client whose view this launch should move, or null when it moves the host's. */
export function agentLaunchCallerNavigationId(
@@ -49,3 +50,20 @@ export function selectAgentLaunchTabForCaller(
console.warn('[agent-launch] the launch ran; selecting its tab for the caller did not', error)
}
}
/**
* `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those
* two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart
* offers use; a client that does not gets that agent as a terminal.
*/
export function callerRendersLaunchedChat(
context: Pick<RpcContext, 'clientKind' | 'clientCapabilities'>,
agent: string
): boolean {
return (
context.clientKind === undefined ||
agent === 'claude' ||
agent === 'codex' ||
clientRendersStructuredAgent(context.clientCapabilities, agent)
)
}
@@ -1,9 +1,14 @@
import { decideAgentLaunchIntentMode } from '../../../agent-launch/agent-launch-intent-mode'
/** Resolve selectors and validate terminal reuse without creating a surface. */
import type { AgentLaunchIntent, AgentLaunchTarget } from '../../../../shared/agent-launch-intent'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan'
import { assertTerminalLaunchAdmissible } from '../../../agent-launch/agent-launch-surface-factories'
import type { AgentLaunchParams } from '../../../../shared/rpc-contract/agent-launch-params'
import {
resolveAgentLaunchModeOnHost,
type AgentLaunchModeReceipt
} from '../../../agent-launch/agent-launch-mode'
import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication'
/** Resolve the selector once to avoid id:id: prefixes below the executor.
@@ -36,6 +41,7 @@ async function agentLaunchIntent(
target: await agentLaunchTarget(params, runtime),
...(params.prompt ? { prompt: params.prompt } : {}),
...(params.sessionOptions ? { sessionOptions: params.sessionOptions } : {}),
...(params.chatOptions ? { chatOptions: params.chatOptions } : {}),
...(params.reuseTerminal ? { reuseTerminal: params.reuseTerminal } : {}),
// `null` means "no arguments" and must survive; only absence falls back to the settings default.
...(params.agentArgs !== undefined ? { agentArgs: params.agentArgs } : {}),
@@ -74,12 +80,28 @@ async function validateReusedTerminal(
export async function resolveUnlaunchedIntent(
params: AgentLaunchParams,
runtime: OrcaRuntimeService,
early: EarlyAgentLaunchTab | null
): Promise<AgentLaunchIntent> {
if (params.reuseTerminal || params.target.kind === 'create-worktree') {
assertOpenCodeModelLaunchPreferencesAbsent(params.agent, params.sessionOptions)
}
early: EarlyAgentLaunchTab | null,
modeOptions: { terminalOnly?: boolean; callerRendersStructured?: boolean } = {}
): Promise<{ intent: AgentLaunchIntent; decidedMode: AgentLaunchModeReceipt }> {
const intent = await agentLaunchIntent(params, runtime, early?.paneKey)
await validateReusedTerminal(intent, runtime)
return intent
const preflight = decideAgentLaunchIntentMode({ intent, runtime, ...modeOptions })
const decidedMode =
intent.target.kind === 'existing'
? await resolveAgentLaunchModeOnHost(
runtime,
preflight,
intent.target.worktree,
intent.agent,
undefined,
{
phase: 'before-effects',
agent: intent.agent,
workspacePath: intent.target.workspacePath,
cwd: intent.cwd
}
)
: preflight
assertTerminalLaunchAdmissible(intent, decidedMode.mode)
return { intent, decidedMode }
}
@@ -7,11 +7,14 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation'
import { AGENT_LAUNCH_SURFACE_UNAVAILABLE_CODE } from '../../../../shared/agent-launch-surface'
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../../opencode/opencode-model-startup-plan'
import type { AgentSessionRecordStore } from '../../agent-session-record-store'
import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { RpcDispatcher } from '../dispatcher'
import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options'
import {
CAPABLE_CLIENT,
methodNamed,
runtimeStub,
setAgentLaunchRecordStore,
@@ -38,9 +41,11 @@ type Launch = {
agent: string
target: { kind: string; worktree?: string; create?: Readonly<Record<string, unknown>> }
sessionOptions?: Readonly<Record<string, unknown>>
chatOptions?: Readonly<Record<string, string>>
reuseTerminal?: { handle: string }
cwd?: string
surface?: string
nativeChatDefault?: boolean
}
/** The create throws; `afterDispatch` says whether the spawn request had already left. */
@@ -81,19 +86,23 @@ describe('a launch whose terminal fails', () => {
async function replay(
runtime: AgentLaunchRuntimeStub,
launch: Launch,
operationId: string = OPERATION_ID
operationId: string = OPERATION_ID,
options?: RpcDispatchStreamingOptions
) {
const dispatcher = new RpcDispatcher({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every runtime method reached by agent.launch and dispatcher metadata.
runtime: { ...runtime, getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService,
methods: AGENT_LAUNCH_METHODS
})
return dispatcher.dispatch({
id: 'request-1',
authToken: 'token',
method: 'agent.launchReplay',
params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId })
})
return dispatcher.dispatch(
{
id: 'request-1',
authToken: 'token',
method: 'agent.launchReplay',
params: AGENT_LAUNCH_REPLAY.params.parse({ ...launch, operationId })
},
options
)
}
it('reports a failure before the spawn request with its real cause and records it', async () => {
@@ -232,6 +241,13 @@ describe('a launch whose terminal fails', () => {
it.each([
{ ...CREATE_LAUNCH, agent: 'opencode', sessionOptions: { model: 'private-proof/model-b' } },
// A saved chat choice is what an explicit terminal starts with, so it is refused the same way.
{
...CREATE_LAUNCH,
agent: 'opencode',
surface: 'terminal',
chatOptions: { model: 'private-proof/model-b' }
},
{
...EXISTING_LAUNCH,
agent: 'opencode',
@@ -260,6 +276,66 @@ describe('a launch whose terminal fails', () => {
expect(retry.createManagedWorktree).not.toHaveBeenCalled()
})
it.each([false, true])(
'settles an automatic terminal model refusal (caller downgrade %s)',
async (callerDowngrade) => {
const launch = {
...CREATE_LAUNCH,
agent: 'opencode',
chatOptions: { model: 'anthropic/claude-sonnet-4-6' },
...(callerDowngrade ? {} : { nativeChatDefault: false }),
prompt: { text: 'Reply with OK', delivery: 'submit' }
}
const first = runtimeStub({ settings: { experimentalNativeChat: true } })
first.createManagedWorktree.mockImplementation(async (args) => {
expect(args).toMatchObject({
startupAgent: 'opencode',
startupLaunchPreferences: { model: launch.chatOptions.model }
})
assertOpenCodeModelLaunchPreferencesAbsent('opencode', launch.chatOptions)
return { worktree: { id: 'wt-new' }, startupTerminal: undefined }
})
const caller = callerDowngrade ? CAPABLE_CLIENT : undefined
expect(await replay(first, launch, OPERATION_ID, caller)).toMatchObject({
ok: false,
error: { code: 'capability_unsupported' }
})
expect(outcomeOf(OPERATION_ID)).toMatchObject({ status: 'failed' })
expect(first.createManagedWorktree).not.toHaveBeenCalled()
expect(first.createTerminal).not.toHaveBeenCalled()
const retry = runtimeStub({ settings: {} })
expect(await replay(retry, launch, OPERATION_ID, caller)).toMatchObject({
ok: false,
error: { code: 'capability_unsupported' }
})
expect(retry.createManagedWorktree).not.toHaveBeenCalled()
}
)
it.each([undefined, 'chat'] as const)(
'keeps an OpenCode model create on its chat route %s',
async (surface) => {
const runtime = runtimeStub({ settings: { experimentalNativeChat: true } })
const launch = {
...CREATE_LAUNCH,
agent: 'opencode',
chatOptions: { model: 'anthropic/claude-sonnet-4-6' },
...(surface ? { surface } : {})
}
expect(await replay(runtime, launch)).toMatchObject({
ok: true,
result: { outcome: { kind: 'structured' } }
})
expect(runtime.createManagedWorktree).toHaveBeenCalledTimes(1)
expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).not.toHaveProperty('startupAgent')
expect(runtime.createManagedWorktree.mock.calls[0]?.[0]).not.toHaveProperty(
'startupLaunchPreferences'
)
expect(runtime.createTerminal).not.toHaveBeenCalled()
}
)
it('preserves a successful recorded model launch even when its placement is now refused', async () => {
const launch = AGENT_LAUNCH_REPLAY.params.parse({
...CREATE_LAUNCH,
@@ -52,6 +52,7 @@ export function agentLaunchSurfaceFactory(
worktreeId,
agent,
options,
chatOptions,
sessionId: requested,
tabId,
withoutPrompt,
@@ -65,7 +66,7 @@ export function agentLaunchSurfaceFactory(
definition?.restingOptions.acceptsKey(key)
)
)
const seeded = Object.keys(supported).length > 0 ? supported : undefined
const seeded = chatOptions ?? (Object.keys(supported).length > 0 ? supported : undefined)
// A client that creates chats without starting them starts this one on its first message.
const atRest =
firstMessage !== undefined ||
@@ -91,6 +92,7 @@ export function agentLaunchSurfaceFactory(
},
worktree: `id:${worktreeId}`,
agent,
...(chatOptions?.model ? { optionsMode: 'replace-model-selection' as const } : {}),
...(seeded ? { options: seeded } : {}),
...(tabId ? { tabId } : {}),
...(atRest ? { atRest: true as const } : {}),
@@ -130,7 +132,7 @@ export function agentLaunchSurfaceFactory(
viewMode,
accountEnv
}) => {
const launchPreferences = toAgentLaunchPreferences(options)
const launchPreferences = toAgentLaunchPreferences(options, agent)
let promptRodeLaunchCommand = false
const hasEarlyTab = earlyTab !== null
if (earlyTab?.closedByUser()) {
@@ -48,7 +48,8 @@ export function agentLaunchWorkspaceFactory(
options,
accountEnv
}) => {
const startupLaunchPreferences = toAgentLaunchPreferences(options)
const startupLaunchPreferences =
startupAgent && toAgentLaunchPreferences(options, startupAgent)
let promptRodeLaunchCommand = false
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: already validated by `AgentLaunch`; the executor only removed the reserved agent fields, so the rest of the payload is the parsed shape.
const params = create as WorktreeCreateParams
+21 -26
View File
@@ -5,7 +5,7 @@
import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability'
import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed'
import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation'
import type { AgentLaunchIntent, AgentLaunchResult } from '../../../../shared/agent-launch-intent'
import type { AgentLaunchResult } from '../../../../shared/agent-launch-intent'
import { agentSessionOperationKey } from '../../../../shared/agent-session-operation-ledger'
import {
WorktreeCreateCollisionError,
@@ -39,11 +39,11 @@ import {
} from './agent-launch-failure-code'
import {
agentLaunchCallerNavigationId,
callerRendersLaunchedChat,
selectAgentLaunchTabForCaller
} from './agent-launch-caller-selection'
import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation'
import { assertAgentLaunchTargetAuthorized } from './agent-launch-target-authorization'
import { clientRendersStructuredAgent } from './structured-agent-session-policy'
import { resolveUnlaunchedIntent } from './agent-launch-intent-resolution'
import {
publishEarlyTab,
@@ -61,19 +61,6 @@ export function supportsAgentLaunch(
)
}
/** v2 covers Claude/Codex chats; other agents require the same explicit rendering capability used by tabs and restart offers, else use terminal. */
function callerRendersLaunchedChat(
context: Pick<RpcContext, 'clientKind' | 'clientCapabilities'>,
agent: string
): boolean {
return (
context.clientKind === undefined ||
agent === 'claude' ||
agent === 'codex' ||
clientRendersStructuredAgent(context.clientCapabilities, agent)
)
}
/** What a launch admitted under an operation id carries into its execution. */
type ReplaySafeLaunch = {
attachOperationId: string
@@ -84,7 +71,7 @@ type ReplaySafeLaunch = {
}
async function runAgentLaunch(
intent: AgentLaunchIntent,
{ intent, decidedMode }: Awaited<ReturnType<typeof resolveUnlaunchedIntent>>,
context: RpcContext,
view: AgentLaunchView,
replaySafe?: ReplaySafeLaunch
@@ -93,6 +80,7 @@ async function runAgentLaunch(
const result = await executeAgentLaunch({
runtime: context.runtime,
intent,
decidedMode,
surfaces: agentLaunchSurfaceFactory(
context,
replaySafe?.attachOperationId,
@@ -102,7 +90,6 @@ async function runAgentLaunch(
view
),
workspaces: agentLaunchWorkspaceFactory(context, intent.agent),
...(callerRendersLaunchedChat(context, intent.agent) ? {} : { callerRendersStructured: false }),
// The tab is shown as it is published, not after a prompt that can take a minute to land.
onSurfacePublished: (surface) => {
view.early?.surfacePublished(surface)
@@ -123,10 +110,16 @@ function runLegacyAgentLaunch(
): Promise<AgentLaunchResult> {
// No early tab, as before: only a replay-safe launch shows its tab before its agent exists.
const execute = async () =>
runAgentLaunch(await resolveUnlaunchedIntent(params, context.runtime, null), context, {
early: null,
presentation: params.presentation
})
runAgentLaunch(
await resolveUnlaunchedIntent(params, context.runtime, null, {
callerRendersStructured: callerRendersLaunchedChat(context, params.agent)
}),
context,
{
early: null,
presentation: params.presentation
}
)
if (params.target.kind === 'create-worktree' && params.target.create.clientMutationId) {
const { repo, repoHostId } = params.target.create
return context.runtime.dedupeWorktreeCreate(
@@ -202,17 +195,19 @@ async function executeAdmittedAgentLaunch(
>,
view: AgentLaunchView
): Promise<AgentLaunchResult> {
let intent: AgentLaunchIntent
let resolved: Awaited<ReturnType<typeof resolveUnlaunchedIntent>>
try {
intent = await resolveUnlaunchedIntent(params, context.runtime, view.early)
resolved = await resolveUnlaunchedIntent(params, context.runtime, view.early, {
callerRendersStructured: callerRendersLaunchedChat(context, params.agent)
})
} catch (error) {
await settleQuietly(admission.fail(agentLaunchFailureCode(error)))
await settleQuietly(admission.fail(agentLaunchFailureCode(error), error))
throw error
}
const terminalSpawn = trackTerminalSpawnDispatch()
let result: AgentLaunchResult
try {
result = await runAgentLaunch(intent, context, view, {
result = await runAgentLaunch(resolved, context, view, {
attachOperationId: admission.attachOperationId,
callerKey: admission.callerKey,
terminalSpawn,
@@ -224,7 +219,7 @@ async function executeAdmittedAgentLaunch(
}
const failedWithoutEffects = launchFailureWithoutEffectsCode(
error,
intent.target.kind,
resolved.intent.target.kind,
terminalSpawn
)
if (failedWithoutEffects) {
@@ -145,6 +145,32 @@ describe('preflight RPC methods', () => {
expect(resolveAgentDetectionHost).not.toHaveBeenCalled()
})
it('routes a project through its launch context and rejects mixed selectors before detection', async () => {
const context = { agents: ['codex'], workspaceKind: 'git', executionContext: { kind: 'host' } }
const detectProjectLaunchContext = vi.fn().mockResolvedValue(context)
const resolveAgentDetectionHost = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher reads only these runtime members.
const runtime = {
getRuntimeId: () => 'runtime',
detectProjectLaunchContext,
resolveAgentDetectionHost
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
const project = { repo: 'r1' }
const response = await dispatcher.dispatch(makeRequest('preflight.detectAgents', { project }))
expect(response).toMatchObject({ ok: true, result: context })
expect(detectProjectLaunchContext).toHaveBeenCalledExactlyOnceWith('r1')
detectProjectLaunchContext.mockClear()
for (const selector of [{ repoId: 'r1' }, { worktreeId: 'r1::/work' }]) {
const mixed = await dispatcher.dispatch(
makeRequest('preflight.detectAgents', { project, ...selector })
)
expect(mixed).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
}
expect(detectProjectLaunchContext).not.toHaveBeenCalled()
expect(resolveAgentDetectionHost).not.toHaveBeenCalled()
})
it.each(['preflight.detectAgents', 'preflight.refreshAgents'])(
'%s never probes locally after repository ownership refuses',
async (method) => {
+7 -2
View File
@@ -9,6 +9,7 @@ import {
} from '../../../preflight/workspace-agent-detection'
import {
PreflightAgentDetection,
PreflightProjectAgentDetection,
PreflightCheck,
PreflightDetectRemoteAgents,
PreflightDetectRemoteWindowsTerminalCapabilities
@@ -24,10 +25,14 @@ export const PREFLIGHT_METHODS = [
defineMethod({
name: 'preflight.detectAgents',
permission: 'workspace',
params: PreflightAgentDetection,
params: PreflightProjectAgentDetection,
// Only the host knows the workspace's actual project runtime, including WSL.
handler: async (params, { runtime }) =>
detectAgentsOnHost(await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId))
params.project
? runtime.detectProjectLaunchContext(params.project.repo)
: detectAgentsOnHost(
await runtime.resolveAgentDetectionHost(params.worktreeId, params.repoId)
)
}),
defineMethod({
name: 'preflight.detectRemoteAgents',
@@ -87,6 +87,7 @@ export async function handleStructuredAgentSessionCreate(
structuredAgentCliSourceEvidence: ctx.structuredAgentCliSourceEvidence,
...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {}),
...(params.tabId ? { tabId: params.tabId } : {}),
...(params.optionsMode ? { optionsMode: params.optionsMode } : {}),
...(params.options ? { options: params.options } : {}),
...(params.firstMessage ? { firstMessage: params.firstMessage } : {}),
atRest: Boolean(
@@ -42,7 +42,11 @@ afterEach(async () => {
vi.restoreAllMocks()
})
function params(options?: Readonly<Record<string, string>>, withMessage = false) {
function params(
options?: Readonly<Record<string, string>>,
withMessage = false,
optionsMode?: 'replace-model-selection'
) {
return structuredAgentSessionCreateParams({
sessionId: SESSION,
worktree: 'id:workspace-1',
@@ -50,6 +54,7 @@ function params(options?: Readonly<Record<string, string>>, withMessage = false)
randomUuid: () => '00000000-0000-0000-0000-0000000000fa',
now: NOW,
...(options ? { options } : {}),
...(optionsMode ? { optionsMode } : {}),
...(withMessage
? {
firstMessage: { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening') }
@@ -191,20 +196,20 @@ it.each(['firstMessage', 'options'] as const)(
}
)
it('merges held option overrides with host seeds before the first acquisition', async () => {
const input = params({ model: 'held-model' })
it('replaces unrelated host model defaults before the first acquisition', async () => {
const input = params({ model: 'held-model' }, false, 'replace-model-selection')
expect(await call('agentSession.create', input, capable)).toMatchObject({
ok: true,
result: { ok: true }
})
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' })
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model' })
expect(rig.adapter.acquire).not.toHaveBeenCalled()
expect(await rig.host.send({ callerKey: 'client-1' }, restTestSend('first work'))).toMatchObject({
ok: true
})
await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce())
expect(rig.adapter.acquire).toHaveBeenCalledWith(
expect.objectContaining({ options: { model: 'held-model', effort: 'medium' } })
expect.objectContaining({ options: { model: 'held-model' } })
)
})
@@ -234,6 +239,32 @@ it('keeps the seeded permission mode beside explicit picks on an acquired create
)
})
it('keeps the remembered model under a permission mode picked alone on an acquired create', async () => {
// The not-at-rest path `agent.launch` with a prompt takes.
const resolveStructuredAgentSessionCreateIntent = async (input: { envelope: unknown }) => ({
envelope: input.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
options: { model: 'saved', effort: 'low', permissionMode: 'full-access' },
runtimeKind: 'native'
})
expect(
await call('agentSession.create', params({ permissionMode: 'ask' }), legacy, {
resolveStructuredAgentSessionCreateIntent
})
).toMatchObject({ ok: true, result: { ok: true } })
expect(rig.adapter.acquire).toHaveBeenCalledWith(
expect.objectContaining({ options: { model: 'saved', effort: 'low' }, permissionMode: 'ask' })
)
})
it('rejects an explicit option the resting agent would reject before founding', async () => {
expect(
await call('agentSession.create', params({ unknownOption: 'yes' }), capable)
@@ -249,7 +280,7 @@ it('rejects an explicit option the resting agent would reject before founding',
it.each(['options', 'firstMessage'] as const)(
'rejects changes to firstMessage or explicit options under a replayed create operation: %s',
async (field) => {
const original = params({ model: 'held-model' }, true)
const original = params({ model: 'held-model' }, true, 'replace-model-selection')
// Keep delivery held while reusing the create id with different intent.
expect(
await call('agentSession.create', original, capable, {
@@ -258,7 +289,7 @@ it.each(['options', 'firstMessage'] as const)(
}
})
).toMatchObject({ ok: true, result: { ok: false } })
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' })
expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model' })
const first = original.firstMessage
if (!first) {
throw new Error('fixture first message missing')
@@ -272,6 +303,7 @@ it.each(['options', 'firstMessage'] as const)(
agent: original.agent,
now: NOW,
randomUuid: () => '00000000-0000-0000-0000-0000000000fa',
optionsMode: original.optionsMode,
options: field === 'options' ? { model: 'other-model' } : original.options,
firstMessage:
field === 'firstMessage' ? { ...first, body: hostTestMessage('changed') } : first
@@ -284,6 +316,34 @@ it.each(['options', 'firstMessage'] as const)(
}
)
it.each([undefined, 'replace-model-selection'] as const)(
'declared composition %s preserves ordinary defaults only when absent',
async (optionsMode) => {
const input = params({ model: 'held-model', effort: 'high' }, false, optionsMode)
await call('agentSession.create', input, capable, {
resolveStructuredAgentSessionCreateIntent: async ({ envelope }: { envelope: unknown }) => ({
envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' },
options: { model: 'saved', effort: 'low', fastMode: 'false' },
runtimeKind: 'native'
})
})
expect(rig.store.getRecord(SESSION)?.options).toEqual({
model: 'held-model',
effort: 'high',
...(optionsMode ? {} : { fastMode: 'false' })
})
}
)
it('answers an old client whose start failed before spawning in plain words', async () => {
const raw = 'codex sessions pin CODEX_HOME, not CLAUDE_CONFIG_DIR'
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -1,3 +1,4 @@
import { seededPermissionMode } from '../../../../shared/native-chat-session-option-defaults'
// Resolve the host launch identity, then commit an at-rest create or the compatible acquired path.
import { assertAgentPromptRequestActive } from '../../orca-runtime-core'
@@ -54,6 +55,7 @@ export function structuredAgentSessionCreateIntentFingerprint(params: {
resumeFrom?: StructuredAgentSessionResumeSource
tabId?: string
firstMessage?: StructuredAgentSessionFirstMessage
optionsMode?: 'replace-model-selection'
options?: Readonly<Record<string, string>>
}): string {
return computeAgentSessionPayloadFingerprint({
@@ -65,6 +67,7 @@ export function structuredAgentSessionCreateIntentFingerprint(params: {
resumeFrom: params.resumeFrom,
tabId: params.tabId,
firstMessage: params.firstMessage,
optionsMode: params.optionsMode,
options: params.options
}
})
@@ -82,6 +85,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
resumeFrom?: StructuredAgentSessionResumeSource
structuredAgentCliSourceEvidence?: StructuredAgentCliSourceEvidence
/** Explicit picks override seed keys at rest; acquired in-process launches replace the seed. */
optionsMode?: 'replace-model-selection'
options?: Readonly<Record<string, string>>
/** Use the caller-reserved tab id at publication; absent, derive the legacy id. Keep it outside launch fingerprints. */
tabId?: string
@@ -133,15 +137,13 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
throw new AgentSessionRefusalError(refusal)
}
}
const {
agent: _resolvedAgent,
provider: _resolvedProvider,
hostLaunchDirectory,
...resolvedAttach
} = resolved
const { agent: _agent, provider: _provider, hostLaunchDirectory, ...resolvedAttach } = resolved
// Fingerprint explicit picks, never replay-varying defaults; explicit options replace saved seeds, while permission default stays chat-owned.
const overlays = atRest
? args.optionsMode !== 'replace-model-selection'
: Object.keys(args.options ?? {}).every((id) => id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID)
const picked = args.options
? atRest
? overlays
? { ...resolved.options, ...args.options }
: { ...seededPermissionMode(resolved.options), ...args.options }
: resolved.options
@@ -291,6 +293,7 @@ export async function createStructuredAgentSessionForWorktree(args: {
worktree: string
agent: StructuredAgentId
activate: boolean
optionsMode?: 'replace-model-selection'
options?: Readonly<Record<string, string>>
tabId?: string
/** Create without starting the agent; its first message starts it. */
@@ -319,10 +322,3 @@ export async function createStructuredAgentSessionForWorktree(args: {
activate: args.activate
})
}
function seededPermissionMode(
options: Readonly<Record<string, string>> | undefined
): Record<string, string> {
const mode = options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID]
return mode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : {}
}
@@ -1,3 +1,4 @@
import { detectProjectLaunchContext } from '../../preflight/workspace-agent-detection'
import { OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree } from '../terminal/launch/terminal-workspace-scope'
import type { ResolvedWorktree } from '../runtime-worktree-path-identity'
import { splitWorktreeIdForFilesystem, worktreeIdentityKey } from '../../../shared/worktree/id'
@@ -202,6 +203,11 @@ export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBr
throw new Error('repo_not_found')
}
async detectProjectLaunchContext(repoSelector: string) {
const repo = await this.resolveRepoSelector(repoSelector)
return detectProjectLaunchContext(this.requireStore(), repo)
}
requireStore(): RuntimeStore {
if (!this.store) {
throw new Error('runtime_unavailable')
@@ -15,6 +15,7 @@ export type StructuredAgentSessionLaunchView = {
kind: 'new' | 'resume'
/** The encoded selection the launch seeded, shown until the host names the model. */
seedOptions?: Readonly<Record<string, string>>
explicitOptions?: Readonly<Record<string, string>>
/** Picks the launch holds and applies before it publishes. */
heldOptions: Readonly<Record<string, string>>
/** Where the chat runs; its own config may replace the listed default. */
@@ -79,10 +80,11 @@ function useLaunchView(
...(selection?.seed ? { seedOptions: selection.seed } : {}),
...(seedPending ? { seedPending: true as const } : {}),
heldOptions: held,
...(selection?.explicit ? { explicitOptions: selection.explicit } : {}),
...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {})
}
: undefined,
[held, intent.kind, selection?.seed, seedPending, worktreeId]
[held, intent.kind, selection?.seed, selection?.explicit, seedPending, worktreeId]
)
}
@@ -98,6 +98,7 @@ function renderNewChat(args: {
agent: AgentType
target?: RuntimeClientTarget
seedOptions?: Record<string, string>
explicitOptions?: Record<string, string>
sessionId?: string
}) {
const frames: Pill[] = []
@@ -117,6 +118,7 @@ function renderNewChat(args: {
kind: 'new',
heldOptions: {},
worktree: 'id:wt-1',
...(args.explicitOptions ? { explicitOptions: args.explicitOptions } : {}),
...(args.seedOptions ? { seedOptions: args.seedOptions } : {})
}
})
@@ -149,6 +151,21 @@ describe('a new chat picker’s first frame', () => {
resetHostModelCatalogSnapshotsForTests()
})
it('shows the unlisted action model rather than a saved or listed replacement on every pending frame', async () => {
catalogAnswers(() => Promise.resolve(CLAUDE_LIST))
await preloadHostModelCatalogSnapshots(LOCAL_TARGET, ['claude'])
const view = renderNewChat({
agent: 'claude',
seedOptions: { model: 'opus[1m]', effort: 'high' },
explicitOptions: { model: 'unlisted-action' }
})
await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2))
expect(
view.frames.every((frame) => frame.model === 'unlisted-action' && frame.effort !== 'high')
).toBe(true)
view.unmount()
})
it('names a saved Claude pick and its effort from the saved list on the first frame', async () => {
catalogAnswers(() => Promise.resolve(CLAUDE_LIST))
await preloadHostModelCatalogSnapshots(LOCAL_TARGET, ['claude'])
@@ -1,7 +1,10 @@
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { toast } from 'sonner'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire'
import type { AgentType } from '../../../../shared/agent-status-types'
import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog'
import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options'
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
@@ -9,25 +12,21 @@ import {
canSetStructuredAgentSessionOption,
commitStructuredAgentSessionOptionValues,
lockedStructuredAgentSessionOptionSnapshot,
structuredAgentSessionOptionSnapshot
structuredAgentSessionOptionSnapshot,
type StructuredAgentSessionOptionState
} from '../../../../shared/structured-agent-session-options'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import { useRememberSessionOptionPicks } from './use-structured-agent-session-option-picks'
import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec'
import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate'
import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade'
import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state'
import { agentSessionWriteFailureText } from './agent-session-write-notice-text'
import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch'
import {
getStructuredAgentSessionLaunchSelection,
holdStructuredAgentSessionLaunchOption,
type StructuredLaunchOptionOutcome
} from '@/lib/structured-agent-session-launch-options'
const NO_HELD_OPTIONS: Readonly<Record<string, string>> = {}
export function useStructuredAgentSessionOptions(args: {
export type StructuredAgentSessionOptionsArgs = {
agent: AgentType
sessionId: string
target: RuntimeClientTarget
@@ -42,7 +41,11 @@ export function useStructuredAgentSessionOptions(args: {
unloadedTurnRevisions: number | undefined
mutate: StructuredAgentSessionMutate
launch?: StructuredAgentSessionLaunchView
}) {
}
const NO_HELD_OPTIONS: Readonly<Record<string, string>> = {}
export function useStructuredAgentSessionOptions(args: StructuredAgentSessionOptionsArgs) {
const {
agent,
fence,
@@ -115,6 +118,12 @@ export function useStructuredAgentSessionOptions(args: {
receiveCatalog
})
const pendingView = useCallback(
(state: StructuredAgentSessionOptionState) =>
optionView(state, launchSeedOptions, held, launch?.explicitOptions),
[held, launch?.explicitOptions, launchSeedOptions, optionView]
)
const rememberOptionPicks = useRememberSessionOptionPicks(target, agent)
const sendStructuredOption = useCallback(
async (id: string, encoded: string): Promise<boolean> => {
@@ -210,11 +219,11 @@ export function useStructuredAgentSessionOptions(args: {
lockSnapshotRef.current = lockSnapshot
})
const optionSnapshot = lockSnapshot(
structuredAgentSessionOptionSnapshot(optionView(optionState, launchSeedOptions, held))
structuredAgentSessionOptionSnapshot(pendingView(optionState))
)
const setStructuredOption = useCallback(
async (id: string, value: string | boolean): Promise<boolean> => {
const view = optionView(optionStateRef.current, launchSeedOptions, held)
const view = pendingView(optionStateRef.current)
const pickState = {
...view,
pendingId: providerStarting || pendingOptionsRef.current[id] === undefined ? null : id
@@ -246,13 +255,11 @@ export function useStructuredAgentSessionOptions(args: {
},
[
fence,
held,
launchSeedOptions,
optionCatalog,
isCurrentContext,
optionStateRef,
optionView,
pendingOptionsRef,
pendingView,
providerStarting,
sendStructuredOption,
sessionId,
@@ -1,3 +1,4 @@
import { structuredLaunchRequestedOptions } from '../../../../shared/native-chat-session-option-defaults'
import { useCallback } from 'react'
import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view'
import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options'
@@ -20,7 +21,15 @@ export function useStructuredAgentSessionSendOptions(
}
// Read held picks directly because launch acceptance can precede this render’s props.
const selection = getStructuredAgentSessionLaunchSelection(sessionId)
// A pick on its way resolves as the picker shows it.
const requested = structuredLaunchRequestedOptions({
seed: selection?.seed ?? launch?.seedOptions,
explicit: selection?.explicit ?? launch?.explicitOptions,
held: { ...(selection?.held ?? launch?.heldOptions), ...pendingOptionsRef.current }
})
if (requested?.model !== undefined) {
return requested
}
// A pick on its way resolves as the picker shows it, keeping the model it names.
const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, {
...(selection?.held ?? launch?.heldOptions),
...pendingOptionsRef.current
@@ -64,15 +64,7 @@ export function ChecksFixDialog({
)
)
}}
onStart={({ agent, commandInput, agentArgs }) =>
startFixChecksFromDialog({
targetRepoId,
item,
agent,
commandInput,
agentArgs
})
}
onStart={(launch) => startFixChecksFromDialog({ targetRepoId, item, ...launch })}
/>
)
}
@@ -54,6 +54,8 @@ export async function startFixChecksFromDialog(args: {
commandInput: string
/** Omitted when CLI arguments do not apply, so the launch resolves the global Agents setting. */
agentArgs?: string
sessionOptions?: Readonly<Record<string, string>>
sessionOptionsYieldToAgentArgs?: true
}): Promise<boolean> {
if (!args.targetRepoId) {
return false
@@ -66,6 +68,8 @@ export async function startFixChecksFromDialog(args: {
promptDelivery: 'submit-after-ready',
agentOverride: args.agent,
agentArgs: args.agentArgs,
...(args.sessionOptions ? { sessionOptions: args.sessionOptions } : {}),
sessionOptionsYieldToAgentArgs: args.sessionOptionsYieldToAgentArgs,
openModalFallback: () => {
toast.error(
translate(
@@ -234,24 +234,35 @@ describe('SourceControlAgentActionDialog', () => {
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
agentArgs: ''
agentArgs: '--dangerously-bypass-approvals-and-sandbox'
})
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(container.textContent).not.toContain('Launch agent')
})
it('hides the dialog and auto-starts once when the saved repo launch recipe matches', async () => {
resetStore(
settingsWithGlobalRecipe({ agentId: 'claude', commandInputTemplate: '{basePrompt}' }),
[repoWithSavedRecipe()]
)
renderControlledDialog({ repoId: 'repo-1' })
it('auto-starts a sparse repo recipe without giving it inherited model ownership', async () => {
const settings = settingsWithGlobalRecipe({
agentId: 'codex',
commandInputTemplate: '{basePrompt}',
launchPreferences: { agentId: 'codex', model: 'gpt-5.6-terra' }
})
const repo = repoWithSavedRecipe()
const sparse = { agentArgs: '--model gpt-5.6-sol' }
repo.sourceControlAi = { actionOverrides: { resolveConflicts: sparse } }
resetStore(settings, [repo])
renderControlledDialog({ repoId: 'repo-1', savedAgentArgs: sparse.agentArgs })
expect(container.textContent).not.toContain('Launch agent')
await vi.waitFor(() => expect(mocks.onStart).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(mocks.onOpenChange).toHaveBeenCalledWith(false))
expect(mocks.ensureDetectedAgents).toHaveBeenCalledTimes(1)
expect(mocks.onLaunched).toHaveBeenCalledTimes(1)
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
agentArgs: `--dangerously-bypass-approvals-and-sandbox ${sparse.agentArgs}`,
sessionOptions: { model: 'gpt-5.6-terra' },
sessionOptionsYieldToAgentArgs: true
})
expect(mocks.onSaveAgentDefault).not.toHaveBeenCalled()
expect(repo.sourceControlAi.actionOverrides?.resolveConflicts).toEqual(sparse)
expect(container.textContent).not.toContain('Launch agent')
})
it('keeps saved arguments on a prospective SSH terminal launch', async () => {
@@ -279,7 +290,7 @@ describe('SourceControlAgentActionDialog', () => {
expect(mocks.onStart).toHaveBeenCalledWith({
agent: 'codex',
commandInput: 'Resolve conflicts.',
agentArgs: '--model saved'
agentArgs: '--dangerously-bypass-approvals-and-sandbox --model saved'
})
})
// Paired-server agents belong to the workspace owner; the client list cannot detect them (#21429).
@@ -53,6 +53,9 @@ export type SourceControlAgentActionDialogProps = {
commandInput: string
/** Omitted when CLI arguments do not apply to this launch, so it resolves the global setting. */
agentArgs?: string
/** The action's saved choice, on either route. */
sessionOptions?: Readonly<Record<string, string>>
sessionOptionsYieldToAgentArgs?: true
}) => boolean | Promise<boolean>
}
@@ -81,6 +84,7 @@ export function SourceControlAgentActionDialog(
statusCopy,
agentArgs,
agentArgsApply,
recipe,
commandTemplate,
saveLaunchRecipe,
saveTargetValue,
@@ -118,6 +122,7 @@ export function SourceControlAgentActionDialog(
statusCopy={statusCopy}
agentArgs={agentArgs}
agentArgsApply={agentArgsApply}
recipe={recipe}
commandTemplate={commandTemplate}
savedCommandInputTemplate={savedCommandInputTemplate}
saveLaunchRecipe={saveLaunchRecipe}
@@ -61,6 +61,11 @@ function renderForm(
statusCopy: null,
agentArgs: '',
agentArgsApply: true,
recipe: {
agentId: overrides.selectedAgent ?? 'codex',
commandInputTemplate: overrides.commandTemplate ?? '{basePrompt}',
agentArgs: overrides.agentArgs ?? ''
},
commandTemplate: '{basePrompt}',
savedCommandInputTemplate: '{basePrompt}',
saveLaunchRecipe: true,
@@ -119,6 +124,35 @@ describe('SourceControlAgentActionDialogForm', () => {
expect(markup).toContain('Resolve the merge conflicts reported for this pull request.')
})
it('names the saved choice on chat and terminal launches alike', () => {
const recipe = {
agentId: 'codex' as const,
launchPreferences: {
agentId: 'codex' as const,
model: 'saved',
permissionMode: 'ask' as const
}
}
expect(renderForm({ recipe, agentArgsApply: true })).toContain(
'Older servers use their own permission settings.'
)
expect(
renderForm({
recipe: { ...recipe, launchPreferences: { agentId: 'codex', model: 'saved' } },
agentArgsApply: false
})
).toContain('Model from Settings: saved')
expect(
renderForm({
selectedAgent: 'antigravity',
recipe: {
agentId: 'antigravity',
launchPreferences: { agentId: 'antigravity', permissionMode: 'ask' }
}
})
).toContain('Applies to chats only')
})
it('offers agents only, since an action cannot start without one', () => {
expect(renderForm()).toContain('data-allow-blank="false"')
})
@@ -133,6 +167,7 @@ describe('SourceControlAgentActionDialogForm', () => {
const globalMarkup = renderForm({
settings,
recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}' },
repo: repoWithoutSavedRecipe,
saveTargets,
saveTargetValue: 'global'
@@ -13,14 +13,18 @@ import {
} from '@/components/ui/select'
import type { AgentCatalogEntry } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { SourceControlLaunchActionId } from '../../../../shared/source-control-ai-actions'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { SourceControlAgentCliArgsField } from './SourceControlAgentCliArgsField'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
import { describeSourceControlActionModel } from '../settings/source-control-action-model-copy'
import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match'
import type { SourceControlAgentScopeNote } from './source-control-agent-action-dialog-result'
import { translate } from '@/i18n/i18n'
@@ -41,6 +45,7 @@ type SourceControlAgentActionDialogFormProps = {
agentArgs: string
/** False when the launch would be structured native chat; the field is then absent, not disabled. */
agentArgsApply: boolean
recipe: SourceControlActionRecipe
commandTemplate: string
savedCommandInputTemplate?: string | null
saveLaunchRecipe: boolean
@@ -87,6 +92,7 @@ export function SourceControlAgentActionDialogForm({
statusCopy,
agentArgs,
agentArgsApply,
recipe,
commandTemplate,
savedCommandInputTemplate,
saveLaunchRecipe,
@@ -110,19 +116,13 @@ export function SourceControlAgentActionDialogForm({
}: SourceControlAgentActionDialogFormProps): React.JSX.Element {
const defaultCommandTemplate = savedCommandInputTemplate ?? '{basePrompt}'
const commandTemplateIncludesBasePrompt = commandTemplate.includes('{basePrompt}')
const selectedRecipe = selectedAgent
? {
agentId: selectedAgent,
commandInputTemplate: commandTemplate,
agentArgs
}
: null
const selectedRecipe = recipe
const selectedSaveTarget = sourceControlLaunchSaveTargetFromValue(saveTargetValue, repo)
// Copy describes only the selected save target, which is the only one start/save writes.
const selectedLaunchRecipeAlreadySaved = Boolean(
selectedRecipe &&
selectedSaveTarget &&
sourceControlActionRecipeMatchesTarget({
sourceControlActionRecipeSaveMatchesTarget({
actionId,
target: selectedSaveTarget,
recipe: selectedRecipe,
@@ -187,6 +187,10 @@ export function SourceControlAgentActionDialogForm({
<TriangleAlert className="mt-px size-3 shrink-0" />
<span>{statusCopy}</span>
</p>
) : selectedAgent && selectedRecipe.launchPreferences?.agentId === selectedAgent ? (
<p className="text-[11px] text-muted-foreground">
{describeSourceControlActionModel(selectedAgent, selectedRecipe.launchPreferences)}
</p>
) : null}
</div>
@@ -19,19 +19,12 @@ export function SourceControlAgentCliArgsField({
return (
<div className="space-y-2">
<Label htmlFor="source-control-agent-cli-args" className="text-xs">
{translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.bc8dc39f4b',
'CLI arguments'
)}
{translate('settings.sourceControl.actionModel.extraArgs', 'Extra CLI arguments')}
</Label>
<Input
id="source-control-agent-cli-args"
value={value}
spellCheck={false}
placeholder={translate(
'auto.components.right.sidebar.SourceControlAgentActionDialogForm.fe119187bb',
'--model sonnet'
)}
onChange={(event) => onChange(event.target.value)}
className="h-8 font-mono text-xs"
/>
@@ -29,6 +29,15 @@ vi.mock('sonner', () => ({
}))
import { runSourceControlAgentActionStart } from './runSourceControlAgentActionStart'
import { getDefaultSettings } from '../../../../shared/constants'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
import { saveSourceControlActionRecipe } from '../../../../shared/source-control-ai-recipe-save'
import { sourceControlActionLaunchOptions } from '../../../../shared/source-control-action-launch-preferences'
import {
resolveAgentLaunchCommand,
terminalSessionOptionInputs
} from '../../../../shared/tui-agent-launch-command'
import type { Repo } from '../../../../shared/repo-types'
function buildArgs(
overrides: Partial<Parameters<typeof runSourceControlAgentActionStart>[0]> = {}
@@ -38,7 +47,7 @@ function buildArgs(
trimmedCommandInput: 'Fix the bug',
agentArgs: '--model gpt-5',
agentArgsApply: true,
commandTemplate: '{basePrompt}',
recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}', agentArgs: '--model gpt-5' },
saveTargetValue: 'none',
actionId: 'resolveComments',
repoId: null,
@@ -444,3 +453,101 @@ describe('runSourceControlAgentActionStart CLI arguments applicability', () => {
)
})
})
it('executes the actual bound namespace while saving the literal default recipe', async () => {
const recipe = {
agentId: null,
commandInputTemplate: '{basePrompt}',
agentArgs: '',
launchPreferences: { agentId: 'codex' as const, model: 'chosen' }
}
const onStart = vi.fn().mockResolvedValue(true)
await runSourceControlAgentActionStart(
buildArgs({ recipe, onStart, selectedAgent: 'codex', saveTargetValue: 'global' })
)
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ sessionOptions: { model: 'chosen' } })
)
expect(mocks.onSaveAgentDefault).toHaveBeenLastCalledWith(
{ type: 'global' },
'resolveComments',
recipe
)
})
// Saved to the repository, the copied choice owns the action there, as on every later launch.
it.each(['--model gpt-5.6-luna', '--model gpt-5.6-sol'])(
'persists the complete target for changed or unchanged Customize arguments %s',
async (agentArgs) => {
vi.clearAllMocks()
const base = getDefaultSettings('/tmp')
const terra = { agentId: 'codex' as const, model: 'gpt-5.6-terra' }
const fixCommitFailure = {
agentId: null,
commandInputTemplate: '{basePrompt}',
launchPreferences: terra
}
const settings = {
...base,
sourceControlAi: { ...base.sourceControlAi!, actions: { fixCommitFailure } }
}
const legacy = { agentArgs: '--model gpt-5.6-sol' }
const repo: Pick<Repo, 'id' | 'sourceControlAi'> = {
id: 'repo-1',
sourceControlAi: { actionOverrides: { fixCommitFailure: legacy } }
}
const shown = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } })
const recipe = { ...shown, agentArgs }
mocks.onSaveAgentDefault.mockImplementation((target, actionId, recipe) => {
const saved = saveSourceControlActionRecipe({ target, actionId, recipe, settings, repo })
if ('update' in saved) {
repo.sourceControlAi = saved.update.sourceControlAi ?? undefined
}
})
const args = { recipe, agentArgs, settings, repo, saveTargetValue: 'repo', repoId: 'repo-1' }
await runSourceControlAgentActionStart(buildArgs({ ...args, actionId: 'fixCommitFailure' }))
expect(shown.launchPreferencesYieldToAgentArgs).toBe(true)
expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).toMatchObject({
agentArgs: `--dangerously-bypass-approvals-and-sandbox ${agentArgs}`,
sessionOptions: { model: 'gpt-5.6-terra' }
})
expect(mocks.launchAgentInNewTab.mock.lastCall?.[0]).not.toHaveProperty(
'sessionOptionsYieldToAgentArgs'
)
expect(repo.sourceControlAi?.actionOverrides?.fixCommitFailure).toEqual({
agentId: null,
commandInputTemplate: '{basePrompt}',
agentArgs,
launchPreferences: terra
})
const next = resolveSourceControlActionRecipe({ settings, repo, actionId: 'fixCommitFailure' })
const nextOptions = sourceControlActionLaunchOptions(next, 'codex')
expect(nextOptions).toEqual({ sessionOptions: { model: terra.model } })
const command = resolveAgentLaunchCommand({
agent: 'codex',
cmdOverrides: {},
platform: 'linux',
shell: 'posix',
agentArgs,
...terminalSessionOptionInputs({
agent: 'codex',
agentArgs,
shell: 'posix',
sessionOptions: nextOptions.sessionOptions,
yieldToAgentArgs: nextOptions.sessionOptionsYieldToAgentArgs
})
})
expect(command).toMatchObject({ ok: true, appliedSessionOptions: { model: terra.model } })
if (command.ok) {
expect(command.command).not.toContain(agentArgs.split(' ')[1])
}
expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
await runSourceControlAgentActionStart(
buildArgs({ ...args, recipe: next, actionId: 'fixCommitFailure' })
)
expect(mocks.onSaveAgentDefault).toHaveBeenCalledTimes(1)
}
)
@@ -10,11 +10,19 @@ import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import {
saveSourceControlActionRecipe,
type SourceControlAiWriteTarget
} from '../../../../shared/source-control-ai-recipe-save'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
import { sourceControlActionRecipeSaveMatchesTarget } from './source-control-action-recipe-match'
import { resolveSourceControlAgentSaveTarget } from './source-control-agent-action-dialog-support'
import {
sourceControlActionAgentArgs,
sourceControlActionLaunchOptions
} from '../../../../shared/source-control-action-launch-preferences'
import { admitSourceControlRecovery } from './source-control-recovery-admission'
type RunSourceControlAgentActionStartArgs = {
@@ -23,7 +31,7 @@ type RunSourceControlAgentActionStartArgs = {
agentArgs: string
/** False when the launch would be structured native chat, which reads no CLI arguments. */
agentArgsApply: boolean
commandTemplate: string
recipe: SourceControlActionRecipe
saveTargetValue: string
actionId: SourceControlLaunchActionId
repoId?: string | null
@@ -41,6 +49,9 @@ type RunSourceControlAgentActionStartArgs = {
commandInput: string
/** Omitted when CLI arguments do not apply, so the launch resolves the global setting. */
agentArgs?: string
/** The action's saved choice, on either route. */
sessionOptions?: Readonly<Record<string, string>>
sessionOptionsYieldToAgentArgs?: true
}) => boolean | Promise<boolean>
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
@@ -60,7 +71,7 @@ export async function runSourceControlAgentActionStart({
trimmedCommandInput,
agentArgs,
agentArgsApply,
commandTemplate,
recipe,
saveTargetValue,
actionId,
repoId,
@@ -94,7 +105,40 @@ export async function runSourceControlAgentActionStart({
let launchFailureNotified = false
let launchAcceptedNotified = false
// Undefined selects global agent arguments; empty string would suppress that fallback.
const launchAgentArgs = agentArgsApply ? agentArgs : undefined
let launchAgentArgs = agentArgsApply ? agentArgs : undefined
const saveTarget = resolveSourceControlAgentSaveTarget(saveTargetValue, repoId)
// The dialog edits agent, arguments and prompt; the model stays the saved one for that agent.
// Why: a recipe being saved launches as it will resolve once saved, so the next launch matches.
let launchRecipe = recipe
let recipeToSave = recipe
if (saveTarget && settings) {
const saved = saveSourceControlActionRecipe({
target: saveTarget,
settings,
repo,
actionId,
recipe
})
recipeToSave =
('sourceControlAi' in saved
? saved.sourceControlAi.actions?.[actionId]
: saved.update.sourceControlAi?.actionOverrides?.[actionId]) ?? recipe
launchRecipe = resolveSourceControlActionRecipe({
settings:
'sourceControlAi' in saved
? { ...settings, sourceControlAi: saved.sourceControlAi }
: settings,
repo:
'update' in saved ? { sourceControlAi: saved.update.sourceControlAi ?? undefined } : repo,
actionId
})
}
launchAgentArgs = sourceControlActionAgentArgs(
{ ...launchRecipe, agentArgs: launchAgentArgs },
selectedAgent,
settings
)
const launchOptions = sourceControlActionLaunchOptions(launchRecipe, selectedAgent)
const notifyLaunchAccepted = (): void => {
if (launchAcceptedNotified) {
return
@@ -106,7 +150,8 @@ export async function runSourceControlAgentActionStart({
launched = await onStart({
agent: selectedAgent,
commandInput: trimmedCommandInput,
agentArgs: launchAgentArgs
agentArgs: launchAgentArgs,
...launchOptions
})
if (launched) {
notifyLaunchAccepted()
@@ -119,6 +164,7 @@ export async function runSourceControlAgentActionStart({
groupId: groupId ?? worktreeId,
prompt: trimmedCommandInput,
agentArgs: launchAgentArgs,
...launchOptions,
promptDelivery,
launchPlatform,
launchSource
@@ -157,25 +203,19 @@ export async function runSourceControlAgentActionStart({
return false
}
const saveTarget = resolveSourceControlAgentSaveTarget(saveTargetValue, repoId)
const launchRecipe = {
agentId: selectedAgent,
commandInputTemplate: commandTemplate,
agentArgs
}
const launchRecipeAlreadySaved = Boolean(
saveTarget &&
sourceControlActionRecipeMatchesTarget({
sourceControlActionRecipeSaveMatchesTarget({
actionId,
target: saveTarget,
recipe: launchRecipe,
recipe: recipeToSave,
settings,
repo
})
)
if (saveTarget && onSaveAgentDefault && !launchRecipeAlreadySaved) {
try {
await onSaveAgentDefault(saveTarget, actionId, launchRecipe)
await onSaveAgentDefault(saveTarget, actionId, recipeToSave)
} catch (error) {
// Prompt delivery succeeded; recipe-save failure must not prevent accepted-launch callbacks from settling.
console.error('onSaveAgentDefault failed', error)
@@ -9,7 +9,10 @@ import {
type SourceControlActionId,
type SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save'
import {
saveSourceControlActionRecipe,
type SourceControlAiWriteTarget
} from '../../../../shared/source-control-ai-recipe-save'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
@@ -17,12 +20,14 @@ type NormalizedSourceControlActionRecipe = {
agentId: SourceControlActionRecipe['agentId'] | null
commandInputTemplate: string
agentArgs: string
launchPreferences: string
}
type PersistedSourceControlActionRecipe = {
agentId?: SourceControlActionRecipe['agentId']
commandInputTemplate?: string | null
agentArgs?: string | null
launchPreferences?: SourceControlActionRecipe['launchPreferences']
}
function normalizeSourceControlActionRecipeForComparison(
@@ -35,7 +40,17 @@ function normalizeSourceControlActionRecipeForComparison(
typeof recipe?.commandInputTemplate === 'string'
? recipe.commandInputTemplate.trim()
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : ''
agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs.trim() : '',
launchPreferences: recipe?.launchPreferences
? JSON.stringify([
recipe.launchPreferences.agentId,
recipe.launchPreferences.model ?? null,
Object.entries(recipe.launchPreferences.options ?? {}).sort(([a], [b]) =>
a.localeCompare(b)
),
recipe.launchPreferences.permissionMode ?? null
])
: ''
}
}
@@ -46,7 +61,8 @@ function sourceControlActionRecipesMatch(
return (
left.agentId === right.agentId &&
left.commandInputTemplate === right.commandInputTemplate &&
left.agentArgs === right.agentArgs
left.agentArgs === right.agentArgs &&
left.launchPreferences === right.launchPreferences
)
}
@@ -62,11 +78,7 @@ function readSavedSourceControlActionRecipeAtTarget(input: {
if (!repoRecipe) {
return null
}
return resolveSourceControlActionRecipe({
actionId: input.actionId,
settings: input.settings,
repo: input.repo
})
return repoRecipe
}
const source = normalizeSourceControlAiSettings(
input.settings?.sourceControlAi,
@@ -106,7 +118,10 @@ export function sourceControlActionRecipeMatchesTarget(input: {
return false
}
const current = normalizeSourceControlActionRecipeForComparison(input.actionId, input.recipe)
const saved = normalizeSourceControlActionRecipeForComparison(input.actionId, savedRecipe)
const saved = normalizeSourceControlActionRecipeForComparison(
input.actionId,
input.target.type === 'repo' ? resolveSourceControlActionRecipe(input) : savedRecipe
)
if (!sourceControlActionRecipesMatch(current, saved)) {
return false
}
@@ -115,3 +130,30 @@ export function sourceControlActionRecipeMatchesTarget(input: {
}
return (input.customAgentCommand ?? '').trim() === readSavedCustomAgentCommandAtTarget(input)
}
export function sourceControlActionRecipeSaveMatchesTarget(
input: Parameters<typeof sourceControlActionRecipeMatchesTarget>[0]
): boolean {
const stored = readSavedSourceControlActionRecipeAtTarget(input)
if (!stored || !input.settings) {
return false
}
const saved = saveSourceControlActionRecipe({ ...input, settings: input.settings })
const planned =
'sourceControlAi' in saved
? saved.sourceControlAi.actions?.[input.actionId]
: saved.update.sourceControlAi?.actionOverrides?.[input.actionId]
// A complete save owns these fields; inherited equal values do not establish that ownership.
return Boolean(
planned &&
stored.agentId === planned.agentId &&
stored.commandInputTemplate === planned.commandInputTemplate &&
stored.agentArgs === planned.agentArgs &&
sourceControlActionRecipesMatch(
normalizeSourceControlActionRecipeForComparison(input.actionId, stored),
normalizeSourceControlActionRecipeForComparison(input.actionId, planned)
) &&
(!isCustomAgentId(planned.agentId) ||
(input.customAgentCommand ?? '').trim() === readSavedCustomAgentCommandAtTarget(input))
)
}
@@ -1,6 +1,7 @@
import type { getAgentCatalog } from '@/lib/agent-catalog'
import type { useAppStore } from '@/store'
import type { useRepoById } from '@/store/selectors'
import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm'
@@ -21,6 +22,7 @@ export type UseSourceControlAgentActionDialogResult = {
agentArgs: string
/** False when this launch would be a structured native chat session, which reads no CLI arguments. */
agentArgsApply: boolean
recipe: SourceControlActionRecipe
commandTemplate: string
saveLaunchRecipe: boolean
saveTargetValue: string
@@ -1,3 +1,5 @@
import { useAppStore } from '@/store'
import { ensureDetectedAgentsForWorktree } from '@/lib/agent-detection-target-inventory'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -146,3 +148,16 @@ export function buildSourceControlAgentScopeNote(launchAgentScope: {
globalAgentLabel: labelFor(launchAgentScope.globalAgentId)
}
}
/** The workspace owner answers detection, including a paired host's SSH workspace. */
export function detectSourceControlActionAgents(
worktreeId: string | null | undefined,
connectionId: string | null | undefined
): Promise<TuiAgent[]> {
const state = useAppStore.getState()
return worktreeId
? ensureDetectedAgentsForWorktree(state, worktreeId)
: typeof connectionId === 'string'
? state.ensureRemoteDetectedAgents(connectionId)
: state.ensureDetectedAgents()
}
@@ -11,12 +11,15 @@ import {
ensureDetectedAgentsForWorktree,
type WorktreeAgentInventoryState
} from '@/lib/agent-detection-target-inventory'
import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup'
import {
pickSourceControlLaunchAgent,
readSourceControlLaunchRecipeAgentId
} from '../../../../../../shared/source-control-launch-agent-selection'
import { isTuiAgentEnabled } from '../../../../../../shared/tui-agent-selection'
import {
sourceControlActionAgentArgs,
sourceControlActionLaunchOptions
} from '../../../../../../shared/source-control-action-launch-preferences'
import type {
SourceControlActionRecipe,
SourceControlLaunchActionId
@@ -119,14 +122,6 @@ export async function launchSourceControlRecoveryAgentWithDefault({
const detectedAgents = [...(choices.detected ?? [])]
const store = getStoreState()
const savedRecipe = getLaunchActionRecipe(actionId)
const agentArgsPlan = planAgentCliArgsSuffix(
savedRecipe.agentArgs,
activeSourceControlLaunchPlatform === 'win32' ? 'powershell' : 'posix'
)
if (!agentArgsPlan.ok) {
toast.error(agentArgsPlan.error)
return false
}
if (!basePrompt) {
toast.error(copy.promptUnavailable)
return false
@@ -171,7 +166,8 @@ export async function launchSourceControlRecoveryAgentWithDefault({
worktreeId: activeWorktreeId,
groupId: activeGroupId ?? activeWorktreeId,
prompt,
agentArgs: savedRecipe.agentArgs,
agentArgs: sourceControlActionAgentArgs(savedRecipe, agent, store.settings),
...sourceControlActionLaunchOptions(savedRecipe, agent),
promptDelivery: 'submit-after-ready',
launchPlatform: activeSourceControlLaunchPlatform,
launchSource: 'source_control_recovery'
@@ -7,6 +7,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { isSourceControlAgentDetectedAndEnabled } from './source-control-agent-action-dialog-support'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
import { sourceControlActionRecipeMatchesTarget } from './source-control-action-recipe-match'
type SavedSourceControlAgentActionTargetValue = 'repo' | 'global'
@@ -36,10 +37,7 @@ type UseSavedSourceControlAgentActionAutoStartArgs = {
isStarting: boolean
detectedAgents: TuiAgent[]
disabledAgents: TuiAgent[] | undefined
onAutoStart: (args: {
detectedAgents: TuiAgent[]
saveTargetValue: SavedSourceControlAgentActionTargetValue
}) => Promise<boolean>
onAutoStart: (args: { detectedAgents: TuiAgent[] }) => Promise<boolean>
}
type SavedSourceControlAgentActionAutoStartResult = {
@@ -57,6 +55,7 @@ function buildSavedLaunchRecipe(input: {
savedAgentId?: TuiAgent | null
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
savedLaunchPreferences?: SourceControlActionRecipe['launchPreferences']
}): SourceControlActionRecipe | null {
if (!input.savedAgentId) {
return null
@@ -64,7 +63,8 @@ function buildSavedLaunchRecipe(input: {
return {
agentId: input.savedAgentId,
commandInputTemplate: input.savedCommandInputTemplate ?? '{basePrompt}',
agentArgs: input.savedAgentArgs ?? ''
agentArgs: input.savedAgentArgs ?? '',
...(input.savedLaunchPreferences ? { launchPreferences: input.savedLaunchPreferences } : {})
}
}
@@ -111,6 +111,7 @@ function buildReceiptKey(input: {
savedAgentId: TuiAgent
savedCommandInputTemplate?: string | null
savedAgentArgs?: string | null
savedLaunchPreferences?: SourceControlActionRecipe['launchPreferences']
repoId?: string | null
connectionId?: string | null
worktreeId?: string | null
@@ -122,6 +123,7 @@ function buildReceiptKey(input: {
input.savedAgentId,
input.savedCommandInputTemplate ?? '{basePrompt}',
input.savedAgentArgs ?? '',
input.savedLaunchPreferences ?? null,
input.repoId ?? null,
input.connectionId ?? null,
input.worktreeId ?? null,
@@ -156,14 +158,19 @@ export function useSavedSourceControlAgentActionAutoStart({
const autoStartedOpenCycleRef = useRef(0)
const [receiptState, setReceiptState] = useState<AutoLaunchReceiptState | null>(null)
const savedLaunchPreferences = useMemo(() => {
const effective = resolveSourceControlActionRecipe({ settings, repo, actionId })
return effective.agentId === savedAgentId ? effective.launchPreferences : undefined
}, [actionId, repo, savedAgentId, settings])
const savedLaunchRecipe = useMemo(
() =>
buildSavedLaunchRecipe({
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs
savedAgentArgs,
savedLaunchPreferences
}),
[savedAgentArgs, savedAgentId, savedCommandInputTemplate]
[savedAgentArgs, savedAgentId, savedCommandInputTemplate, savedLaunchPreferences]
)
const matchedSavedReceiptTargetValue = useMemo(
() =>
@@ -186,6 +193,7 @@ export function useSavedSourceControlAgentActionAutoStart({
savedAgentId,
savedCommandInputTemplate,
savedAgentArgs,
savedLaunchPreferences,
repoId,
connectionId,
worktreeId,
@@ -200,6 +208,7 @@ export function useSavedSourceControlAgentActionAutoStart({
savedAgentArgs,
savedAgentId,
savedCommandInputTemplate,
savedLaunchPreferences,
worktreeId
])
@@ -262,10 +271,7 @@ export function useSavedSourceControlAgentActionAutoStart({
return
}
autoStartedOpenCycleRef.current = openCycle
void onAutoStart({
detectedAgents,
saveTargetValue: matchedSavedReceiptTargetValue
})
void onAutoStart({ detectedAgents })
.then((launched) => {
if (!launched) {
revealDialog()
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { getDefaultSettings } from '../../../../shared/constants'
const mocks = vi.hoisted(
(): { settings: GlobalSettings | null; agents: ReturnType<typeof vi.fn> } => ({
settings: null,
agents: vi.fn().mockResolvedValue(['claude', 'codex'])
})
)
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(select: (state: unknown) => unknown) => select({ settings: mocks.settings }),
{ getState: () => ({ settings: mocks.settings, ensureDetectedAgents: mocks.agents }) }
)
}))
vi.mock('@/store/selectors', () => ({ useRepoById: () => null }))
vi.mock('@/runtime/local-runtime-capabilities', () => ({
ensureLocalRuntimeCapabilities: async () => [],
readLocalRuntimeCapabilitiesOrUnknown: () => []
}))
vi.mock('./source-control-launch-agent-args-applicability', () => ({
sourceControlLaunchAppliesAgentArgs: () => false
}))
vi.mock('./use-source-control-launchable-agents', () => ({
useSourceControlLaunchableAgents: () => ({
detecting: false,
refreshAgents: mocks.agents,
refreshLaunchableAgents: mocks.agents,
launchableAgents: ['claude', 'codex']
})
}))
vi.mock('./useSavedSourceControlAgentActionAutoStart', () => ({
useSavedSourceControlAgentActionAutoStart: () => ({ autoLaunchPending: false })
}))
vi.mock('./useSourceControlAgentActionStart', () => ({
useSourceControlAgentActionStart: () => ({
deliveryPlan: { status: 'idle' },
resetDeliveryPlan: () => {},
isStarting: false,
handleStart: async () => {},
startWithDetectedAgents: async () => true
})
}))
import { useSourceControlAgentActionDialog } from './useSourceControlAgentActionDialog'
afterEach(cleanup)
it('keeps a default recipe and dormant choice through display changes, then records an actual edit', async () => {
mocks.settings = { ...getDefaultSettings('/tmp/pr-30449'), defaultTuiAgent: 'codex' }
const launchPreferences = {
agentId: 'codex' as const,
model: 'chosen',
options: { effort: 'high' }
}
mocks.settings.sourceControlAi!.actions!.resolveConflicts = { agentId: null, launchPreferences }
const { result, rerender } = renderHook(() =>
useSourceControlAgentActionDialog({
open: true,
onOpenChange: () => {},
actionId: 'resolveConflicts',
title: '',
description: '',
baseCommandInput: 'fix',
launchSource: 'source_control_recovery',
savedAgentId: null
})
)
await waitFor(() => expect(result.current.detecting).toBe(false))
expect(result.current.selectedAgent).toBe('codex')
act(() => result.current.onCommandTemplateChange('{basePrompt} edited'))
mocks.settings = { ...mocks.settings, defaultTuiAgent: 'claude' }
rerender()
await waitFor(() => expect(result.current.detecting).toBe(false))
expect(result.current.selectedAgent).toBe('claude')
expect(result.current.recipe).toMatchObject({
agentId: null,
launchPreferences,
commandInputTemplate: '{basePrompt} edited'
})
act(() => result.current.onSelectedAgentChange('codex'))
expect(result.current.recipe).toMatchObject({ agentId: 'codex', launchPreferences })
act(() => result.current.onSelectedAgentChange('claude'))
expect(result.current.recipe.agentId).toBe('claude')
expect(result.current.recipe.launchPreferences).toBeUndefined()
})
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
pickSourceControlLaunchAgent,
resolveSourceControlLaunchAgentScope
} from '../../../../shared/source-control-launch-agent-selection'
import { useAppStore } from '@/store'
import { useRepoById } from '@/store/selectors'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai'
import type { SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions'
import { renderSourceControlActionCommandTemplate } from '../../../../shared/source-control-ai-actions'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -56,11 +59,18 @@ export function useSourceControlAgentActionDialog({
// Update existing repo overrides in place; a new global default would remain shadowed.
const defaultSaveTargetValue =
launchAgentScope.overridesGlobalAgent && repoId ? 'repo' : DEFAULT_SAVE_TARGET_VALUE
const [commandTemplate, setCommandTemplate] = useState(
savedCommandInputTemplate ?? '{basePrompt}'
const initialRecipe = useCallback(
(): SourceControlActionRecipe => ({
...resolveSourceControlActionRecipe({ settings, repo, actionId }),
agentId: savedAgentId ?? null,
commandInputTemplate: savedCommandInputTemplate ?? '{basePrompt}',
agentArgs: savedAgentArgs ?? ''
}),
[settings, repo, actionId, savedAgentId, savedCommandInputTemplate, savedAgentArgs]
)
const [agentArgs, setAgentArgs] = useState(savedAgentArgs ?? '')
const [selectedAgent, setSelectedAgent] = useState<TuiAgent | null>(savedAgentId ?? null)
const [recipe, setRecipe] = useState(initialRecipe)
const commandTemplate = recipe.commandInputTemplate ?? '{basePrompt}'
const agentArgs = recipe.agentArgs ?? ''
const openCycleRef = useRef(0)
const wasOpenRef = useRef(false)
const [openCycle, setOpenCycle] = useState(0)
@@ -91,32 +101,19 @@ export function useSourceControlAgentActionDialog({
if (!wasOpenRef.current) {
openCycleRef.current = cycle
setOpenCycle(cycle)
setRecipe(initialRecipe())
setSaveLaunchRecipe(true)
setSaveTargetValue(defaultSaveTargetValue)
}
wasOpenRef.current = true
setDiscovery({ cycle, outcome: 'pending' })
setCommandTemplate(savedCommandInputTemplate ?? '{basePrompt}')
setAgentArgs(savedAgentArgs ?? '')
setSelectedAgent(savedAgentId ?? null)
setSaveLaunchRecipe(true)
setSaveTargetValue(defaultSaveTargetValue)
let stale = false
// Settle CLI-argument applicability with discovery so structured launches never flash an inapplicable field.
void refreshAgents().then(
({ detected, launchable }) => {
() => {
if (stale || openCycleRef.current !== cycle) {
return
}
setSelectedAgent(
(current) =>
current ??
pickSourceControlLaunchAgent({
savedAgent: savedAgentId,
defaultAgent: settings?.defaultTuiAgent,
detectedAgents: detected,
disabledAgents,
chosenOpensAsChat: (agent) => launchable.includes(agent)
})
)
setDiscovery({ cycle, outcome: 'success' })
},
(error: unknown) => {
@@ -129,20 +126,18 @@ export function useSourceControlAgentActionDialog({
return () => {
stale = true
}
}, [
defaultSaveTargetValue,
disabledAgents,
open,
refreshAgents,
savedAgentId,
savedAgentArgs,
savedCommandInputTemplate,
repoId,
settings?.defaultTuiAgent
])
}, [defaultSaveTargetValue, disabledAgents, open, refreshAgents, initialRecipe])
const closeDialog = useCallback(() => onOpenChange(false), [onOpenChange])
const selectedAgent =
recipe.agentId && !isCustomAgentId(recipe.agentId)
? recipe.agentId
: pickSourceControlLaunchAgent({
defaultAgent: settings?.defaultTuiAgent,
detectedAgents: launchableAgents,
disabledAgents
})
const { agentOptions, selectedAgentUnavailable, hasEnabledAgents } = useMemo(
() =>
buildSourceControlAgentAvailability({
@@ -179,7 +174,7 @@ export function useSourceControlAgentActionDialog({
trimmedCommandInput,
agentArgs,
agentArgsApply,
commandTemplate,
recipe,
saveLaunchRecipe,
saveTargetValue,
actionId,
@@ -247,10 +242,10 @@ export function useSourceControlAgentActionDialog({
isStarting,
detectedAgents: launchableAgents,
disabledAgents,
onAutoStart: ({ detectedAgents: agentsForLaunch, saveTargetValue: matchedTargetValue }) =>
onAutoStart: ({ detectedAgents: agentsForLaunch }) =>
startWithDetectedAgents({
detectedAgents: agentsForLaunch,
saveTargetValueOverride: matchedTargetValue
saveTargetValueOverride: 'none'
})
})
@@ -271,11 +266,18 @@ export function useSourceControlAgentActionDialog({
},
[resetDeliveryPlan]
)
const onSelectedAgentChange = useMemo(() => resetPlanAfter(setSelectedAgent), [resetPlanAfter])
const onAgentArgsChange = useMemo(() => resetPlanAfter(setAgentArgs), [resetPlanAfter])
const onCommandTemplateChange = useMemo(
() => resetPlanAfter(setCommandTemplate),
[resetPlanAfter]
const onSelectedAgentChange = resetPlanAfter((agent: TuiAgent | null) =>
setRecipe(({ launchPreferences, ...current }) => ({
...current,
agentId: agent,
...(launchPreferences?.agentId === agent ? { launchPreferences } : {})
}))
)
const onAgentArgsChange = resetPlanAfter((agentArgs: string) =>
setRecipe((current) => ({ ...current, agentArgs }))
)
const onCommandTemplateChange = resetPlanAfter((commandInputTemplate: string) =>
setRecipe((current) => ({ ...current, commandInputTemplate }))
)
const onSaveLaunchRecipeChange = useMemo(
() => resetPlanAfter(setSaveLaunchRecipe),
@@ -299,6 +301,7 @@ export function useSourceControlAgentActionDialog({
agentArgs,
agentArgsApply,
commandTemplate,
recipe,
saveLaunchRecipe,
saveTargetValue,
saveTargets,
@@ -23,7 +23,7 @@ type UseSourceControlAgentActionStartArgs = {
agentArgs: string
/** False when the launch would be structured native chat, which reads no CLI arguments. */
agentArgsApply: boolean
commandTemplate: string
recipe: SourceControlActionRecipe
saveLaunchRecipe: boolean
saveTargetValue: string
actionId: SourceControlLaunchActionId
@@ -45,6 +45,9 @@ type UseSourceControlAgentActionStartArgs = {
commandInput: string
/** Omitted when CLI arguments do not apply, so the launch resolves the global setting. */
agentArgs?: string
/** The action's saved choice, on either route. */
sessionOptions?: Readonly<Record<string, string>>
sessionOptionsYieldToAgentArgs?: true
}) => boolean | Promise<boolean>
onSaveAgentDefault?: (
target: SourceControlAiWriteTarget,
@@ -81,7 +84,7 @@ export function useSourceControlAgentActionStart({
trimmedCommandInput,
agentArgs,
agentArgsApply,
commandTemplate,
recipe,
saveLaunchRecipe,
saveTargetValue,
actionId,
@@ -156,7 +159,7 @@ export function useSourceControlAgentActionStart({
trimmedCommandInput,
agentArgs,
agentArgsApply,
commandTemplate,
recipe,
saveTargetValue: saveLaunchRecipe ? (saveTargetValueOverride ?? saveTargetValue) : 'none',
actionId,
repoId,
@@ -191,7 +194,7 @@ export function useSourceControlAgentActionStart({
agentArgs,
agentArgsApply,
commandInput,
commandTemplate,
recipe,
connectionUnavailable,
groupId,
isRemote,
@@ -3,9 +3,42 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AgentCommandOverrideInput, AgentDefaultArgsInput } from './AgentLaunchDefaultsEditor'
import { TooltipProvider } from '../ui/tooltip'
afterEach(cleanup)
it.each(['', '--default'])(
'hides the %j placeholder while a permission chip is present',
(defaultArgs) => {
const editor = (withChip: boolean) => (
<TooltipProvider>
<AgentDefaultArgsInput
defaultArgs={defaultArgs}
argsOverride=""
onSaveArgs={vi.fn()}
permissionChip={
withChip
? {
label: 'Claude',
flag: '--permission-mode default',
removeRecipe: () => null,
onRemove: vi.fn()
}
: undefined
}
/>
</TooltipProvider>
)
const view = render(editor(true))
expect(screen.getByRole('button', { name: /Remove --permission-mode default/ })).toBeDefined()
const input = screen.getByRole('textbox', { name: 'Arguments' })
expect(input).toHaveProperty('placeholder', '')
expect(input).toHaveProperty('value', '')
view.rerender(editor(false))
expect(input).toHaveProperty('placeholder', defaultArgs || 'No default arguments')
}
)
describe('launch field reset ordering', () => {
it.each(['arguments', 'command'] as const)(
'honors the first Reset click after blurring a focused %s field',
@@ -88,7 +88,9 @@ export function AgentDefaultArgsInput({
argsOverride,
onSaveArgs,
permissionChip,
note
note,
disabled = false,
onDraftChange
}: {
defaultArgs: string
argsOverride: string
@@ -100,12 +102,14 @@ export function AgentDefaultArgsInput({
onRemove: () => void
}
note?: string
disabled?: boolean
onDraftChange?: (value: string) => void
}): React.JSX.Element {
const [draft, setDraft] = useState<string | null>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const chipRef = useRef<HTMLSpanElement>(null)
const chipButtonRef = useRef<HTMLButtonElement>(null)
const argsDraft = draft ?? argsOverride
const argsDraft = onDraftChange ? argsOverride : (draft ?? argsOverride)
const { pending, write } = useAgentLaunchFieldWrite()
useLayoutEffect(() => {
const input = inputRef.current
@@ -126,11 +130,11 @@ export function AgentDefaultArgsInput({
return () => observer.disconnect()
}, [permissionChip?.flag])
const commitArgs = (): void => {
if (pending || draft === null) {
if (disabled || pending || (!onDraftChange && draft === null)) {
return
}
const value = draft.trim()
if (value === argsOverride) {
const value = argsDraft.trim()
if (!onDraftChange && value === argsOverride) {
setDraft(null)
return
}
@@ -142,11 +146,11 @@ export function AgentDefaultArgsInput({
}
return (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium leading-none text-muted-foreground">
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
</span>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<div
className="scrollbar-sleek relative max-h-[calc(var(--spacing)*41+2px)] min-h-7 min-w-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md border border-input px-3 py-0.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30"
onBlur={(event) => {
@@ -165,9 +169,14 @@ export function AgentDefaultArgsInput({
{...permissionChip}
chipRef={chipRef}
buttonRef={chipButtonRef}
disabled={pending}
disabled={disabled || pending}
onRemove={() => {
if (draft !== null) {
if (disabled || pending) {
return
}
if (onDraftChange) {
onDraftChange(permissionChip.removeRecipe(argsDraft) ?? argsDraft)
} else if (draft !== null) {
setDraft(permissionChip.removeRecipe(draft) ?? draft)
}
inputRef.current?.focus()
@@ -180,9 +189,16 @@ export function AgentDefaultArgsInput({
ref={inputRef}
rows={1}
value={argsDraft}
disabled={disabled}
readOnly={pending}
onChange={(event) => setDraft(event.target.value.replace(/[\r\n]/g, ''))}
aria-label={translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
onChange={(event) =>
(onDraftChange ?? setDraft)(event.target.value.replace(/[\r\n]/g, ''))
}
onKeyDown={(event) => {
if (disabled || pending) {
return
}
if (
permissionChip &&
event.key === 'Backspace' &&
@@ -206,8 +222,13 @@ export function AgentDefaultArgsInput({
}
}}
placeholder={
defaultArgs ||
translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments')
permissionChip
? undefined
: defaultArgs ||
translate(
'auto.components.settings.AgentsPane.6f99bf5dd0',
'No default arguments'
)
}
spellCheck={false}
className="block min-h-5 [field-sizing:content]"
@@ -218,8 +239,11 @@ export function AgentDefaultArgsInput({
type="button"
variant="ghost"
size="xs"
disabled={disabled}
onClick={() => {
setDraft(argsDraft)
if (!onDraftChange) {
setDraft(argsDraft)
}
write(
defaultArgs,
() => onSaveArgs(defaultArgs),
@@ -5,6 +5,7 @@ import type { AgentPermissionChoice } from '../../../../shared/tui-agent-permiss
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import {
DropdownMenu,
DropdownMenuContent,
@@ -174,31 +175,53 @@ export type AgentPermissionChoiceRow = {
export function AgentPermissionMenu({
agentId,
label,
permission
permission,
inheritLabel,
disabled,
size = 'xs',
layout = 'inline'
}: {
agentId: string
label: string
permission: AgentPermissionChoiceRow
inheritLabel?: string
disabled?: boolean
size?: 'xs' | 'sm'
layout?: 'inline' | 'stacked'
}): React.JSX.Element {
const followAll = translate('settings.agents.permissionSameAsAll', 'Same as all agents')
const followAll =
inheritLabel ?? translate('settings.agents.permissionSameAsAll', 'Same as all agents')
const current = agentPermissionChoiceLabel(permission.choice, label)
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs font-medium">
{translate('settings.agents.rowPermissions', 'Permissions')}
</span>
<div
className={cn(
'flex min-w-0',
layout === 'stacked'
? 'flex-col items-stretch gap-2'
: 'flex-wrap items-center gap-x-3 gap-y-1'
)}
>
{layout === 'stacked' ? (
<Label variant="settings">
{translate('settings.agents.rowPermissions', 'Permissions')}
</Label>
) : (
<span className="text-xs font-medium">
{translate('settings.agents.rowPermissions', 'Permissions')}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button
type="button"
variant="outline"
size="xs"
size={size}
aria-label={translate('settings.agents.rowPermissionsLabel', '{{agent}} permissions', {
agent: label
})}
className="max-w-full"
className={cn('min-w-0 max-w-full', layout === 'stacked' && 'w-full justify-between')}
>
<span className="truncate">
<span className="truncate text-xs">
{permission.source === 'all' ? `${followAll} · ${current}` : current}
</span>
<ChevronDown className="size-3 shrink-0" />
@@ -208,7 +231,11 @@ export function AgentPermissionMenu({
<DropdownMenuRadioGroup
value={permission.source === 'all' ? 'default' : permission.choice}
>
<DropdownMenuRadioItem value="default" onSelect={() => permission.onSelect('default')}>
<DropdownMenuRadioItem
value="default"
disabled={disabled}
onSelect={() => permission.onSelect('default')}
>
<span className="truncate">
{followAll} · {agentPermissionChoiceLabel(permission.defaultChoice, label)}
</span>
@@ -218,6 +245,7 @@ export function AgentPermissionMenu({
<DropdownMenuRadioItem
key={choice}
value={choice}
disabled={disabled}
onSelect={() => permission.onSelect(choice)}
>
<div className="min-w-0 py-0.5">
@@ -279,7 +307,13 @@ export function AgentPermissionArgsChip({
onKeyDown={(event) => {
if (event.key === 'Backspace') {
event.preventDefault()
if (!event.repeat && !event.altKey && !event.ctrlKey && !event.metaKey) {
if (
!disabled &&
!event.repeat &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
onRemove()
}
}
@@ -57,7 +57,7 @@ function changeTemplate(value: string): void {
}
function changeAgentArgs(value: string): void {
const input = screen.getByText('CLI arguments').nextElementSibling
const input = screen.getByText('Arguments').nextElementSibling
if (!(input instanceof HTMLInputElement)) {
throw new Error('CLI arguments input is missing')
}
@@ -1,6 +1,13 @@
// @vitest-environment happy-dom
import React from 'react'
import recordings from '../../../../shared/fixtures/source-control-launch-recordings.json'
import { normalizeSourceControlActionLaunchPreferences } from '../../../../shared/source-control-ai-actions'
import { sourceControlActionLaunchOptions } from '../../../../shared/source-control-action-launch-preferences'
import { buildAgentStartupPlan } from '../../../../shared/tui-agent-startup'
import { describeSourceControlActionModel } from './source-control-action-model-copy'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types'
import {
@@ -21,8 +28,10 @@ import {
import { getCommitMessageAiPaneSearchEntries } from './commit-message-ai-search'
import { TooltipProvider } from '../ui/tooltip'
function renderPane(settings: GlobalSettings): string {
return renderToStaticMarkup(
const originalState = useAppStore.getState()
function renderPane(settings: GlobalSettings, renderer = renderToStaticMarkup): string {
return renderer(
React.createElement(
TooltipProvider,
null,
@@ -53,6 +62,11 @@ describe('CommitMessageAiPane', () => {
useAppStore.setState({ settingsSearchQuery: '' })
})
afterEach(() => {
cleanup()
useAppStore.setState(originalState, true)
})
it('renders only the opt-in control before the feature is enabled', () => {
const markup = renderPane(buildSettings())
@@ -88,7 +102,7 @@ describe('CommitMessageAiPane', () => {
expect(markup).toContain('Push failure fixes')
expect(markup).toContain('Broken checks fixes')
expect(markup).toContain('Conflict resolution')
expect(markup).toContain('CLI arguments')
expect(markup).toContain('Arguments')
expect(markup).toContain('Command template')
expect(markup).toContain('Custom command')
expect(markup).toContain('{basePrompt}')
@@ -112,14 +126,18 @@ describe('CommitMessageAiPane', () => {
instructionsByOperation: {},
customAgentCommand: '',
actions: {
fixChecks: {
commitMessage: {
agentId: 'codex'
}
},
prCreationDefaults: {},
launchActionDefaults: {}
}
})
}),
(element) => {
useAppStore.setState({ localDetectedAgentIdsByContext: { host: ['codex'] } })
return render(element).container.innerHTML
}
)
expect(markup).toContain('placeholder="--model gpt-5.4-mini"')
@@ -461,3 +479,25 @@ describe('CommitMessageAiPane', () => {
).toBe('runtime:env-1')
})
})
it('keeps the recorded Antigravity Ask action chat-only in both its hint and terminal command', () => {
const capture = recordings.antigravity
const preferences = normalizeSourceControlActionLaunchPreferences(capture.launchPreferences)
if (!preferences) {
throw new Error('Recorded Antigravity action must remain valid')
}
const plan = buildAgentStartupPlan({
agent: 'antigravity',
prompt: '',
cmdOverrides: {},
platform: 'darwin',
allowEmptyPromptLaunch: true,
agentArgs: capture.agentArgs,
...sourceControlActionLaunchOptions(
{ agentId: 'antigravity', launchPreferences: preferences },
'antigravity'
)
})
expect(plan?.launchCommand).toBe(capture.command)
expect(describeSourceControlActionModel('antigravity', preferences)).toContain(capture.routeHint)
})
@@ -127,19 +127,25 @@ export function CommitMessageAiPane({
}
const writeConfig = writeSourceControlAiSettings ?? localWriteConfig
const writeImmediateConfig = (patch: SourceControlAiSettingsPatch): void => {
void writeConfig(patch).catch((error: unknown) => {
console.error('Failed to update settings:', error)
})
}
const onToggleEnabled = (): void => {
void writeConfig({ enabled: !config.enabled })
writeImmediateConfig({ enabled: !config.enabled })
}
const onCustomCommandChange = (value: string): void => {
void writeConfig({ customAgentCommand: value })
writeImmediateConfig({ customAgentCommand: value })
}
const onPrDefaultChange = (
key: keyof NonNullable<SourceControlAiSettings['prCreationDefaults']>,
value: boolean
): void => {
void writeConfig((current) => ({
writeImmediateConfig((current) => ({
prCreationDefaults: {
...current.prCreationDefaults,
[key]: value
@@ -70,6 +70,7 @@ beforeEach(() => {
root = createRoot(container)
// Provide the fs bridge needed by mounted WorktreeSymlinksSection path suggestions.
;(window as unknown as { api: unknown }).api = {
preflight: { detectAgents: async () => [] },
fs: { readDir: () => Promise.resolve([]) }
}
})
@@ -1,3 +1,4 @@
import { SourceControlActionArguments } from './SourceControlActionArguments'
import type React from 'react'
import { Terminal } from 'lucide-react'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -9,7 +10,8 @@ import type {
import {
SOURCE_CONTROL_ACTION_IDS,
SOURCE_CONTROL_ACTION_LABELS,
type SourceControlActionId
type SourceControlActionId,
type SourceControlActionLaunchPreferences
} from '../../../../shared/source-control-ai-actions'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
@@ -40,6 +42,16 @@ import {
import { hasOwnActionOverride } from './repository-source-control-ai-draft'
import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-settings-targets'
import { translate } from '@/i18n/i18n'
import { SourceControlActionOptionField } from './SourceControlActionOptionField'
import {
useSourceControlActionAgentInventory,
useSourceControlActionAgent
} from './use-source-control-action-agent'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
import { shouldShowSourceControlActionArguments } from './source-control-action-arguments-visibility'
import { chatProviderAgents, useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
type RepositorySourceControlAiActionRowsProps = {
repoId: string
@@ -48,8 +60,16 @@ type RepositorySourceControlAiActionRowsProps = {
defaultTuiAgent: TuiAgent | 'blank' | null | undefined
onActionModeChange: (actionId: SourceControlActionId, mode: string) => void
onActionAgentChange: (actionId: SourceControlActionId, value: string) => void
onActionLaunchPreferencesChange: (
actionId: SourceControlActionId,
value: SourceControlActionLaunchPreferences | undefined
) => void
onActionTemplateChange: (actionId: SourceControlActionId, value: string) => void
onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void
onActionAgentArgsChange: (
actionId: SourceControlActionId,
value: string,
commit?: boolean
) => void
onAppendVariable: (actionId: SourceControlActionId, variable: string) => void
/** Per-action saving state for CLI args + command template (matches global recipes). */
savingActionIds: Partial<Record<SourceControlActionId, boolean>>
@@ -65,6 +85,7 @@ export function RepositorySourceControlAiActionRows({
defaultTuiAgent,
onActionModeChange,
onActionAgentChange,
onActionLaunchPreferencesChange,
onActionTemplateChange,
onActionAgentArgsChange,
onAppendVariable,
@@ -73,6 +94,10 @@ export function RepositorySourceControlAiActionRows({
onActionDiscard,
onActionSave
}: RepositorySourceControlAiActionRowsProps): React.JSX.Element {
useSourceControlActionAgentInventory()
const settings = useAppStore((state) => state.settings)
useHostStructuredAgentsVersion()
const resolveAgent = useSourceControlActionAgent(null, defaultTuiAgent ?? null).resolve
return (
<div className="space-y-3">
<Label className="text-xs font-medium">
@@ -92,14 +117,33 @@ export function RepositorySourceControlAiActionRows({
: ''
const agentArgsValue =
hasOverride && typeof override?.agentArgs === 'string' ? override.agentArgs : ''
const effectiveAgent = hasOverride ? override?.agentId : source.actions?.[actionId]?.agentId
const recipe = resolveSourceControlActionRecipe({
settings: { sourceControlAi: source },
repo: { sourceControlAi: repoAi },
actionId
})
const effectiveAgent = recipe.agentId
const actual = resolveAgent(effectiveAgent)
const pickerAgent =
effectiveAgent || actual.status === 'pending'
? resolveAgentArgsPlaceholderAgent(effectiveAgent, source, actionId, defaultTuiAgent)
: actual.agent
const isLaunchAction = !SOURCE_CONTROL_TEXT_ACTION_ID_SET.has(actionId)
// Launch actions pick their model above, so only text actions get a `--model` example.
const agentArgsPlaceholder =
hasOverride && agentArgsValue
? ''
: inheritedAgentArgs ||
getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(effectiveAgent, source, actionId, defaultTuiAgent)
)
(isLaunchAction
? ''
: getSourceControlAgentArgsPlaceholder(
resolveAgentArgsPlaceholderAgent(
effectiveAgent,
source,
actionId,
defaultTuiAgent
)
))
const agentOptions = getAgentCatalogForAction(actionId, effectiveAgent)
const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent)
const agentSupportText = getSourceControlActionAgentSupportText(actionId)
@@ -130,7 +174,7 @@ export function RepositorySourceControlAiActionRows({
</span>
<span>
{agentArgsStateLabel({
hasOverride,
hasOverride: override?.agentArgs !== undefined,
inheritedAgentArgs,
repoAgentArgs: agentArgsValue
})}
@@ -160,7 +204,7 @@ export function RepositorySourceControlAiActionRows({
</SelectContent>
</Select>
</div>
<div className="grid gap-3 sm:grid-cols-[180px_1fr]">
<div className="grid items-start gap-3 sm:grid-cols-[180px_1fr]">
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">
{translate(
@@ -212,20 +256,38 @@ export function RepositorySourceControlAiActionRows({
) : agentSupportText ? (
<p className="text-[11px] text-muted-foreground">{agentSupportText}</p>
) : null}
<Label className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.RepositorySourceControlAiActionRows.7a3a8e431d',
'CLI arguments'
)}
</Label>
<Input
value={agentArgsValue}
onChange={(event) => onActionAgentArgsChange(actionId, event.target.value)}
disabled={!hasOverride}
placeholder={agentArgsPlaceholder}
spellCheck={false}
className="h-8 font-mono text-xs disabled:cursor-not-allowed disabled:bg-muted/40"
/>
{isLaunchAction ? (
<SourceControlActionArguments
showArguments={shouldShowSourceControlActionArguments({
agent: actual.status === 'ready' ? actual.agent : null,
settings,
chatAgents: chatProviderAgents(LOCAL_EXECUTION_HOST_ID, undefined)
})}
agent={pickerAgent}
value={agentArgsValue}
preferences={recipe.launchPreferences}
disabled={!hasOverride || actual.status === 'pending' || isSavingAction}
onArgsChange={(value, commit) =>
onActionAgentArgsChange(actionId, value, commit)
}
onPreferencesChange={(value) =>
onActionLaunchPreferencesChange(actionId, value)
}
/>
) : (
<SourceControlActionOptionField
label={translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
>
<Input
value={agentArgsValue}
onChange={(event) => onActionAgentArgsChange(actionId, event.target.value)}
disabled={!hasOverride}
placeholder={agentArgsPlaceholder}
spellCheck={false}
className="h-8 font-mono text-xs disabled:cursor-not-allowed disabled:bg-muted/40"
/>
</SourceControlActionOptionField>
)}
</div>
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest'
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import './__fixtures__/settings-persistence-test-fixture'
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
@@ -7,10 +10,15 @@ import {
SOURCE_CONTROL_ACTION_IDS,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import { dropRepoLegacyInstructionForAction } from './repository-source-control-ai-draft'
import {
computeActionDirtyById,
dropRepoLegacyInstructionForAction
} from './repository-source-control-ai-draft'
import { RepositorySourceControlAiActionRows } from './RepositorySourceControlAiActionRows'
import { RepositorySourceControlAiEnablement } from './RepositorySourceControlAiEnablement'
import { SourceControlAiActionRecipeDefaults } from './SourceControlAiActionRecipeDefaults'
import { useAppStore } from '../../store'
import { TooltipProvider } from '../ui/tooltip'
describe('RepositorySourceControlAiEnablement', () => {
it('describes repo enablement as Source Control AI action visibility', () => {
const markup = renderToStaticMarkup(
@@ -30,27 +38,172 @@ describe('RepositorySourceControlAiEnablement', () => {
})
describe('RepositorySourceControlAiActionRows', () => {
it('renders save controls on dirty repository action recipes', () => {
const actionDirtyById = Object.fromEntries(
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure'])
) as Record<SourceControlActionId, boolean>
const source = {
enabled: true,
agentId: null,
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {}
} as SourceControlAiSettings
it.each([
{ agent: 'codex', detected: ['codex'] },
{ agent: 'opencode', detected: ['opencode'] },
{ agent: 'codex', detected: ['claude'] }
] as const)(
'keeps saved $agent controls bound across detection returning $detected',
async ({ agent, detected }) => {
useAppStore.getState().clearLocalDetectedAgents()
const settings = useAppStore.getState().settings!
const recipe = {
agentId: agent,
launchPreferences: {
agentId: agent,
permissionMode: 'ask' as const,
model: 'saved',
options: { fastMode: 'false' }
},
agentArgs: '--saved-cli',
commandInputTemplate: 'kept-template'
}
const source = {
...settings.sourceControlAi!,
enabled: true,
actions: { fixCommitFailure: recipe }
}
const originalRecipe = JSON.stringify(recipe)
let finish: (ids: string[]) => void = () => {}
const detectAgents = vi.fn(
() =>
new Promise<string[]>((resolve) => {
finish = resolve
})
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { preflight: { detectAgents } }
})
useAppStore.setState({
settings: {
...settings,
experimentalNativeChat: false,
defaultTuiAgent: 'claude',
sourceControlAi: source
},
settingsSearchQuery: '',
localDetectedAgentIdsByContext: {}
})
const ensure = vi.spyOn(useAppStore.getState(), 'ensureNativeDetectedAgents')
const writeConfig = vi.fn()
const onEdit = vi.fn()
const view = render(
React.createElement(
TooltipProvider,
null,
React.createElement(SourceControlAiActionRecipeDefaults, {
config: source,
defaultTuiAgent: 'claude',
searchQuery: '',
writeConfig
}),
React.createElement(RepositorySourceControlAiActionRows, {
repoId: 'repo-1',
repoAi: { actionOverrides: { fixCommitFailure: recipe } },
source,
defaultTuiAgent: 'claude',
savingActionIds: {},
actionDirtyById: computeActionDirtyById({}, {}, {}),
onActionModeChange: onEdit,
onActionAgentChange: onEdit,
onActionLaunchPreferencesChange: onEdit,
onActionTemplateChange: onEdit,
onActionAgentArgsChange: onEdit,
onAppendVariable: onEdit,
onActionDiscard: onEdit,
onActionSave: onEdit
})
)
)
await act(async () => {})
const cards = Array.from(view.container.querySelectorAll('textarea'))
.filter((field) => field.value === 'kept-template')
.map((field) => field.parentElement!.closest('.rounded-md')!)
expect(cards).toHaveLength(2)
expect(ensure).toHaveBeenCalledTimes(2)
expect(detectAgents).toHaveBeenCalledTimes(1)
for (const card of cards) {
expect(card.textContent).not.toContain('Model')
expect(card.textContent).toContain('Permissions')
expect(card.textContent).toContain('Ask')
expect(card.querySelector('button[aria-label="saved"]')).toBeNull()
const controls = Array.from(
card.querySelectorAll<HTMLButtonElement>(
'button[role="combobox"].min-w-0, button[aria-haspopup="menu"]'
)
)
expect(controls.length).toBeGreaterThan(0)
expect(controls.every((control) => control.disabled)).toBe(true)
expect(card.querySelector('textarea[aria-label="Arguments"]')).toBeNull()
}
await act(async () => {
finish([...detected])
})
for (const card of cards) {
const controls = Array.from(
card.querySelectorAll<HTMLButtonElement>(
'button[role="combobox"].min-w-0, button[aria-haspopup="menu"]'
)
)
expect(controls.length).toBeGreaterThan(0)
expect(controls.every((control) => !control.disabled)).toBe(true)
expect(card.textContent).not.toContain('Model')
expect(card.textContent).not.toContain('Effort')
expect(card.textContent).toContain('Permissions')
expect(card.textContent).toContain('Arguments')
expect(
card.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')?.value
).toBe('--saved-cli')
expect(
Array.from(card.querySelectorAll('textarea')).some(
(field) => field.value === 'kept-template'
)
).toBe(true)
}
for (const experimentalNativeChat of [true, false]) {
await act(() =>
useAppStore.setState({
settings: { ...useAppStore.getState().settings!, experimentalNativeChat }
})
)
for (const card of cards) {
const input = card.querySelector<HTMLTextAreaElement>('textarea[aria-label="Arguments"]')
expect(input?.value).toBe(
experimentalNativeChat && agent !== 'opencode' ? undefined : '--saved-cli'
)
}
}
expect(JSON.stringify(source.actions.fixCommitFailure)).toBe(originalRecipe)
expect(writeConfig).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
}
)
const markup = renderToStaticMarkup(
React.createElement(RepositorySourceControlAiActionRows, {
it.each([undefined, null, '', '--repo'])(
'renders save controls and argument ownership for %s',
(repoAgentArgs) => {
const actionDirtyById = Object.fromEntries(
SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, actionId === 'fixCommitFailure'])
) as Record<SourceControlActionId, boolean>
const source: SourceControlAiSettings = {
enabled: true,
agentId: null,
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand: '',
instructionsByOperation: {},
actions: { fixCommitFailure: { agentId: null, agentArgs: '--global' } }
}
const rows = React.createElement(RepositorySourceControlAiActionRows, {
repoId: 'repo-1',
repoAi: {
actionOverrides: {
fixCommitFailure: {
agentId: null,
commandInputTemplate: '{basePrompt}\n\nrepo'
commandInputTemplate: '{basePrompt}\n\nrepo',
...(repoAgentArgs !== undefined ? { agentArgs: repoAgentArgs } : {})
}
}
},
@@ -60,21 +213,33 @@ describe('RepositorySourceControlAiActionRows', () => {
actionDirtyById,
onActionModeChange: () => {},
onActionAgentChange: () => {},
onActionLaunchPreferencesChange: () => {},
onActionTemplateChange: () => {},
onActionAgentArgsChange: () => {},
onAppendVariable: () => {},
onActionDiscard: () => {},
onActionSave: () => {}
})
)
const markup = renderToStaticMarkup(React.createElement(TooltipProvider, null, rows))
expect(markup).toContain('Commit failure fixes')
expect(markup).toContain('Push failure fixes')
expect(markup).toContain('Unsaved changes')
expect(markup).toContain('Discard')
expect(markup).toContain('Save')
expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure')
})
expect(markup).toContain('Commit failure fixes')
expect(markup).toContain('Push failure fixes')
expect(markup).toContain('Unsaved changes')
expect(markup).toContain('Discard')
expect(markup).toContain('Save')
expect(markup).toContain('repo-repo-1-source-control-ai-fixCommitFailure')
const card = new DOMParser()
.parseFromString(markup, 'text/html')
.getElementById('repo-repo-1-source-control-ai-fixCommitFailure')
expect(card?.textContent).toContain(
repoAgentArgs === undefined
? 'Global custom args'
: repoAgentArgs
? 'Repository custom args'
: 'No args'
)
}
)
})
describe('dropRepoLegacyInstructionForAction', () => {
@@ -46,6 +46,7 @@ export function RepositorySourceControlAiSection({
updateHostedReviewDefault,
updateActionMode,
updateActionAgent,
updateActionLaunchPreferences,
updateActionTemplate,
updateActionAgentArgs,
appendVariable,
@@ -97,6 +98,7 @@ export function RepositorySourceControlAiSection({
actionDirtyById={actionDirtyById}
onActionModeChange={updateActionMode}
onActionAgentChange={updateActionAgent}
onActionLaunchPreferencesChange={updateActionLaunchPreferences}
onActionTemplateChange={updateActionTemplate}
onActionAgentArgsChange={updateActionAgentArgs}
onAppendVariable={appendVariable}
@@ -0,0 +1,113 @@
import type { SourceControlActionLaunchPreferences } from '../../../../shared/source-control-ai-actions'
import {
agentPermissionChoices,
agentPermissionArgs,
resolveAgentPermission
} from '../../../../shared/tui-agent-permission-choice'
import { agentArgsSetPermission } from '../../../../shared/tui-agent-permission-lift'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { AgentDefaultArgsInput } from './AgentLaunchDefaultsEditor'
import { AgentPermissionMenu } from './AgentPermissionChoiceControls'
export function SourceControlActionArguments({
agent,
value,
preferences,
disabled,
showArguments = true,
onArgsChange,
onPreferencesChange
}: {
agent: TuiAgent | null | undefined
value: string
preferences?: SourceControlActionLaunchPreferences
disabled?: boolean
showArguments?: boolean
onArgsChange: (value: string, commit?: boolean) => void
onPreferencesChange: (value: SourceControlActionLaunchPreferences | undefined) => void
}): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const selected = preferences?.agentId === agent ? preferences : undefined
const inherited = agent ? resolveAgentPermission(agent, settings).choice : 'own'
const choice = selected?.permissionMode ?? inherited
const label = agent ? (AGENT_CATALOG.find((entry) => entry.id === agent)?.label ?? agent) : ''
const flag = agent && selected?.permissionMode ? agentPermissionArgs(agent, choice) : ''
const loose = agent ? agentArgsSetPermission(agent, value) : false
const select = (next: typeof choice | 'default'): void => {
if (!agent || disabled) {
return
}
const { permissionMode: _previous, ...rest } = selected ?? { agentId: agent }
onPreferencesChange(
next !== 'default' ? { ...rest, permissionMode: next } : rest.model ? rest : undefined
)
}
return (
<>
{agent ? (
<div className="min-w-0 flex-1 basis-48 max-w-sm">
<AgentPermissionMenu
agentId={agent}
label={label}
layout="stacked"
size="sm"
disabled={disabled}
inheritLabel={translate(
'settings.sourceControl.actionPermissions.sameAsAgent',
'Same as {{agent}}',
{ agent: label }
)}
permission={{
chatOwnSupported: true,
choices: agentPermissionChoices(agent),
choice,
source: selected?.permissionMode ? 'agent' : 'all',
defaultChoice: inherited,
onSelect: select,
flag,
argsSetPermission: loose
}}
/>
</div>
) : null}
{showArguments ? (
<div className="w-full min-w-0">
<AgentDefaultArgsInput
defaultArgs=""
argsOverride={value}
disabled={disabled}
onDraftChange={(text) => onArgsChange(text)}
onSaveArgs={(text) => onArgsChange(text, true)}
permissionChip={
flag && !loose
? {
label,
flag,
removeRecipe: (text) =>
agent
? (liftActionPermissionArgs({ agentId: agent, agentArgs: text }, true)
.agentArgs ?? null)
: null,
onRemove: () => select('own')
}
: undefined
}
note={
loose
? translate(
'settings.agents.permissionArgsTerminalOnly',
'These arguments may control {{agent}}’s terminal permissions, so Orca adds no flag. Chats use the Permissions choice above.',
{ agent: label }
)
: undefined
}
/>
</div>
) : null}
</>
)
}
@@ -0,0 +1,167 @@
import {
findCatalogModel,
getAgentSessionOptionCatalog
} from '../../../../shared/agent-session-option-catalog'
import type { SourceControlActionLaunchPreferences } from '../../../../shared/source-control-ai-actions'
import {
sourceControlActionAgentPicksModel,
sourceControlActionOptions
} from '../../../../shared/source-control-action-launch-preferences'
import { encodeCatalogOptionValue } from '../../../../shared/structured-agent-session-option-codec'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { Select, SelectContent, SelectItem } from '../ui/select'
import {
SourceControlActionOptionField,
SourceControlActionOptionTrigger,
sourceControlActionRouteHint
} from './SourceControlActionOptionField'
import { translate } from '@/i18n/i18n'
const AGENT_DEFAULT_VALUE = '__agent_default__'
type SourceControlActionModelPickerProps = {
agent: TuiAgent | null | undefined
value: SourceControlActionLaunchPreferences | undefined
disabled?: boolean
layout?: 'stacked' | 'contents'
onChange: (value: SourceControlActionLaunchPreferences | undefined) => void
}
export function SourceControlActionModelPicker({
agent,
value,
disabled,
layout = 'stacked',
onChange
}: SourceControlActionModelPickerProps): React.JSX.Element | null {
const picksModel = sourceControlActionAgentPicksModel(agent)
if (!agent || !picksModel) {
return null
}
const catalog = picksModel ? getAgentSessionOptionCatalog(agent) : null
const selected = value?.agentId === agent ? value : undefined
// Model and permission mode are saved independently; a model change keeps the mode.
const save = (next: Omit<SourceControlActionLaunchPreferences, 'agentId'>): void =>
onChange(
next.model || next.permissionMode
? {
agentId: agent,
...(next.model
? { model: next.model, ...(next.options ? { options: next.options } : {}) }
: {}),
...(next.permissionMode ? { permissionMode: next.permissionMode } : {})
}
: undefined
)
const { permissionMode } = selected ?? {}
const unlistedModel =
catalog && selected?.model && !findCatalogModel(catalog, selected.model)
? selected.model
: undefined
const options = selected?.model ? sourceControlActionOptions(agent, selected.model) : []
const defaultLabel = translate('settings.sourceControl.actionModel.effortDefault', 'Default')
return (
<div className={layout === 'contents' ? 'contents' : 'grid min-w-0 grid-cols-1 gap-2'}>
{catalog ? (
<SourceControlActionOptionField
label={translate('settings.sourceControl.actionModel.model', 'Model')}
>
<Select
value={selected?.model ?? AGENT_DEFAULT_VALUE}
disabled={disabled}
onValueChange={(model) =>
save({ ...(model === AGENT_DEFAULT_VALUE ? {} : { model }), permissionMode })
}
>
<SourceControlActionOptionTrigger
disabled={disabled}
label={
selected?.model
? (findCatalogModel(catalog, selected.model)?.label ?? selected.model)
: translate('settings.sourceControl.actionModel.agentDefault', 'Agent default')
}
/>
<SelectContent>
<SelectItem value={AGENT_DEFAULT_VALUE}>
{translate('settings.sourceControl.actionModel.agentDefault', 'Agent default')}
</SelectItem>
{catalog.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
{unlistedModel ? (
<SelectItem value={unlistedModel}>{unlistedModel}</SelectItem>
) : null}
</SelectContent>
</Select>
</SourceControlActionOptionField>
) : null}
{selected?.model
? options.map(({ option, route }) => {
const choices =
option.kind.type === 'select'
? option.kind.choices
: [
{
value: 'true',
label: translate('settings.sourceControl.actionModel.on', 'On')
},
{
value: 'false',
label: translate('settings.sourceControl.actionModel.off', 'Off')
}
]
const current = selected.options?.[option.id]
return (
<SourceControlActionOptionField
key={option.id}
label={option.label}
hint={sourceControlActionRouteHint(route)}
>
<Select
value={current ?? AGENT_DEFAULT_VALUE}
disabled={disabled}
onValueChange={(next) => {
const { [option.id]: _old, ...rest } = selected.options ?? {}
const encoded =
next === AGENT_DEFAULT_VALUE
? null
: encodeCatalogOptionValue(
option.kind,
option.kind.type === 'boolean' ? next === 'true' : next
)
const values = {
...rest,
...(encoded === null ? {} : { [option.id]: encoded })
}
onChange({ ...selected, options: values })
}}
>
<SourceControlActionOptionTrigger
disabled={disabled}
label={
current
? (choices.find((choice) => choice.value === current)?.label ?? current)
: defaultLabel
}
/>
<SelectContent>
<SelectItem value={AGENT_DEFAULT_VALUE}>{defaultLabel}</SelectItem>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.label}
</SelectItem>
))}
{current && !choices.some((choice) => choice.value === current) ? (
<SelectItem value={current}>{current}</SelectItem>
) : null}
</SelectContent>
</Select>
</SourceControlActionOptionField>
)
})
: null}
</div>
)
}
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react'
import { Label } from '../ui/label'
import { SelectTrigger, SelectValue } from '../ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { translate } from '@/i18n/i18n'
import type { SourceControlActionChoiceRoute } from '../../../../shared/source-control-action-launch-preferences'
export function SourceControlActionOptionField({
label,
hint,
children
}: {
label: ReactNode
hint?: ReactNode
children: ReactNode
}): React.JSX.Element {
return (
<div className="min-h-[56.5px] min-w-0 flex-1 basis-48 space-y-2">
<Label variant="settings">{label}</Label>
{children}
{hint ? <p className="text-[11px] text-muted-foreground">{hint}</p> : null}
</div>
)
}
/** Says which launches a saved choice reaches when it is not both. */
export function sourceControlActionRouteHint(route: SourceControlActionChoiceRoute): string | null {
return route === 'chats'
? translate('settings.sourceControl.actionModel.chatsOnly', 'Applies to chats only')
: route === 'terminals'
? translate('settings.sourceControl.actionModel.terminalsOnly', 'Applies to terminals only')
: null
}
export function SourceControlActionOptionTrigger({
label,
disabled
}: {
label: string
disabled?: boolean
}): React.JSX.Element {
const trigger = (
<SelectTrigger size="sm" density="compact" className="h-8 w-full min-w-0" aria-label={label}>
<SelectValue className="min-w-0 flex-1">
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
</SelectValue>
</SelectTrigger>
)
return (
<Tooltip>
<TooltipTrigger asChild>
{disabled ? <span className="flex min-w-0">{trigger}</span> : trigger}
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
@@ -1,3 +1,4 @@
import { SourceControlActionArguments } from './SourceControlActionArguments'
import { Terminal } from 'lucide-react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -5,7 +6,8 @@ import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec
import { CUSTOM_AGENT_ID, isCustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
SOURCE_CONTROL_ACTION_LABELS,
type AiActionId
type AiActionId,
type SourceControlActionLaunchPreferences
} from '../../../../shared/source-control-ai-actions'
import { AgentIcon } from '@/lib/agent-catalog'
import { SourceControlActionVariableChips } from '../source-control/SourceControlActionVariableChips'
@@ -23,6 +25,12 @@ import {
getSourceControlAgentArgsPlaceholder
} from './source-control-action-recipe-options'
import { translate } from '@/i18n/i18n'
import { SourceControlActionOptionField } from './SourceControlActionOptionField'
import { useSourceControlActionAgent } from './use-source-control-action-agent'
import { shouldShowSourceControlActionArguments } from './source-control-action-arguments-visibility'
import { chatProviderAgents, useHostStructuredAgentsVersion } from '@/lib/chat-provider-agents'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
const DEFAULT_AGENT_VALUE = '__default_agent__'
@@ -36,8 +44,14 @@ type SourceControlActionRecipeRowProps<ActionId extends AiActionId> = {
hasUnsavedChanges?: boolean
repoOverrideNote?: React.ReactNode
onAgentChange: (actionId: ActionId, value: string) => void
/** Launch actions only, for chats and terminals alike; text actions run headless through CLI arguments. */
launchPreferences?: SourceControlActionLaunchPreferences
onLaunchPreferencesChange?: (
actionId: ActionId,
value: SourceControlActionLaunchPreferences | undefined
) => void
onTemplateChange: (actionId: ActionId, value: string) => void
onAgentArgsChange: (actionId: ActionId, value: string) => void
onAgentArgsChange: (actionId: ActionId, value: string, commit?: boolean) => void
onAppendVariable: (actionId: ActionId, variable: string) => void
onDiscard: (actionId: ActionId) => void
onSave: (actionId: ActionId) => void
@@ -69,12 +83,25 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
hasUnsavedChanges,
repoOverrideNote,
onAgentChange,
launchPreferences,
onLaunchPreferencesChange,
onTemplateChange,
onAgentArgsChange,
onAppendVariable,
onDiscard,
onSave
}: SourceControlActionRecipeRowProps<ActionId>): React.JSX.Element {
const actual = useSourceControlActionAgent(selectedAgent, defaultTuiAgent)
const settings = useAppStore((state) => state.settings)
useHostStructuredAgentsVersion()
const showArguments = shouldShowSourceControlActionArguments({
agent: actual.status === 'ready' ? actual.agent : null,
settings,
chatAgents: chatProviderAgents(LOCAL_EXECUTION_HOST_ID, undefined)
})
const expectedAgent = resolveAgentArgsPlaceholderAgent(selectedAgent, defaultTuiAgent)
const pickerAgent = selectedAgent || actual.status === 'pending' ? expectedAgent : actual.agent
const isLaunchAction = !AI_TEXT_ACTION_ID_SET.has(actionId)
const templateDirty =
hasUnsavedChanges ?? JSON.stringify(draftValue) !== JSON.stringify(baseValue)
const agentArgsPlaceholder = getSourceControlAgentArgsPlaceholder(
@@ -96,7 +123,7 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
value={selectedAgent ?? DEFAULT_AGENT_VALUE}
onValueChange={(value) => onAgentChange(actionId, value)}
>
<SelectTrigger size="sm" className="h-8 w-full text-xs">
<SelectTrigger size="sm" density="compact" className="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -137,24 +164,35 @@ export function SourceControlActionRecipeRow<ActionId extends AiActionId>({
) : null}
</div>
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-[220px_1fr]">
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.2cb4bb7e5d',
'CLI arguments'
)}
</Label>
<Input
value={draftValue.agentArgs}
spellCheck={false}
placeholder={agentArgsPlaceholder}
onChange={(event) => onAgentArgsChange(actionId, event.target.value)}
className="h-8 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-[11px] text-muted-foreground">
<div className="mt-3 grid min-w-0 grid-cols-1 items-start gap-3">
{isLaunchAction ? (
<div className="flex min-w-0 flex-wrap items-start gap-3">
<SourceControlActionArguments
showArguments={showArguments}
agent={pickerAgent}
value={draftValue.agentArgs}
preferences={launchPreferences}
disabled={actual.status === 'pending' || isSavingTemplate}
onArgsChange={(value, commit) => onAgentArgsChange(actionId, value, commit)}
onPreferencesChange={(value) => onLaunchPreferencesChange?.(actionId, value)}
/>
</div>
) : (
<SourceControlActionOptionField
label={translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
>
<Input
value={draftValue.agentArgs}
spellCheck={false}
placeholder={agentArgsPlaceholder}
onChange={(event) => onAgentArgsChange(actionId, event.target.value)}
variant="code"
className="h-8"
/>
</SourceControlActionOptionField>
)}
<div className="min-w-0 space-y-2">
<Label variant="settings">
{translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.fb09da4345',
'Command template'
@@ -32,6 +32,8 @@ function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideFie
'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate',
'Command template'
)
case 'launchPreferences':
return translate('settings.sourceControl.actionModel.launchOptions', 'Model and permissions')
}
const _exhaustive: never = field
return _exhaustive
@@ -12,8 +12,9 @@ import { CUSTOM_AGENT_ID } from '../../../../shared/commit-message-agent-spec'
import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec'
import {
SOURCE_CONTROL_ACTION_IDS,
setSourceControlActionDefault,
type SourceControlActionId
setSourceControlActionAgentDefault,
type SourceControlActionId,
type SourceControlAiActionDefaults
} from '../../../../shared/source-control-ai-actions'
import { Label } from '../ui/label'
import { useAppStore } from '@/store'
@@ -27,8 +28,10 @@ import { getRepositorySourceControlAiActionRecipeSectionId } from './repository-
import { matchesSettingsSearch } from './settings-search'
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
import { SourceControlActionRepoOverrideNote } from './SourceControlActionRepoOverrideNote'
import { retireActionLaunchPreferences } from './source-control-ai-action-recipe-draft'
import { useSourceControlActionRecipeDraftState } from './source-control-action-recipe-draft-state'
import { translate } from '@/i18n/i18n'
import { useSourceControlActionAgentInventory } from './use-source-control-action-agent'
type SourceControlAiActionRecipeDefaultsProps = {
config: SourceControlAiSettings
@@ -95,6 +98,9 @@ export function SourceControlAiActionRecipeDefaults({
searchQuery,
writeConfig
}: SourceControlAiActionRecipeDefaultsProps): React.JSX.Element | null {
useSourceControlActionAgentInventory(
config.enabled && matchesSettingsSearch(searchQuery, ACTION_RECIPES_SEARCH_ENTRY)
)
const allRepos = useRepos()
// Memoize the O(actions × repos) override scan so sibling recipe typing cannot repeat it.
const repos = useMemo(() => allRepos.filter((repo) => !isFolderRepo(repo)), [allRepos])
@@ -115,6 +121,7 @@ export function SourceControlAiActionRecipeDefaults({
savingActionTemplateIds,
onActionTemplateChange,
onActionAgentArgsChange,
onActionLaunchPreferencesChange,
saveActionTemplateDraft,
discardActionTemplateDraft,
appendVariable
@@ -125,6 +132,27 @@ export function SourceControlAiActionRecipeDefaults({
writeConfig
})
const writeActionDefault = async (
update: (actions: SourceControlAiActionDefaults | undefined) => SourceControlAiActionDefaults
): Promise<boolean> => {
try {
await writeConfig((current) => {
return { actions: update(current.actions) }
})
return true
} catch (error) {
console.error('Failed to save Source Control AI action agent default', error)
toast.error(
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b5f46664d3',
'Failed to save Source Control AI action default: {{value0}}',
{ value0: error instanceof Error ? error.message : 'Unknown error' }
)
)
return false
}
}
const onActionAgentChange = async (
actionId: SourceControlActionId,
value: string
@@ -135,27 +163,14 @@ export function SourceControlAiActionRecipeDefaults({
: value === CUSTOM_AGENT_ID
? CUSTOM_AGENT_ID
: (value as TuiAgent)
let previousActions = config.actions
try {
await writeConfig((current) => {
previousActions = current.actions
return {
actions: setSourceControlActionDefault(current.actions, actionId, { agentId })
}
})
} catch (error) {
console.error('Failed to save Source Control AI action agent default', error)
try {
await writeConfig({ actions: previousActions })
} catch (rollbackError) {
console.error('Failed to roll back Source Control AI action agent default', rollbackError)
}
toast.error(
translate(
'auto.components.settings.SourceControlAiActionRecipeDefaults.b5f46664d3',
'Failed to save Source Control AI action default: {{value0}}',
{ value0: error instanceof Error ? error.message : 'Unknown error' }
)
const changed = config.actions?.[actionId]?.agentId !== agentId
const captured = actionRecipeDraftState.values[actionId].launchPreferences
const saved = await writeActionDefault((actions) =>
setSourceControlActionAgentDefault(actions, actionId, agentId)
)
if (saved && changed && captured) {
onActionLaunchPreferencesChange(actionId, (current) =>
retireActionLaunchPreferences(current, captured, agentId)
)
}
}
@@ -213,11 +228,15 @@ export function SourceControlAiActionRecipeDefaults({
/>
}
onAgentChange={(id, value) => void onActionAgentChange(id, value)}
launchPreferences={actionRecipeDraftState.values[actionId].launchPreferences}
onLaunchPreferencesChange={(id, value) =>
void onActionLaunchPreferencesChange(id, value)
}
onTemplateChange={onActionTemplateChange}
onAgentArgsChange={onActionAgentArgsChange}
onAppendVariable={appendVariable}
onDiscard={discardActionTemplateDraft}
onSave={(id) => void saveActionTemplateDraft(id)}
onSave={(id) => void saveActionTemplateDraft(id).catch(console.error)}
/>
)
})}
@@ -58,7 +58,8 @@ beforeEach(() => {
configurable: true,
value: {
settings: { set: persist, get: vi.fn(async () => useAppStore.getState().settings) },
runtime: { call: runtimeCall }
runtime: { call: runtimeCall },
preflight: { detectAgents: async () => [] }
}
})
})
@@ -106,7 +106,7 @@ function argumentsInput(label: string): HTMLTextAreaElement {
if (expand) {
fireEvent.click(expand)
}
const input = within(row).getByPlaceholderText('No default arguments')
const input = within(row).getByRole('textbox', { name: 'Arguments' })
if (!(input instanceof HTMLTextAreaElement)) {
throw new Error('Missing Arguments input')
}
@@ -123,6 +123,9 @@ it.each(
savedArguments(models, 'full-access')
render(<AgentsHarness />)
const input = argumentsInput('Claude')
expect(screen.getByRole('button', { name: 'Claude permissions' }).getAttribute('data-size')).toBe(
'xs'
)
const remove = () =>
screen.getByRole('button', { name: /Remove .* and use Claude's own settings/ })
fireEvent.click(remove())
@@ -59,7 +59,7 @@ it('keeps the Chat draft and guard after actual persistence fails, then saves it
persist.mockRejectedValueOnce(new Error('Settings disk is unavailable'))
render(<ChatPersistenceHarness model={model} />)
const template = screen.getByText('Command template').nextElementSibling
const args = screen.getByText('CLI arguments').nextElementSibling
const args = screen.getByText('Arguments').nextElementSibling
if (!(template instanceof HTMLTextAreaElement) || !(args instanceof HTMLInputElement)) {
throw new Error('Chat recipe controls are missing')
}
@@ -0,0 +1,52 @@
import type {
SourceControlActionId,
SourceControlActionLaunchPreferences
} from '../../../../shared/source-control-ai-actions'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import {
normalizeRepoAiDraft,
readCompleteRecipeForDraft,
setActionOverride
} from './repository-source-control-ai-draft'
type RepoActionRecipe = NonNullable<
NonNullable<RepoSourceControlAiOverrides['actionOverrides']>[SourceControlActionId]
>
export function withRepoAiActionAgent(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId,
agentId: RepoActionRecipe['agentId']
): RepoSourceControlAiOverrides {
const currentRecipe = readCompleteRecipeForDraft(base, settings, actionId)
// A model and permission mode are one agent's choices; neither survives a change of agent.
const { launchPreferences, ...rest } = currentRecipe
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...rest,
...(launchPreferences && currentRecipe.agentId === agentId ? { launchPreferences } : {}),
agentId
})
)
}
export function withRepoAiActionLaunchPreferences(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId,
launchPreferences: SourceControlActionLaunchPreferences | undefined
): RepoSourceControlAiOverrides {
const { launchPreferences: _previous, ...currentRecipe } = readCompleteRecipeForDraft(
base,
settings,
actionId
)
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...currentRecipe,
...(launchPreferences ? { launchPreferences } : {})
})
)
}
@@ -54,6 +54,8 @@ export function dropRepoLegacyInstructionForAction(
}
}
/** The action an edit starts from: the complete recipe it resolves to, owned or partial, keeping a
* saved explicit args clear and never the agent only detected to run a default selection. */
export function readCompleteRecipeForDraft(
current: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
@@ -64,7 +66,8 @@ export function readCompleteRecipeForDraft(
repo: { sourceControlAi: current },
actionId
})
return completeRepoActionRecipe(recipe, actionId)
const cleared = current.actionOverrides?.[actionId]?.agentArgs === null
return { ...completeRepoActionRecipe(recipe, actionId), ...(cleared ? { agentArgs: null } : {}) }
}
export function setActionOverride(
@@ -155,42 +158,50 @@ export function withRepoAiActionMode(
)
}
export function withRepoAiActionAgent(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId,
agentId: RepoActionRecipe['agentId']
): RepoSourceControlAiOverrides {
const currentRecipe =
base.actionOverrides?.[actionId] ?? readCompleteRecipeForDraft(base, settings, actionId)
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...currentRecipe,
agentId
})
)
}
export function withRepoAiActionRecipeText(
base: RepoSourceControlAiOverrides,
settings: GlobalSettings | null,
actionId: SourceControlActionId,
text: { commandInputTemplate: string; agentArgs: string }
text: ActionRecipeTextDraft
): RepoSourceControlAiOverrides {
const currentRecipe =
base.actionOverrides?.[actionId] ?? readCompleteRecipeForDraft(base, settings, actionId)
const currentRecipe = readCompleteRecipeForDraft(base, settings, actionId)
const preferencesEdited = !sameDraftLaunchPreferences(
text,
readActionRecipeTextDraft(base, actionId)
)
return normalizeRepoAiDraft(
setActionOverride(base, actionId, {
...currentRecipe,
commandInputTemplate: text.commandInputTemplate,
agentArgs: text.agentArgs
...(typeof text.commandInputTemplate === 'string'
? { commandInputTemplate: text.commandInputTemplate }
: {}),
...(text.agentArgs !== undefined ? { agentArgs: text.agentArgs } : {}),
...(preferencesEdited ? { launchPreferences: text.launchPreferences } : {})
})
)
}
export type ActionRecipeTextDraft = {
commandInputTemplate: string
agentArgs: string
/** The action's text as stored: an unset or cleared value stays so until the user types; inputs
* show it as empty. */
export type ActionRecipeTextDraft = Pick<
RepoActionRecipe,
'commandInputTemplate' | 'agentArgs' | 'launchPreferences'
>
// An own undefined is a deliberate clear; an absent preference leaves inheritance untouched.
function sameDraftLaunchPreferences(a: ActionRecipeTextDraft, b: ActionRecipeTextDraft): boolean {
return (
Object.hasOwn(a, 'launchPreferences') === Object.hasOwn(b, 'launchPreferences') &&
JSON.stringify(a.launchPreferences) === JSON.stringify(b.launchPreferences)
)
}
function sameActionRecipeText(a: ActionRecipeTextDraft, b: ActionRecipeTextDraft): boolean {
return (
a.commandInputTemplate === b.commandInputTemplate &&
a.agentArgs === b.agentArgs &&
sameDraftLaunchPreferences(a, b)
)
}
export function readActionRecipeTextDraft(
@@ -199,25 +210,29 @@ export function readActionRecipeTextDraft(
): ActionRecipeTextDraft {
const recipe = value.actionOverrides?.[actionId]
return {
commandInputTemplate:
typeof recipe?.commandInputTemplate === 'string' ? recipe.commandInputTemplate : '',
agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs : ''
commandInputTemplate: recipe?.commandInputTemplate,
agentArgs: recipe?.agentArgs,
...(recipe?.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {})
}
}
export function composeDisplayRepoAi(
immediate: RepoSourceControlAiOverrides,
customCommandDraft: string | null,
actionTextDrafts: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>
actionTextDrafts: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>,
settings: GlobalSettings | null = null
): RepoSourceControlAiOverrides {
let next =
customCommandDraft === null ? immediate : withRepoAiCustomCommand(immediate, customCommandDraft)
for (const actionId of SOURCE_CONTROL_ACTION_IDS) {
const draft = actionTextDrafts[actionId]
const currentRecipe = next.actionOverrides?.[actionId]
let currentRecipe = next.actionOverrides?.[actionId]
if (!draft || !hasOwnActionOverride(next.actionOverrides, actionId) || !currentRecipe) {
continue
}
if (!sameDraftLaunchPreferences(draft, readActionRecipeTextDraft(next, actionId))) {
currentRecipe = readCompleteRecipeForDraft(next, settings, actionId)
}
next = {
...next,
actionOverrides: {
@@ -225,7 +240,8 @@ export function composeDisplayRepoAi(
[actionId]: {
...currentRecipe,
commandInputTemplate: draft.commandInputTemplate,
agentArgs: draft.agentArgs
agentArgs: draft.agentArgs,
launchPreferences: draft.launchPreferences
}
}
}
@@ -238,23 +254,27 @@ export function computeActionDirtyById(
persisted: RepoSourceControlAiOverrides,
actionTextDrafts: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>
): Record<SourceControlActionId, boolean> {
return Object.fromEntries(
SOURCE_CONTROL_ACTION_IDS.map((actionId) => {
if (!hasOwnActionOverride(immediate.actionOverrides, actionId)) {
return [actionId, false]
}
const draft = actionTextDrafts[actionId] ?? readActionRecipeTextDraft(immediate, actionId)
// Prefer persisted text as the base; if the override is only optimistic, compare against immediate.
const compareBase = hasOwnActionOverride(persisted.actionOverrides, actionId)
? readActionRecipeTextDraft(persisted, actionId)
: readActionRecipeTextDraft(immediate, actionId)
return [
actionId,
draft.commandInputTemplate !== compareBase.commandInputTemplate ||
draft.agentArgs !== compareBase.agentArgs
]
})
) as Record<SourceControlActionId, boolean>
const isDirty = (actionId: SourceControlActionId): boolean => {
if (!hasOwnActionOverride(immediate.actionOverrides, actionId)) {
return false
}
const draft = actionTextDrafts[actionId] ?? readActionRecipeTextDraft(immediate, actionId)
// Prefer persisted text as the base; if the override is only optimistic, compare against immediate.
const compareBase = hasOwnActionOverride(persisted.actionOverrides, actionId)
? readActionRecipeTextDraft(persisted, actionId)
: readActionRecipeTextDraft(immediate, actionId)
return !sameActionRecipeText(draft, compareBase)
}
return {
commitMessage: isDirty('commitMessage'),
pullRequest: isDirty('pullRequest'),
branchName: isDirty('branchName'),
fixCommitFailure: isDirty('fixCommitFailure'),
fixPushFailure: isDirty('fixPushFailure'),
fixChecks: isDirty('fixChecks'),
resolveConflicts: isDirty('resolveConflicts'),
resolveComments: isDirty('resolveComments')
}
}
export function retainDivergentActionTextDrafts(
@@ -268,10 +288,7 @@ export function retainDivergentActionTextDrafts(
continue
}
const persistedText = readActionRecipeTextDraft(persisted, actionId)
if (
draft.commandInputTemplate !== persistedText.commandInputTemplate ||
draft.agentArgs !== persistedText.agentArgs
) {
if (!sameActionRecipeText(draft, persistedText)) {
next[actionId] = draft
}
}
@@ -292,28 +309,9 @@ export function clearActionTextDraftIfUnchanged(
saved: ActionRecipeTextDraft
): Partial<Record<SourceControlActionId, ActionRecipeTextDraft>> {
const latest = current[actionId]
if (
latest &&
(latest.commandInputTemplate !== saved.commandInputTemplate ||
latest.agentArgs !== saved.agentArgs)
) {
if (latest && !sameActionRecipeText(latest, saved)) {
return current
}
const { [actionId]: _removed, ...rest } = current
return rest
}
export function patchActionTextDraft(
current: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>,
immediate: RepoSourceControlAiOverrides,
actionId: SourceControlActionId,
patch: Partial<ActionRecipeTextDraft>
): Partial<Record<SourceControlActionId, ActionRecipeTextDraft>> {
return {
...current,
[actionId]: {
...(current[actionId] ?? readActionRecipeTextDraft(immediate, actionId)),
...patch
}
}
}
@@ -5,6 +5,15 @@ import { normalizeSourceControlAiSettings } from '../../../../shared/source-cont
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import type { SourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
import { useRepositorySourceControlAiGlobalUx } from './repository-source-control-ai-global-ux'
import { withRepoAiActionLaunchPreferences } from './repository-source-control-ai-action-agent-draft'
import {
readActionRecipeTextDraft,
withRepoAiActionRecipeText
} from './repository-source-control-ai-draft'
import { toSourceControlAiRepoUpdate } from '../../../../shared/source-control-ai-recipe-save'
import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions'
import { getDefaultSettings } from '../../../../shared/constants'
import { resolveSourceControlActionRecipe } from '../../../../shared/source-control-ai-operation-policy'
const source = normalizeSourceControlAiSettings(undefined, undefined)
@@ -33,6 +42,103 @@ const withRecipe = (commandInputTemplate: string): RepoSourceControlAiOverrides
})
describe('useRepositorySourceControlAiGlobalUx', () => {
it('keeps dirty preferences after failed or same-agent saves and clears the old namespace after a successful switch', async () => {
const preferences = { agentId: 'claude', model: 'sonnet', permissionMode: 'ask' } as const
const dirty = { ...preferences, model: 'opus', permissionMode: 'full-access' } as const
const updateRepo = vi.fn().mockResolvedValue(true)
const { result } = setup(
{
actionOverrides: {
fixChecks: {
agentId: 'claude',
agentArgs: '',
commandInputTemplate: 'original',
launchPreferences: preferences
}
}
},
updateRepo
)
act(() => {
result.current.updateActionLaunchPreferences('fixChecks', dirty)
result.current.updateActionAgentArgs('fixChecks', '--verbose')
result.current.updateActionTemplate('fixChecks', 'edited')
})
updateRepo.mockResolvedValueOnce(false)
await act(async () => result.current.updateActionAgent('fixChecks', 'codex'))
expect(result.current.displayRepoAi.actionOverrides?.fixChecks?.launchPreferences).toEqual(
dirty
)
await act(async () => result.current.updateActionAgent('fixChecks', 'claude'))
expect(result.current.displayRepoAi.actionOverrides?.fixChecks?.launchPreferences).toEqual(
dirty
)
await act(async () => result.current.updateActionAgent('fixChecks', 'codex'))
expect(
result.current.displayRepoAi.actionOverrides?.fixChecks?.launchPreferences
).toBeUndefined()
await act(async () => result.current.saveActionRecipeText('fixChecks'))
const saved = updateRepo.mock.lastCall?.[1].sourceControlAi
const reopened = resolveSourceControlActionRecipe({
settings: null,
repo: { sourceControlAi: saved },
actionId: 'fixChecks'
})
expect(reopened).toMatchObject({
agentId: 'codex',
agentArgs: '--verbose',
commandInputTemplate: 'edited'
})
expect(reopened.launchPreferences).toBeUndefined()
})
it.each(['grok', 'claude', 'identical', 'clear'] as const)(
'two queued switches preserve a newly authored %s value and unrelated text',
async (edit) => {
const old = { agentId: 'claude', model: 'sonnet', permissionMode: 'ask' } as const
const finishes: ((ok: boolean) => void)[] = []
const updateRepo = vi.fn<
(repoId: string, updates: SourceControlAiRepoUpdate) => Promise<boolean>
>(() => new Promise<boolean>((resolve) => finishes.push(resolve)))
const { result } = setup(
{ actionOverrides: { fixChecks: { agentId: 'claude' } } },
updateRepo
)
act(() => result.current.updateActionLaunchPreferences('fixChecks', old))
await act(async () => result.current.updateActionAgent('fixChecks', 'codex'))
await act(async () =>
result.current.updateActionAgent(
'fixChecks',
edit === 'clear' ? '__default_agent__' : edit === 'claude' ? 'claude' : 'grok'
)
)
const authored =
edit === 'clear'
? undefined
: { ...old, agentId: edit === 'grok' ? ('grok' as const) : ('claude' as const) }
act(() => {
result.current.updateActionLaunchPreferences('fixChecks', authored)
result.current.updateActionAgentArgs('fixChecks', '--verbose')
result.current.updateActionTemplate('fixChecks', 'new template')
})
await act(async () => finishes[0](true))
await act(async () => finishes[1](true))
const row = result.current.displayRepoAi.actionOverrides?.fixChecks
expect(row?.launchPreferences).toBe(authored)
expect(Object.hasOwn(row ?? {}, 'launchPreferences')).toBe(true)
expect(row).toMatchObject({ agentArgs: '--verbose', commandInputTemplate: 'new template' })
const save = result.current.saveActionRecipeText('fixChecks')
await act(async () => {})
await act(async () => {
finishes[2](true)
await save
})
expect(
updateRepo.mock.lastCall?.[1].sourceControlAi?.actionOverrides?.fixChecks?.launchPreferences
).toEqual(authored)
}
)
it('keeps an optimistic immediate field when the persist succeeds', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const { result } = setup({}, updateRepo)
@@ -230,3 +336,129 @@ describe('useRepositorySourceControlAiGlobalUx', () => {
expect(result.current.saveError).toBe('Failed to save Source Control AI settings.')
})
})
// One action edit saves that action complete, through the real serializer, and nothing else.
describe('editing one repository action', () => {
const base = getDefaultSettings('/tmp')
const terra = { agentId: 'codex' as const, model: 'gpt-5.6-terra' }
const global = { agentId: 'codex' as const, commandInputTemplate: 'g', agentArgs: '--global' }
const actions = {
fixCommitFailure: { ...global, launchPreferences: terra },
fixPushFailure: global
}
const settings = { ...base, sourceControlAi: { ...base.sourceControlAi!, actions } }
const reload = (repo: RepoSourceControlAiOverrides, actionId: SourceControlActionId) => {
const update = toSourceControlAiRepoUpdate(repo)
const sourceControlAi = update.sourceControlAi ?? undefined
return resolveSourceControlActionRecipe({ settings, repo: { sourceControlAi }, actionId })
}
const editPrompt = (repo: RepoSourceControlAiOverrides, actionId: SourceControlActionId) =>
withRepoAiActionRecipeText(repo, settings, actionId, {
...readActionRecipeTextDraft(repo, actionId),
commandInputTemplate: 'mine'
})
it('drafts an inherited model and permission clear separately from untouched sparse preferences', async () => {
const inherited = { ...terra, permissionMode: 'ask' } as const
const settingsWithPermission = {
...settings,
sourceControlAi: {
...settings.sourceControlAi,
actions: {
...actions,
fixCommitFailure: { ...global, launchPreferences: inherited }
}
}
}
const sibling = { ...global, commandInputTemplate: 'sibling' }
const legacy: RepoSourceControlAiOverrides = {
actionOverrides: {
fixCommitFailure: { agentArgs: '--verbose' },
fixPushFailure: sibling
}
}
const updateRepo = vi.fn().mockResolvedValue(true)
const { result, rerender } = renderHook(
(persistedRepoAi: RepoSourceControlAiOverrides) =>
useRepositorySourceControlAiGlobalUx({
repoId: 'repo',
persistedRepoAi,
settings: settingsWithPermission,
source: settingsWithPermission.sourceControlAi,
updateRepo
}),
{ initialProps: legacy }
)
const effective = () =>
resolveSourceControlActionRecipe({
settings: settingsWithPermission,
repo: { sourceControlAi: result.current.displayRepoAi },
actionId: 'fixCommitFailure'
})
act(() => result.current.updateActionLaunchPreferences('fixCommitFailure', undefined))
expect(result.current.actionDirtyById.fixCommitFailure).toBe(true)
expect(effective().launchPreferences).toBeUndefined()
rerender({ ...legacy, enabled: true })
expect(result.current.actionDirtyById.fixCommitFailure).toBe(true)
act(() => result.current.discardActionRecipeText('fixCommitFailure'))
expect(effective().launchPreferences).toEqual(inherited)
act(() => result.current.updateActionLaunchPreferences('fixCommitFailure', undefined))
await act(async () => result.current.saveActionRecipeText('fixCommitFailure'))
const saved = updateRepo.mock.lastCall?.[1].sourceControlAi
const oldImmediate = withRepoAiActionLaunchPreferences(
{ ...legacy, enabled: true },
settingsWithPermission,
'fixCommitFailure',
undefined
)
expect(saved).toEqual(toSourceControlAiRepoUpdate(oldImmediate).sourceControlAi)
expect(saved.actionOverrides.fixPushFailure).toEqual(sibling)
rerender(saved)
expect(result.current.actionDirtyById.fixCommitFailure).toBe(false)
expect(effective().launchPreferences).toBeUndefined()
expect(effective().agentArgs).toBe('--verbose')
})
it('keeps a legacy partial row on its agent and empty args when its choice is set or reset', () => {
const legacy = { actionOverrides: { fixCommitFailure: { agentArgs: '' } } }
const set = (value?: typeof terra) =>
reload(
withRepoAiActionLaunchPreferences(legacy, settings, 'fixCommitFailure', value),
'fixCommitFailure'
)
const luna = { ...terra, model: 'gpt-5.6-luna' }
expect(set(luna)).toMatchObject({ agentId: 'codex', agentArgs: '', launchPreferences: luna })
expect(set()).toMatchObject({ agentId: 'codex', agentArgs: '' })
expect(set().launchPreferences).toBeUndefined()
})
it('makes the copied model owned by the action, so it outranks the raw model it yielded to', () => {
const legacy = { actionOverrides: { fixCommitFailure: { agentArgs: '--model gpt-5.6-sol' } } }
const stored = {
settings,
repo: { sourceControlAi: legacy },
actionId: 'fixCommitFailure'
} as const
expect(resolveSourceControlActionRecipe(stored).launchPreferencesYieldToAgentArgs).toBe(true)
const edited = reload(editPrompt(legacy, 'fixCommitFailure'), 'fixCommitFailure')
expect(edited).toMatchObject({ commandInputTemplate: 'mine', launchPreferences: terra })
expect(edited.launchPreferencesYieldToAgentArgs).toBeUndefined()
})
it('saves a prompt edit without authoring args the user never typed or touching a sibling', () => {
const sibling = { agentArgs: '--repo' }
const cleared = {
fixCommitFailure: { agentId: 'codex' as const, agentArgs: null },
fixPushFailure: sibling
}
expect(editPrompt({ actionOverrides: cleared }, 'fixCommitFailure').actionOverrides).toEqual({
fixCommitFailure: expect.objectContaining({ agentArgs: null, commandInputTemplate: 'mine' }),
fixPushFailure: sibling
})
// Absent args under nonempty Globals: the edited action keeps the args it ran with.
const absent = { actionOverrides: { fixCommitFailure: { agentId: 'codex' as const } } }
expect(reload(editPrompt(absent, 'fixCommitFailure'), 'fixCommitFailure').agentArgs).toBe(
'--global'
)
})
})
@@ -1,3 +1,4 @@
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
@@ -14,24 +15,24 @@ import {
composeDisplayRepoAi,
computeActionDirtyById,
hasOwnActionOverride,
patchActionTextDraft,
readCompleteRecipeForDraft,
readActionRecipeTextDraft,
type ActionRecipeTextDraft,
retainCustomCommandDraft,
retainDivergentActionTextDrafts,
withRepoAiActionAgent,
withRepoAiActionMode,
withRepoAiActionRecipeText,
withRepoAiCustomCommand,
withRepoAiEnabled,
withRepoAiHostedReviewDefault
} from './repository-source-control-ai-draft'
import {
ACTION_MODE_INHERIT,
DEFAULT_AGENT_VALUE,
readInheritedCommandTemplate
} from './repository-source-control-ai-labels'
import { ACTION_MODE_INHERIT, DEFAULT_AGENT_VALUE } from './repository-source-control-ai-labels'
import { createRepoAiPersistQueue } from './repository/source-control/repository-source-control-ai-persist-queue'
import { withRepoAiActionAgent } from './repository-source-control-ai-action-agent-draft'
import {
createRepoActionTextDraftEditors,
resetActionDraftLaunchPreferences
} from './repository-source-control-ai-text-draft'
export { normalizeRepoAiDraft as normalizePersistedRepoAi } from './repository-source-control-ai-draft'
@@ -138,7 +139,8 @@ export function useRepositorySourceControlAiGlobalUx({
}
const persist = (
transform: (base: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides
transform: (base: RepoSourceControlAiOverrides) => RepoSourceControlAiOverrides,
onSuccess?: () => void
): void => {
const repoIdForWrite = beginWrite()
void queueRef.current.persistTransform(transform).then((ok) => {
@@ -149,6 +151,9 @@ export function useRepositorySourceControlAiGlobalUx({
if (!ok && pendingWritesRef.current === 0) {
commitImmediate(persistedRef.current)
}
if (ok) {
onSuccess?.()
}
})
}
@@ -209,38 +214,28 @@ export function useRepositorySourceControlAiGlobalUx({
: value === CUSTOM_AGENT_ID
? CUSTOM_AGENT_ID
: (value as TuiAgent)
const changed =
readCompleteRecipeForDraft(immediateRepoAiRef.current, settings, actionId).agentId !== agentId
const captured = actionTextDrafts[actionId]?.launchPreferences
commitImmediate(withRepoAiActionAgent(immediateRepoAiRef.current, settings, actionId, agentId))
persist((base) => withRepoAiActionAgent(base, settings, actionId, agentId))
}
const updateActionTemplate = (actionId: SourceControlActionId, value: string): void => {
setActionTextDrafts((current) =>
patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
commandInputTemplate: value
})
persist(
(base) => withRepoAiActionAgent(base, settings, actionId, agentId),
() => {
if (changed) {
setActionTextDrafts((current) =>
resetActionDraftLaunchPreferences(current, actionId, agentId, captured)
)
}
}
)
}
const updateActionAgentArgs = (actionId: SourceControlActionId, value: string): void => {
setActionTextDrafts((current) =>
patchActionTextDraft(current, immediateRepoAiRef.current, actionId, { agentArgs: value })
)
}
const appendVariable = (actionId: SourceControlActionId, variable: string): void => {
setActionTextDrafts((current) => {
const draft =
current[actionId] ?? readActionRecipeTextDraft(immediateRepoAiRef.current, actionId)
const currentTemplate =
draft.commandInputTemplate.length > 0
? draft.commandInputTemplate
: readInheritedCommandTemplate(source, actionId)
const separator = currentTemplate.endsWith('\n') || currentTemplate.length === 0 ? '' : ' '
return patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
commandInputTemplate: `${currentTemplate}${separator}{${variable}}`
})
})
}
const {
updateActionLaunchPreferences,
updateActionTemplate,
updateActionAgentArgs,
appendVariable
} = createRepoActionTextDraftEditors(setActionTextDrafts, immediateRepoAiRef, source)
const actionDirtyById = useMemo(
() => computeActionDirtyById(immediateRepoAi, baselineRepoAi, actionTextDrafts),
@@ -251,8 +246,15 @@ export function useRepositorySourceControlAiGlobalUx({
if (!actionDirtyById[actionId] || savingActionIds[actionId]) {
return
}
const draft =
actionTextDrafts[actionId] ?? readActionRecipeTextDraft(immediateRepoAiRef.current, actionId)
const draft = liftActionPermissionArgs(
{
...(actionTextDrafts[actionId] ??
readActionRecipeTextDraft(immediateRepoAiRef.current, actionId)),
agentId: immediateRepoAiRef.current.actionOverrides?.[actionId]?.agentId
},
true,
actionId
)
setSavingActionIds((current) => ({ ...current, [actionId]: true }))
const repoIdForWrite = beginWrite()
try {
@@ -271,7 +273,9 @@ export function useRepositorySourceControlAiGlobalUx({
immediateRepoAiRef.current = next
return next
})
setActionTextDrafts((current) => clearActionTextDraftIfUnchanged(current, actionId, draft))
setActionTextDrafts((current) =>
clearActionTextDraftIfUnchanged(current, actionId, actionTextDrafts[actionId] ?? draft)
)
} finally {
pendingWritesRef.current = Math.max(0, pendingWritesRef.current - 1)
if (mountedRef.current && repoIdRef.current === repoIdForWrite) {
@@ -288,8 +292,8 @@ export function useRepositorySourceControlAiGlobalUx({
}
const displayRepoAi = useMemo(
() => composeDisplayRepoAi(immediateRepoAi, customCommandDraft, actionTextDrafts),
[actionTextDrafts, customCommandDraft, immediateRepoAi]
() => composeDisplayRepoAi(immediateRepoAi, customCommandDraft, actionTextDrafts, settings),
[actionTextDrafts, customCommandDraft, immediateRepoAi, settings]
)
return {
@@ -303,6 +307,7 @@ export function useRepositorySourceControlAiGlobalUx({
updateHostedReviewDefault,
updateActionMode,
updateActionAgent,
updateActionLaunchPreferences,
updateActionTemplate,
updateActionAgentArgs,
appendVariable,
@@ -61,11 +61,12 @@ export function completeRepoActionRecipe(
typeof recipe.commandInputTemplate === 'string'
? recipe.commandInputTemplate
: DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]
const agentArgs = typeof recipe.agentArgs === 'string' ? recipe.agentArgs : ''
return {
agentId: recipe.agentId ?? null,
commandInputTemplate,
...(agentArgs ? { agentArgs } : {})
// Empty args are a value: they suppress the agent's default flags, unlike absent args.
...(typeof recipe.agentArgs === 'string' ? { agentArgs: recipe.agentArgs } : {}),
...(recipe.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {})
}
}
@@ -0,0 +1,122 @@
import type { Dispatch, SetStateAction } from 'react'
import type {
SourceControlActionId,
SourceControlActionLaunchPreferences,
SourceControlActionRecipe
} from '../../../../shared/source-control-ai-actions'
import type {
RepoSourceControlAiOverrides,
SourceControlAiSettings
} from '../../../../shared/source-control-ai-types'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import {
readActionRecipeTextDraft,
type ActionRecipeTextDraft
} from './repository-source-control-ai-draft'
import { retireActionLaunchPreferences } from './source-control-ai-action-recipe-draft'
import { readInheritedCommandTemplate } from './repository-source-control-ai-labels'
export function resetActionDraftLaunchPreferences(
current: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>,
actionId: SourceControlActionId,
agentId: SourceControlActionRecipe['agentId'],
captured: SourceControlActionLaunchPreferences | undefined
): Partial<Record<SourceControlActionId, ActionRecipeTextDraft>> {
const draft = current[actionId]
if (
!draft ||
retireActionLaunchPreferences(draft.launchPreferences, captured, agentId) ===
draft.launchPreferences
) {
return current
}
const { launchPreferences: _oldAgent, ...text } = draft
return { ...current, [actionId]: text }
}
export function patchActionTextDraft(
current: Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>,
immediate: RepoSourceControlAiOverrides,
actionId: SourceControlActionId,
patch: Partial<ActionRecipeTextDraft>
): Partial<Record<SourceControlActionId, ActionRecipeTextDraft>> {
return {
...current,
[actionId]: {
...(current[actionId] ?? readActionRecipeTextDraft(immediate, actionId)),
...patch
}
}
}
export function createRepoActionTextDraftEditors(
setActionTextDrafts: Dispatch<
SetStateAction<Partial<Record<SourceControlActionId, ActionRecipeTextDraft>>>
>,
immediateRepoAiRef: { current: RepoSourceControlAiOverrides },
source: SourceControlAiSettings
) {
const updateActionLaunchPreferences = (
actionId: SourceControlActionId,
value: SourceControlActionLaunchPreferences | undefined
): void => {
setActionTextDrafts((current) =>
patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
launchPreferences: value
})
)
}
const updateActionTemplate = (actionId: SourceControlActionId, value: string): void => {
setActionTextDrafts((current) =>
patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
commandInputTemplate: value
})
)
}
const updateActionAgentArgs = (
actionId: SourceControlActionId,
value: string,
commit = false
): void => {
setActionTextDrafts((current) => {
const patched = patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
agentArgs: value
})
return commit
? {
...patched,
[actionId]: liftActionPermissionArgs(
{
...patched[actionId],
agentId: immediateRepoAiRef.current.actionOverrides?.[actionId]?.agentId
},
true,
actionId
)
}
: patched
})
}
const appendVariable = (actionId: SourceControlActionId, variable: string): void => {
setActionTextDrafts((current) => {
const draft =
current[actionId] ?? readActionRecipeTextDraft(immediateRepoAiRef.current, actionId)
const currentTemplate =
draft.commandInputTemplate || readInheritedCommandTemplate(source, actionId)
const separator = currentTemplate.endsWith('\n') || currentTemplate.length === 0 ? '' : ' '
return patchActionTextDraft(current, immediateRepoAiRef.current, actionId, {
commandInputTemplate: `${currentTemplate}${separator}{${variable}}`
})
})
}
return {
updateActionLaunchPreferences,
updateActionTemplate,
updateActionAgentArgs,
appendVariable
}
}
@@ -34,7 +34,7 @@ export function renderGitSettingsSection(context: SettingsRenderContext): React.
<CommitMessageAiPane
settings={model.settings}
updateSettings={model.updateSettings}
writeSourceControlAiSettings={interactions.writeSourceControlAiSettings}
writeSourceControlAiSettings={interactions.writeSourceControlAiSettingsOrThrow}
onCustomPromptDirtyChange={model.setHasUnsavedCommitPromptChanges}
customPromptDiscardSignal={model.sourceControlAiPromptDiscardSignal}
settingsSearchQuery={model.settingsSearchQuery}
@@ -0,0 +1,24 @@
import type { NativeChatDefaultSettings } from '../../../../shared/structured-native-chat-launch-route'
import { isNativeChatDefaultFor } from '../../../../shared/structured-native-chat-launch-route'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type { AgentLaunchSurface } from '@/lib/agent-session-launch-plan'
/** Settings knows the default surface; workspace-specific terminal fallback stays a launch decision. */
export function shouldShowSourceControlActionArguments({
agent,
settings,
chatAgents,
launchKind
}: {
agent: TuiAgent | null | undefined
settings: Partial<NativeChatDefaultSettings> | null | undefined
chatAgents: readonly TuiAgent[]
launchKind?: AgentLaunchSurface
}): boolean {
return Boolean(
agent &&
(launchKind
? launchKind === 'terminal'
: !isNativeChatDefaultFor(settings, agent) || !chatAgents.includes(agent))
)
}
@@ -0,0 +1,145 @@
// @vitest-environment happy-dom
import { useState } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import './__fixtures__/settings-persistence-test-fixture'
import { useAppStore } from '@/store'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type {
AiActionId,
SourceControlActionLaunchPreferences
} from '../../../../shared/source-control-ai-actions'
import { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
loadHostStructuredAgents,
resetHostStructuredAgentsForTests
} from '@/runtime/host-structured-agents'
import { TooltipProvider } from '../ui/tooltip'
import { SourceControlActionRecipeRow } from './SourceControlActionRecipeRow'
import { shouldShowSourceControlActionArguments } from './source-control-action-arguments-visibility'
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn(async () => ({ agents: [{ agent: 'grok', capabilities: {} }] }))
}))
const unused = vi.fn()
function configure(patch: Partial<GlobalSettings>) {
useAppStore.setState(({ settings }) => ({ settings: settings && { ...settings, ...patch } }))
}
function Row({
agent = 'claude',
actionId = 'fixChecks'
}: {
agent?: TuiAgent
actionId?: AiActionId
}) {
const [draft, setDraft] = useState({ agentArgs: '--saved', commandInputTemplate: 'Do the work' })
const [preferences, setPreferences] = useState<SourceControlActionLaunchPreferences | undefined>({
agentId: 'claude',
model: 'sonnet',
options: { effort: 'high' },
permissionMode: 'full-access'
})
return (
<TooltipProvider>
<SourceControlActionRecipeRow
actionId={actionId}
selectedAgent={agent}
defaultTuiAgent="claude"
draftValue={draft}
baseValue={draft}
isSavingTemplate={false}
launchPreferences={preferences}
onLaunchPreferencesChange={(_id, value) => setPreferences(value)}
onAgentArgsChange={(_id, agentArgs) => setDraft({ ...draft, agentArgs })}
onAgentChange={unused}
onTemplateChange={unused}
onAppendVariable={unused}
onDiscard={unused}
onSave={unused}
/>
</TooltipProvider>
)
}
beforeEach(() => {
resetHostStructuredAgentsForTests()
useAppStore.setState({ localDetectedAgentIdsByContext: { host: ['claude', 'goose', 'grok'] } })
})
afterEach(resetHostStructuredAgentsForTests)
it.each([
['chat on', 'claude', true, undefined, false],
['chat off', 'claude', false, undefined, true],
['provider off', 'claude', true, { claude: false }, true],
['no chat support', 'goose', true, undefined, true]
] as const)('%s: shows Arguments only for the CLI', (_name, agent, chat, providers, visible) => {
configure({ experimentalNativeChat: chat, nativeChatProviders: providers })
render(<Row agent={agent} />)
expect(Boolean(screen.queryByRole('textbox', { name: 'Arguments' }))).toBe(visible)
expect(screen.getByText('Command template')).toBeDefined()
})
it('hides an unresolved launch even with chat off, but always shows text action Arguments', () => {
configure({ experimentalNativeChat: false })
useAppStore.setState({ localDetectedAgentIdsByContext: {} })
const view = render(<Row />)
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
view.rerender(<Row actionId="commitMessage" />)
expect(screen.getByDisplayValue('--saved')).toBeDefined()
})
it('keeps the draft and chip while Model/Effort controls stay hidden', () => {
configure({ experimentalNativeChat: false })
render(<Row />)
fireEvent.change(screen.getByRole('textbox', { name: 'Arguments' }), {
target: { value: '--draft' }
})
act(() => configure({ experimentalNativeChat: true }))
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
expect(screen.getByRole('button', { name: 'Claude permissions' }).textContent).toContain(
'Full access'
)
expect(screen.getByRole('button', { name: 'Claude permissions' }).getAttribute('data-size')).toBe(
'sm'
)
act(() => configure({ experimentalNativeChat: false }))
expect(screen.getByRole('textbox', { name: 'Arguments' })).toHaveProperty('value', '--draft')
expect(
screen.getByRole('button', { name: /Remove --dangerously-skip-permissions/ })
).toBeDefined()
expect(screen.queryByRole('combobox', { name: 'Sonnet' })).toBeNull()
expect(screen.queryByRole('combobox', { name: 'High' })).toBeNull()
expect(unused).not.toHaveBeenCalled()
})
it('reacts to the real host registry when another agent gains chat support', async () => {
render(<Row agent="grok" />)
expect(screen.getByRole('textbox', { name: 'Arguments' })).toBeDefined()
await act(() =>
loadHostStructuredAgents(
'local',
[STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY],
null
)
)
expect(screen.queryByRole('textbox', { name: 'Arguments' })).toBeNull()
})
it('honors an explicit future surface only after an agent resolves', () => {
const input = {
agent: 'claude',
settings: { experimentalNativeChat: true },
chatAgents: ['claude']
} as const
expect(shouldShowSourceControlActionArguments({ ...input, launchKind: 'terminal' })).toBe(true)
expect(
shouldShowSourceControlActionArguments({
...input,
settings: { experimentalNativeChat: false },
launchKind: 'chat'
})
).toBe(false)
expect(
shouldShowSourceControlActionArguments({ ...input, agent: null, launchKind: 'terminal' })
).toBe(false)
})
@@ -0,0 +1,57 @@
import {
findCatalogModel,
getAgentSessionOptionCatalog
} from '../../../../shared/agent-session-option-catalog'
import type { SourceControlActionLaunchPreferences } from '../../../../shared/source-control-ai-actions'
import type { TuiAgent } from '../../../../shared/tui-agent'
import {
sourceControlActionOptions,
sourceControlActionPermissionModeRoute
} from '../../../../shared/source-control-action-launch-preferences'
import { translate } from '@/i18n/i18n'
import { AGENT_CATALOG } from '@/lib/agent-catalog'
import { agentPermissionChoiceLabel } from './AgentPermissionChoiceControls'
import { sourceControlActionRouteHint } from './SourceControlActionOptionField'
/** Describes the saved choices and which launches they reach. */
export function describeSourceControlActionModel(
agent: TuiAgent,
preferences: SourceControlActionLaunchPreferences
): string {
const { model: modelId, permissionMode } = preferences
const catalog = getAgentSessionOptionCatalog(agent) ?? { models: [], modelApply: {} }
const labels = modelId
? [
findCatalogModel(catalog, modelId)?.label ?? modelId,
...sourceControlActionOptions(agent, modelId).flatMap(({ option }) => {
const value = preferences.options?.[option.id]
return value && option.kind.type === 'select'
? [option.kind.choices.find((choice) => choice.value === value)?.label ?? value]
: []
})
]
: []
if (!permissionMode) {
return translate(
'settings.sourceControl.actionModel.savedModel',
'Model from Settings: {{value0}}',
{ value0: labels.join(' · ') }
)
}
return translate(
'settings.sourceControl.actionModel.savedLaunchOptions',
'Saved options: {{value0}}. Older servers use their own permission settings.',
{
value0: [
...labels,
agentPermissionChoiceLabel(
permissionMode,
AGENT_CATALOG.find((entry) => entry.id === agent)?.label ?? agent
),
sourceControlActionRouteHint(sourceControlActionPermissionModeRoute(agent, permissionMode))
]
.filter(Boolean)
.join(' · ')
}
)
}
@@ -0,0 +1,200 @@
// @vitest-environment happy-dom
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as lifts from '../../../../shared/source-control-action-permission-lift'
import * as shell from '../../../../shared/tui-agent-startup-shell'
import { expect, it, vi } from 'vitest'
import './__fixtures__/settings-persistence-test-fixture'
import {
getDefaultSourceControlAiSettings,
normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import { sourceControlActionAgentArgs } from '../../../../shared/source-control-action-launch-preferences'
import { agentPermissionArgsForLaunch } from '../../../../shared/tui-agent-launch-defaults'
import { SourceControlActionArguments } from './SourceControlActionArguments'
import { useRepositorySourceControlAiGlobalUx } from './repository-source-control-ai-global-ux'
import { TooltipProvider } from '../ui/tooltip'
const model = { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } } as const
const initial = normalizeSourceControlAiSettings({
...getDefaultSourceControlAiSettings(),
enabled: true,
actions: { fixChecks: { agentId: 'claude', agentArgs: '', launchPreferences: model } }
})
const write = vi.fn()
function RepoHarness({ saved }: { saved: { value: RepoSourceControlAiOverrides } }) {
const [persisted, setPersisted] = useState(saved.value)
const draft = useRepositorySourceControlAiGlobalUx({
repoId: 'repo',
persistedRepoAi: persisted,
settings: null,
source: initial,
updateRepo: async (_id, update) => {
saved.value = update.sourceControlAi ?? {}
write(update)
setPersisted(saved.value)
return true
}
})
const value = draft.displayRepoAi.actionOverrides?.fixChecks
return (
<TooltipProvider>
<SourceControlActionArguments
agent="claude"
value={value?.agentArgs ?? ''}
preferences={value?.launchPreferences}
onArgsChange={(text, commit) => draft.updateActionAgentArgs('fixChecks', text, commit)}
onPreferencesChange={(preferences) =>
draft.updateActionLaunchPreferences('fixChecks', preferences)
}
/>
<button onClick={() => void draft.saveActionRecipeText('fixChecks')}>Save</button>
<button onClick={() => draft.discardActionRecipeText('fixChecks')}>Discard</button>
</TooltipProvider>
)
}
it('repo: commits typed permission, drafts own removal and saves/discards args and model together', async () => {
write.mockClear()
const repo: { value: RepoSourceControlAiOverrides } = {
value: { actionOverrides: { fixChecks: initial.actions?.fixChecks } }
}
const view = () => <RepoHarness saved={repo} />
const savedRecipe = () => repo.value.actionOverrides?.fixChecks
const rendered = render(view())
const input = screen.getByRole('textbox', { name: 'Arguments' })
const lift = vi.spyOn(lifts, 'liftActionPermissionArgs')
const tokenize = vi.spyOn(shell, 'tokenizeStartupCommand')
const work: number[][] = []
for (const n of [32, 320]) {
tokenize.mockClear()
for (let i = 0; i < n; i++) {
fireEvent.change(input, { target: { value: `--verbose ${String(i).padStart(3, '0')}` } })
}
work.push([
tokenize.mock.calls.length,
tokenize.mock.calls.reduce((sum, [text]) => sum + text.length, 0)
])
expect(lift).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
}
process.stdout.write(
`${JSON.stringify({ metric: 'permission-input-work', scope: 'repo', work })}\n`
)
expect(work[0][0]).toBeGreaterThan(32)
work[1].forEach((value, i) => expect(value).toBeLessThanOrEqual(10 * work[0][i]))
fireEvent.click(screen.getByText('Discard'))
fireEvent.click(screen.getByText('Save'))
expect(write).not.toHaveBeenCalled()
lift.mockClear()
fireEvent.change(input, { target: { value: '--dangerously-skip-permissions --verbose' } })
expect(screen.queryByRole('button', { name: /Remove --dangerously/ })).toBeNull()
fireEvent.keyDown(input, { key: 'Enter' })
expect(lift).toHaveBeenCalledTimes(1)
expect(input).toHaveProperty('value', '--verbose')
expect(screen.getByRole('button', { name: /Remove --dangerously/ })).toBeDefined()
expect(write).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Discard'))
expect(input).toHaveProperty('value', '')
expect(screen.queryByRole('button', { name: /Remove --dangerously/ })).toBeNull()
// Direct Save without blur must commit the dirty text exactly once.
fireEvent.change(input, { target: { value: '--dangerously-skip-permissions --verbose' } })
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(savedRecipe()?.launchPreferences?.permissionMode).toBe('full-access'))
expect(savedRecipe()?.launchPreferences).toEqual({ ...model, permissionMode: 'full-access' })
expect(savedRecipe()?.agentArgs).toBe('--verbose')
expect(write).toHaveBeenCalledTimes(1)
rendered.unmount()
const reopened = render(view())
const args = screen.getByRole('textbox', { name: 'Arguments' })
fireEvent.click(screen.getByRole('button', { name: /Remove --dangerously/ }))
expect(screen.queryByRole('button', { name: /Remove --dangerously/ })).toBeNull()
expect(write).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('Discard'))
fireEvent.focus(args)
if (!(args instanceof HTMLTextAreaElement)) {
throw new Error('Missing arguments textarea')
}
args.setSelectionRange(0, 0)
fireEvent.keyDown(args, { key: 'Backspace' })
expect(document.activeElement).toBe(screen.getByRole('button', { name: /Remove --dangerously/ }))
fireEvent.keyDown(document.activeElement ?? args, { key: 'Backspace' })
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(savedRecipe()?.launchPreferences?.permissionMode).toBe('own'))
expect(savedRecipe()?.launchPreferences).toEqual({ ...model, permissionMode: 'own' })
expect(savedRecipe()?.agentArgs).toBe('--verbose')
reopened.unmount()
render(view())
expect(screen.queryByRole('button', { name: /Remove --dangerously/ })).toBeNull()
expect(screen.getByRole('textbox', { name: 'Arguments' })).toHaveProperty('value', '--verbose')
})
it('disables the menu, input, reset and chip keyboard path while preserving geometry', async () => {
const args = vi.fn()
const preferences = vi.fn()
const props = {
agent: 'claude' as const,
value: '--verbose',
preferences: { ...model, permissionMode: 'full-access' as const },
onArgsChange: args,
onPreferencesChange: preferences
}
const rendered = render(
<TooltipProvider>
<SourceControlActionArguments {...props} disabled />
</TooltipProvider>
)
expect(screen.getByRole('textbox', { name: 'Arguments' })).toHaveProperty('disabled', true)
expect(screen.getByRole('button', { name: 'Claude permissions' })).toHaveProperty(
'disabled',
true
)
expect(screen.getByRole('button', { name: 'Reset' })).toHaveProperty('disabled', true)
const chip = screen.getByRole('button', { name: /Remove --dangerously/ })
fireEvent.keyDown(chip, { key: 'Backspace' })
expect(preferences).not.toHaveBeenCalled()
rendered.rerender(
<TooltipProvider>
<SourceControlActionArguments {...props} />
</TooltipProvider>
)
await userEvent.click(screen.getByRole('button', { name: 'Claude permissions' }))
expect(screen.getByRole('menuitemradio', { name: /Same as Claude ·/ })).toBeDefined()
await userEvent.click(screen.getByRole('menuitemradio', { name: /Use Claude's own settings/ }))
expect(preferences).toHaveBeenLastCalledWith({ ...model, permissionMode: 'own' })
})
it('keeps the old Codex workspace-write Ask literal and explains that terminal text wins', () => {
const text = ' -a on-request -s workspace-write -c approvals_reviewer=user --search '
const recipe = {
agentId: 'codex',
agentArgs: text,
launchPreferences: { agentId: 'codex', permissionMode: 'ask' }
} as const
expect(liftActionPermissionArgs(recipe)).toBe(recipe)
expect(liftActionPermissionArgs(recipe, true)).toBe(recipe)
expect(sourceControlActionAgentArgs(recipe, 'codex', null)).toBe(text)
expect(agentPermissionArgsForLaunch('codex', 'ask', text)).toBeNull()
render(
<TooltipProvider>
<SourceControlActionArguments
agent="codex"
value={text}
preferences={recipe.launchPreferences}
onArgsChange={vi.fn()}
onPreferencesChange={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getByRole('textbox', { name: 'Arguments' })).toHaveProperty('value', text)
expect(screen.queryByRole('button', { name: /Remove -a/ })).toBeNull()
expect(
screen.getByText(
'These arguments may control Codex’s terminal permissions, so Orca adds no flag. Chats use the Permissions choice above.'
)
).toBeDefined()
})
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { liftActionPermissionArgs } from '../../../../shared/source-control-action-permission-lift'
import { useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'
import type {
SourceControlAiSettings,
SourceControlAiSettingsPatch
} from '../../../../shared/source-control-ai-types'
import {
setSourceControlActionDefault,
type SourceControlActionLaunchPreferences,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
import type { ActionRecipeDraftState } from './source-control-ai-action-recipe-draft'
@@ -103,21 +105,59 @@ export function useSourceControlActionRecipeDraftState({
}))
}
const onActionAgentArgsChange = (actionId: SourceControlActionId, value: string): void => {
setActionRecipeDraftState((current) => ({
...current,
values: {
...current.values,
[actionId]: {
...current.values[actionId],
agentArgs: value
const onActionAgentArgsChange = (
actionId: SourceControlActionId,
value: string,
commit = false
): void => {
setActionRecipeDraftState((current) => {
const { agentId: _binding, ...valueDraft } = commit
? liftActionPermissionArgs(
{
...current.values[actionId],
agentId: config.actions?.[actionId]?.agentId,
agentArgs: value
},
true,
actionId
)
: {
...current.values[actionId],
agentId: config.actions?.[actionId]?.agentId,
agentArgs: value
}
return { ...current, values: { ...current.values, [actionId]: valueDraft } }
})
}
const onActionLaunchPreferencesChange = (
actionId: SourceControlActionId,
update: SetStateAction<SourceControlActionLaunchPreferences | undefined>
): void => {
setActionRecipeDraftState((current) => {
const latest = current.values[actionId].launchPreferences
const next = typeof update === 'function' ? update(latest) : update
// A completion with no owned change must not materialize an explicit clear.
if (typeof update === 'function' && next === latest) {
return current
}
return {
...current,
values: {
...current.values,
[actionId]: { ...current.values[actionId], launchPreferences: next }
}
}
}))
})
}
const saveActionTemplateDraft = async (actionId: SourceControlActionId): Promise<void> => {
const nextValue = actionRecipeDraftState.values[actionId]
const submitted = actionRecipeDraftState.values[actionId]
const { agentId: _binding, ...nextValue } = liftActionPermissionArgs(
{ ...actionRecipeDraftState.values[actionId], agentId: config.actions?.[actionId]?.agentId },
true,
actionId
)
if (
JSON.stringify(nextValue) === JSON.stringify(actionRecipeDraftState.baseValues[actionId]) ||
savingActionTemplateIds[actionId]
@@ -130,16 +170,17 @@ export function useSourceControlActionRecipeDraftState({
return {
actions: setSourceControlActionDefault(current.actions, actionId, {
commandInputTemplate: nextValue.commandInputTemplate,
agentArgs: nextValue.agentArgs
agentArgs: nextValue.agentArgs,
launchPreferences: nextValue.launchPreferences
})
}
})
setActionRecipeDraftState((current) => ({
values: current.values,
baseValues: {
...current.baseValues,
[actionId]: nextValue
}
values:
JSON.stringify(current.values[actionId]) === JSON.stringify(submitted)
? { ...current.values, [actionId]: nextValue }
: current.values,
baseValues: { ...current.baseValues, [actionId]: nextValue }
}))
} finally {
setSavingActionTemplateIds((current) => ({ ...current, [actionId]: false }))
@@ -178,6 +219,7 @@ export function useSourceControlActionRecipeDraftState({
savingActionTemplateIds,
onActionTemplateChange,
onActionAgentArgsChange,
onActionLaunchPreferencesChange,
saveActionTemplateDraft,
discardActionTemplateDraft,
appendVariable
@@ -2,12 +2,15 @@ import type { SourceControlAiSettings } from '../../../../shared/source-control-
import {
DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES,
SOURCE_CONTROL_ACTION_IDS,
type SourceControlActionLaunchPreferences,
type SourceControlActionRecipe,
type SourceControlActionId
} from '../../../../shared/source-control-ai-actions'
export type ActionRecipeDraftValue = {
commandInputTemplate: string
agentArgs: string
launchPreferences?: SourceControlActionLaunchPreferences
}
export type ActionRecipeDraftState = {
@@ -25,7 +28,8 @@ function readActionRecipeInputValue(
return {
commandInputTemplate:
typeof value === 'string' ? value : DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId],
agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs : ''
agentArgs: typeof recipe?.agentArgs === 'string' ? recipe.agentArgs : '',
...(recipe?.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {})
}
}
@@ -45,3 +49,12 @@ export function serializeActionRecipeInputValues(
): string {
return JSON.stringify(SOURCE_CONTROL_ACTION_IDS.map((actionId) => [actionId, values[actionId]]))
}
/** A successful agent change can retire only the obsolete value it captured. */
export function retireActionLaunchPreferences(
latest: SourceControlActionLaunchPreferences | undefined,
captured: SourceControlActionLaunchPreferences | undefined,
selected: SourceControlActionRecipe['agentId']
): SourceControlActionLaunchPreferences | undefined {
return captured && latest === captured && captured.agentId !== selected ? undefined : latest
}
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '../../store'
import { ChatNamingSetting } from './ChatNamingSetting'
@@ -10,7 +10,14 @@ import {
settingsModel,
type SettingsPersistenceModel
} from './__fixtures__/settings-persistence-test-fixture'
import { normalizeSourceControlAiSettings } from '../../../../shared/source-control-ai'
import * as selection from '../../../../shared/source-control-launch-agent-selection'
import * as lifts from '../../../../shared/source-control-action-permission-lift'
import * as shell from '../../../../shared/tui-agent-startup-shell'
import { TooltipProvider } from '../ui/tooltip'
import {
getDefaultSourceControlAiSettings,
normalizeSourceControlAiSettings
} from '../../../../shared/source-control-ai'
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
@@ -27,7 +34,7 @@ function GitPersistenceHarness({ model }: { model: SettingsPersistenceModel }) {
<CommitMessageAiPane
settings={settings}
updateSettings={model.updateSettings}
writeSourceControlAiSettings={controller.writeSourceControlAiSettings}
writeSourceControlAiSettings={controller.writeSourceControlAiSettingsOrThrow}
/>
<ChatNamingSetting
settings={settings}
@@ -103,3 +110,122 @@ it('does not let a failed actual Git agent selection roll back a queued Chat sel
)
expect(persist).toHaveBeenCalledTimes(2)
})
it('global owner: bounded controlled edits, failed/same selection and no-op save', async () => {
const settings = useAppStore.getState().settings
if (!settings) {
throw new Error('Missing settings')
}
useAppStore.setState({
localDetectedAgentIdsByContext: { host: ['claude', 'codex'] },
repos: Array.from({ length: 32 }, (_, i) => ({
id: `repo-${i}`,
path: `/repo-${i}`,
displayName: `Repo ${i}`,
badgeColor: '',
addedAt: 0
})),
settings: {
...settings,
experimentalNativeChat: false,
sourceControlAi: normalizeSourceControlAiSettings({
...getDefaultSourceControlAiSettings(),
enabled: true,
actions: {
fixChecks: {
agentId: 'claude',
agentArgs: '',
launchPreferences: { agentId: 'claude', model: 'sonnet', options: { effort: 'high' } }
}
}
})
}
})
const summary = vi.spyOn(selection, 'summarizeReposOverridingActionRecipe')
const lift = vi.spyOn(lifts, 'liftActionPermissionArgs')
const tokenize = vi.spyOn(shell, 'tokenizeStartupCommand')
render(
<TooltipProvider>
<GitPersistenceHarness model={settingsModel()} />
</TooltipProvider>
)
const heading = screen.getByText('Broken checks fixes')
const container = heading.closest('.rounded-md')
if (!(container instanceof HTMLElement)) {
throw new Error('Missing recipe row')
}
const row = within(container)
const input = row.getByRole('textbox', { name: 'Arguments' })
await waitFor(() => expect(input).toHaveProperty('disabled', false))
summary.mockClear()
lift.mockClear()
const inputWork: number[][] = []
for (const n of [32, 320]) {
tokenize.mockClear()
for (let i = 0; i < n; i++) {
fireEvent.change(input, { target: { value: `--verbose ${String(i).padStart(3, '0')}` } })
}
expect(persist).not.toHaveBeenCalled()
expect(summary).not.toHaveBeenCalled()
expect(lift).not.toHaveBeenCalled()
const work = [
tokenize.mock.calls.length,
tokenize.mock.calls.reduce((sum, [text]) => sum + text.length, 0)
]
expect(work[0]).toBeGreaterThan(n)
if (n === 320) {
work.forEach((value, i) => expect(value).toBeLessThanOrEqual(10 * inputWork[0][i]))
}
inputWork.push(work)
process.stdout.write(
`${JSON.stringify({ metric: 'permission-global-input-work', edits: n, work })}\n`
)
}
fireEvent.change(input, { target: { value: '--dangerously-skip-permissions --verbose' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(lift).toHaveBeenCalledTimes(1)
let reject = (_error: Error): void => {}
persist.mockImplementationOnce(
() =>
new Promise((_resolve, fail) => {
reject = fail
})
)
fireEvent.click(row.getAllByRole('combobox')[0])
fireEvent.click(screen.getByRole('option', { name: 'Codex' }))
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
// Actual global writer publishes only after success; failed selection must retain this later edit.
fireEvent.change(input, { target: { value: '--verbose newer' } })
await act(async () => reject(new Error('disk unavailable')))
await waitFor(() =>
expect(console.error).toHaveBeenCalledWith(
expect.stringMatching(/Failed to/),
expect.any(Error)
)
)
expect(input).toHaveProperty('value', '--verbose newer')
fireEvent.click(row.getAllByRole('combobox')[0])
fireEvent.click(screen.getByRole('option', { name: 'Claude' }))
expect(row.getByRole('button', { name: /Remove --dangerously/ })).toBeDefined()
persist.mockRejectedValueOnce(new Error('Save disk unavailable'))
fireEvent.click(row.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(console.error).toHaveBeenCalledWith(new Error('Save disk unavailable'))
)
fireEvent.click(row.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(persist).toHaveBeenCalledTimes(3))
expect(useAppStore.getState().settings?.sourceControlAi?.actions?.fixChecks).toMatchObject({
agentId: 'claude',
agentArgs: '--verbose newer',
launchPreferences: {
agentId: 'claude',
model: 'sonnet',
options: { effort: 'high' },
permissionMode: 'full-access'
}
})
await waitFor(() =>
expect(row.getByRole('button', { name: 'Save' })).toHaveProperty('disabled', true)
)
expect(summary).not.toHaveBeenCalled()
})
@@ -0,0 +1,41 @@
import { useEffect } from 'react'
import type { TuiAgent } from '../../../../shared/tui-agent'
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 { GlobalSettings } from '../../../../shared/global-settings-types'
import { readNativeDetectedAgents } from '@/store/slices/local-detected-agent-store-state'
import { useAppStore } from '@/store'
type ActionAgent = { status: 'pending' } | { status: 'ready'; agent: TuiAgent | null }
export function useSourceControlActionAgentInventory(enabled = true): void {
const ensure = useAppStore((state) => state.ensureNativeDetectedAgents)
useEffect(() => {
if (enabled) {
void ensure()
}
}, [enabled, ensure])
}
/** The agent an action launches; pending until detection says which agents can run. */
export function useSourceControlActionAgent(
selected: SourceControlActionRecipe['agentId'],
defaultAgent: GlobalSettings['defaultTuiAgent']
): ActionAgent & { resolve: (agent: SourceControlActionRecipe['agentId']) => ActionAgent } {
const disabledAgents = useAppStore((state) => state.settings?.disabledTuiAgents)
const detected = useAppStore(readNativeDetectedAgents)
const resolve = (selected: SourceControlActionRecipe['agentId']): ActionAgent =>
detected === null
? { status: 'pending' }
: {
status: 'ready',
agent: pickSourceControlLaunchAgent({
savedAgent: selected && !isCustomAgentId(selected) ? selected : null,
defaultAgent,
detectedAgents: detected,
disabledAgents
})
}
return { ...resolve(selected), resolve }
}
@@ -17,7 +17,7 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace
}))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.terminal }))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTabWithOutcome: mocks.terminal }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(async () => ({ supported: false, reason: 'wsl' }))
}))

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