mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
+10
-1
@@ -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"
|
||||
/>
|
||||
|
||||
+108
-1
@@ -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
|
||||
|
||||
+15
@@ -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'
|
||||
|
||||
+17
-11
@@ -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)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
+2
-1
@@ -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 () => [] }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+4
-1
@@ -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')
|
||||
}
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
|
||||
+129
-3
@@ -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 }
|
||||
}
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user