diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index b77b103f2c3..5a03d8192b9 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -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) ) } diff --git a/mobile/src/session/mobile-existing-agent-launch.test.ts b/mobile/src/session/mobile-existing-agent-launch.test.ts index a26c8d97f42..0b058046afe 100644 --- a/mobile/src/session/mobile-existing-agent-launch.test.ts +++ b/mobile/src/session/mobile-existing-agent-launch.test.ts @@ -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'] ) diff --git a/mobile/src/session/mobile-existing-agent-launch.ts b/mobile/src/session/mobile-existing-agent-launch.ts index fe4d102df3c..61e5dd45b23 100644 --- a/mobile/src/session/mobile-existing-agent-launch.ts +++ b/mobile/src/session/mobile-existing-agent-launch.ts @@ -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> + chatOptions?: Readonly> 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 diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index a49c8498e29..5869de97c06 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -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 diff --git a/mobile/src/tasks/agent-launch-request.test.ts b/mobile/src/tasks/agent-launch-request.test.ts index 33cc7f53082..3acc981b292 100644 --- a/mobile/src/tasks/agent-launch-request.test.ts +++ b/mobile/src/tasks/agent-launch-request.test.ts @@ -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([ { diff --git a/mobile/src/tasks/agent-launch-request.ts b/mobile/src/tasks/agent-launch-request.ts index 0b3ad2e25c5..0de11aa3ae7 100644 --- a/mobile/src/tasks/agent-launch-request.ts +++ b/mobile/src/tasks/agent-launch-request.ts @@ -61,12 +61,16 @@ export function agentLaunchExistingParams(args: { paneKey?: string sessionId?: string placement?: AgentLaunchPlacement + sessionOptions?: Readonly> + chatOptions?: Readonly> }): 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 } : {}), diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index c506e58ae44..a98cf0dba37 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -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' }) }) diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index c98f4176967..7daa0d01edd 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -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 { 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), diff --git a/src/main/agent-launch/agent-launch-intent-mode.ts b/src/main/agent-launch/agent-launch-intent-mode.ts new file mode 100644 index 00000000000..5102b9f1bae --- /dev/null +++ b/src/main/agent-launch/agent-launch-intent-mode.ts @@ -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[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 + }) +} diff --git a/src/main/agent-launch/agent-launch-opencode-model.test.ts b/src/main/agent-launch/agent-launch-opencode-model.test.ts index 38885d6c263..a6a7ae977e8 100644 --- a/src/main/agent-launch/agent-launch-opencode-model.test.ts +++ b/src/main/agent-launch/agent-launch-opencode-model.test.ts @@ -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() diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index f623bcafda3..0bafb03838f 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -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> options?: Readonly> /** 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) { + 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 } : {}), diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 62ad8138f8b..fcc3954cfcf 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -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, diff --git a/src/main/codex/codex-structured-real-cli-command-env.test.ts b/src/main/codex/codex-structured-real-cli-command-env.test.ts index 73e07255523..a7ca627b5fd 100644 --- a/src/main/codex/codex-structured-real-cli-command-env.test.ts +++ b/src/main/codex/codex-structured-real-cli-command-env.test.ts @@ -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' diff --git a/src/main/codex/codex-structured-real-cli-permissions.test.ts b/src/main/codex/codex-structured-real-cli-permissions.test.ts index 43516a3c03b..a8a15dec4e9 100644 --- a/src/main/codex/codex-structured-real-cli-permissions.test.ts +++ b/src/main/codex/codex-structured-real-cli-permissions.test.ts @@ -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' diff --git a/src/main/codex/codex-structured-real-cli-visuals.test.ts b/src/main/codex/codex-structured-real-cli-visuals.test.ts index 7bbc3f18390..e16f7d35701 100644 --- a/src/main/codex/codex-structured-real-cli-visuals.test.ts +++ b/src/main/codex/codex-structured-real-cli-visuals.test.ts @@ -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') diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 8bc6ae172c9..b17e19727c1 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -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, diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index 9b1610ddfa7..5c9f91ddce2 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -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( diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 6bb6d5abc68..542ba54cbab 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -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' diff --git a/src/main/codex/codex-structured-turn-permission.ts b/src/main/codex/codex-structured-turn-permission.ts index bd919e15d69..7332c1b609f 100644 --- a/src/main/codex/codex-structured-turn-permission.ts +++ b/src/main/codex/codex-structured-turn-permission.ts @@ -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 = { diff --git a/src/main/codex/codex-structured-visuals.test.ts b/src/main/codex/codex-structured-visuals.test.ts index d4d78e0bf25..7c745ce4525 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -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', diff --git a/src/main/codex/codex-structured-visuals.ts b/src/main/codex/codex-structured-visuals.ts index e66e39bfccf..5b123a75428 100644 --- a/src/main/codex/codex-structured-visuals.ts +++ b/src/main/codex/codex-structured-visuals.ts @@ -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 diff --git a/src/main/external-conversations/external-conversation-start.test.ts b/src/main/external-conversations/external-conversation-start.test.ts new file mode 100644 index 00000000000..e31f4d048c4 --- /dev/null +++ b/src/main/external-conversations/external-conversation-start.test.ts @@ -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) +}) diff --git a/src/main/external-conversations/external-conversation-start.ts b/src/main/external-conversations/external-conversation-start.ts index bc4c7b192c0..2780178a019 100644 --- a/src/main/external-conversations/external-conversation-start.ts +++ b/src/main/external-conversations/external-conversation-start.ts @@ -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' diff --git a/src/main/ipc/pty/ipc/spawn-agent-launch.ts b/src/main/ipc/pty/ipc/spawn-agent-launch.ts index f12a2195e5a..b77fc002f47 100644 --- a/src/main/ipc/pty/ipc/spawn-agent-launch.ts +++ b/src/main/ipc/pty/ipc/spawn-agent-launch.ts @@ -109,11 +109,13 @@ export async function planPaneAgentLaunch(ctx: PtyIpcSpawnState): Promise } } } - 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 } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts index 3be7b4aba81..c2843976bab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-input-host.test.ts @@ -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' diff --git a/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts b/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts index 3a4d138b486..a9636894b59 100644 --- a/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts +++ b/src/main/persistence/applying-settings/persistence-source-control-ai-migration.test.ts @@ -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, diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts index 23dfd78f3f0..03065fdd6ab 100644 --- a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts +++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts @@ -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 diff --git a/src/main/persistence/persistence-load-repair-durability.test.ts b/src/main/persistence/persistence-load-repair-durability.test.ts index 3638e0011a5..f29931bf256 100644 --- a/src/main/persistence/persistence-load-repair-durability.test.ts +++ b/src/main/persistence/persistence-load-repair-durability.test.ts @@ -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).rightSidebarExplorerView diff --git a/src/main/preflight/workspace-agent-detection.test.ts b/src/main/preflight/workspace-agent-detection.test.ts index 7fba9b8ea3d..1834dc64c27 100644 --- a/src/main/preflight/workspace-agent-detection.test.ts +++ b/src/main/preflight/workspace-agent-detection.test.ts @@ -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' }) diff --git a/src/main/preflight/workspace-agent-detection.ts b/src/main/preflight/workspace-agent-detection.ts index 38fac66ce10..e9c8355262c 100644 --- a/src/main/preflight/workspace-agent-detection.ts +++ b/src/main/preflight/workspace-agent-detection.ts @@ -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 { + 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' } + } +} diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 56bec0d8ddd..331306bf1b3 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -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({ diff --git a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts index ba4a841e798..aab9759e110 100644 --- a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts @@ -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 () => { diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 0fe3460914e..d595922cd77 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -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, + agent: string +): boolean { + return ( + context.clientKind === undefined || + agent === 'claude' || + agent === 'codex' || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} diff --git a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts index 5048068ac6d..73a391ed44d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts +++ b/src/main/runtime/rpc/methods/agent-launch-intent-resolution.ts @@ -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 { - 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 } } diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index 4e70833eef7..62eefadcbb6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -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> } sessionOptions?: Readonly> + chatOptions?: Readonly> 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, diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 486b9aab330..61e88b5da71 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -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()) { diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 9eda85c2d14..88317333bd2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch.ts index adf28591178..ca7e0340499 100644 --- a/src/main/runtime/rpc/methods/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch.ts @@ -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, - 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>, 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 { // 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 { - let intent: AgentLaunchIntent + let resolved: Awaited> 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) { diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index a18e0374f56..f5cd16dcd68 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -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) => { diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 9e395c6eb00..5d4b8ced7ff 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 74280deb013..2651a13a7fe 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -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( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts index afa22af7a1a..eaa828ee603 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -42,7 +42,11 @@ afterEach(async () => { vi.restoreAllMocks() }) -function params(options?: Readonly>, withMessage = false) { +function params( + options?: Readonly>, + withMessage = false, + optionsMode?: 'replace-model-selection' +) { return structuredAgentSessionCreateParams({ sessionId: SESSION, worktree: 'id:workspace-1', @@ -50,6 +54,7 @@ function params(options?: Readonly>, 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) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 5e4d4750895..5b2d617a2a2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -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> }): 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> /** 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> 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> | undefined -): Record { - const mode = options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] - return mode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : {} -} diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index 7ad7cd1cf99..d7c3d161f6d 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -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') diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 09a478bbb58..2ab59b3d5d7 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -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> + explicitOptions?: Readonly> /** Picks the launch holds and applies before it publishes. */ heldOptions: Readonly> /** 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] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx index 2376a1f33f9..74a476939bd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.first-frame.test.tsx @@ -98,6 +98,7 @@ function renderNewChat(args: { agent: AgentType target?: RuntimeClientTarget seedOptions?: Record + explicitOptions?: Record 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']) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 6452e68cb27..a7e244fed4f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -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> = {} - -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> = {} + +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 => { @@ -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 => { - 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, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts index 26118f02217..9d34e292fb3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts @@ -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 diff --git a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx index 0759c82442c..523daa91f49 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx +++ b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx @@ -64,15 +64,7 @@ export function ChecksFixDialog({ ) ) }} - onStart={({ agent, commandInput, agentArgs }) => - startFixChecksFromDialog({ - targetRepoId, - item, - agent, - commandInput, - agentArgs - }) - } + onStart={(launch) => startFixChecksFromDialog({ targetRepoId, item, ...launch })} /> ) } diff --git a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts index 3944540ef66..af5e55d7d5c 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts +++ b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts @@ -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> + sessionOptionsYieldToAgentArgs?: true }): Promise { 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( diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index 915129eaefb..e5ef8f532fb 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -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). diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 41a125b5776..8a3c141f431 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -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> + sessionOptionsYieldToAgentArgs?: true }) => boolean | Promise } @@ -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} diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx index 06ec7fecd67..976ce1b9833 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.test.tsx @@ -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' diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index edd6ab1e8fc..24d14ee86dd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -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({ {statusCopy}

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

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

) : null} diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx index ce837f1f2d6..72aabd5867e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentCliArgsField.tsx @@ -19,19 +19,12 @@ export function SourceControlAgentCliArgsField({ return (
onChange(event.target.value)} className="h-8 font-mono text-xs" /> diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts index 1c2cddb3b0d..06d01f32d0d 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.test.ts @@ -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[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 = { + 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) + } +) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 9a9dd995892..d1b7a3b6b85 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -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> + sessionOptionsYieldToAgentArgs?: true }) => boolean | Promise 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) diff --git a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts index 83055b58e77..6ead241e867 100644 --- a/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts +++ b/src/renderer/src/components/right-sidebar/source-control-action-recipe-match.ts @@ -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[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)) + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts index ff33aee447b..c2f255cc68b 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-result.ts @@ -1,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 diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts index d5cb86112fc..62610470f30 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts @@ -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 { + const state = useAppStore.getState() + return worktreeId + ? ensureDetectedAgentsForWorktree(state, worktreeId) + : typeof connectionId === 'string' + ? state.ensureRemoteDetectedAgents(connectionId) + : state.ensureDetectedAgents() +} diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 7eddcfd5942..ad457587065 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -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' diff --git a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts index 81f7b92af97..ed09e6a52f1 100644 --- a/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts +++ b/src/renderer/src/components/right-sidebar/useSavedSourceControlAgentActionAutoStart.ts @@ -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 + onAutoStart: (args: { detectedAgents: TuiAgent[] }) => Promise } 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(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() diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx new file mode 100644 index 00000000000..a1a5cc449f9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.test.tsx @@ -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 } => ({ + 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() +}) diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index dde8f28f3ee..00ab33e7e3c 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -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(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, diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 8fdef3042e0..6798808bc11 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -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> + sessionOptionsYieldToAgentArgs?: true }) => boolean | Promise 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, diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx index f5fb08aed20..d96c66bb090 100644 --- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx +++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.test.tsx @@ -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) => ( + + null, + onRemove: vi.fn() + } + : undefined + } + /> + + ) + 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', diff --git a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx index f402891bbc3..085d8d626ae 100644 --- a/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx +++ b/src/renderer/src/components/settings/AgentLaunchDefaultsEditor.tsx @@ -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(null) const inputRef = useRef(null) const chipRef = useRef(null) const chipButtonRef = useRef(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 ( -
- +
+ {translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')} -
+
{ @@ -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), diff --git a/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx b/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx index 103a2217019..63f19ef2dfa 100644 --- a/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx +++ b/src/renderer/src/components/settings/AgentPermissionChoiceControls.tsx @@ -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 ( -
- - {translate('settings.agents.rowPermissions', 'Permissions')} - +
+ {layout === 'stacked' ? ( + + ) : ( + + {translate('settings.agents.rowPermissions', 'Permissions')} + + )} - + + + + ) +} + +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 = () => + 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( + + + + ) + 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( + + + + ) + 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( + + + + ) + 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() +}) diff --git a/src/renderer/src/components/settings/source-control-action-recipe-draft-state.ts b/src/renderer/src/components/settings/source-control-action-recipe-draft-state.ts index 1b984e22619..74f0f6e5f7e 100644 --- a/src/renderer/src/components/settings/source-control-action-recipe-draft-state.ts +++ b/src/renderer/src/components/settings/source-control-action-recipe-draft-state.ts @@ -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 + ): 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 => { - 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 diff --git a/src/renderer/src/components/settings/source-control-ai-action-recipe-draft.ts b/src/renderer/src/components/settings/source-control-ai-action-recipe-draft.ts index 5bf306e4272..d91dab9e754 100644 --- a/src/renderer/src/components/settings/source-control-ai-action-recipe-draft.ts +++ b/src/renderer/src/components/settings/source-control-ai-action-recipe-draft.ts @@ -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 +} diff --git a/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx b/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx index 581b4e6fc03..c52acdab277 100644 --- a/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx +++ b/src/renderer/src/components/settings/source-control-ai-settings-persistence.test.tsx @@ -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 }) { { + 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( + + + + ) + 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() +}) diff --git a/src/renderer/src/components/settings/use-source-control-action-agent.ts b/src/renderer/src/components/settings/use-source-control-action-agent.ts new file mode 100644 index 00000000000..0fa2e534755 --- /dev/null +++ b/src/renderer/src/components/settings/use-source-control-action-agent.ts @@ -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 } +} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-surface.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-surface.test.ts index 47cc160cf48..d761b5f86d8 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit-surface.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit-surface.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => ({ ...(await importOriginal>()), 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' })) })) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index a962afd14ba..fab321906fa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -43,7 +43,10 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { session.terminalColorQueryReplies = session.terminalTheme ? { foreground: session.terminalTheme.foreground, background: session.terminalTheme.background } : undefined - session.agentLaunchPreferences = toAgentLaunchPreferences(session.paneStartup?.sessionOptions) + const launchAgent = session.paneStartup?.launchAgent + session.agentLaunchPreferences = launchAgent + ? toAgentLaunchPreferences(session.paneStartup?.sessionOptions, launchAgent) + : undefined session.transportOptions = buildPaneTransportOptions(session) if (session.connectionOwnerHydrating) { // Register unresolved-host panes for repo-change remount so hydration cannot leave an inert blank terminal. diff --git a/src/renderer/src/components/ui/input.tsx b/src/renderer/src/components/ui/input.tsx index 390fcabdc2f..de2d0f8b8b2 100644 --- a/src/renderer/src/components/ui/input.tsx +++ b/src/renderer/src/components/ui/input.tsx @@ -6,18 +6,22 @@ import { ImeInput } from '@/lib/ime-text-field' const inputVariants = cva('', { variants: { + variant: { + default: '', + code: 'font-mono text-xs md:text-xs' + }, density: { default: '', compact: 'h-7 border-border bg-input/50 px-2 text-xs shadow-none md:text-xs' } }, - defaultVariants: { density: 'default' } + defaultVariants: { variant: 'default', density: 'default' } }) const Input = React.forwardRef< HTMLInputElement, React.ComponentProps<'input'> & VariantProps ->(({ className, type, density, ...props }, ref) => { +>(({ className, type, variant, density, ...props }, ref) => { return ( ) { +const labelVariants = cva('', { + variants: { + variant: { + default: '', + settings: 'text-xs text-muted-foreground' + } + }, + defaultVariants: { variant: 'default' } +}) + +function Label({ + className, + variant, + ...props +}: React.ComponentProps & VariantProps) { return ( & { - size?: 'sm' | 'default' -}) { +}: React.ComponentProps & + VariantProps & { + size?: 'sm' | 'default' + }) { return ( { + const capability = PREFLIGHT_PROJECT_LAUNCH_CONTEXT_RUNTIME_CAPABILITY + let supported = false + try { + supported = + target.kind === 'local' + ? (await ensureLocalRuntimeCapabilities())?.includes(capability) === true + : await runtimeEnvironmentSupportsCapability(target.environmentId, capability) + } catch { + // An unknown capability keeps the existing launch path. + } + return supported + ? callRuntimeRpc(target, 'preflight.detectAgents', { + project: { repo: `id:${repoId}` } + }) + : undefined +} + /** IDs and the latest attempt outcome always describe the same target. */ export function readAgentInventoryForTarget(state: AppState, target: AgentDetectionTarget) { if (target.kind === 'local') { diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index d0b6ab2fbf1..4b91ba97de3 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -1,3 +1,4 @@ +import type { ProjectLaunchContext } from '../../../shared/rpc-contract/preflight-params' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, @@ -36,6 +37,7 @@ export type ProspectiveWorkspaceKind = NonNullable ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => ({ @@ -111,4 +114,33 @@ describe('a desktop AI-button launch', () => { settleAgentLaunchPaneStart(failed.tabId, failedLeaf, 'pty-2') await expect(failed.started).resolves.toBeNull() }) + + // The own pane's chain, as main plans it: string wire, the agent's strict preferences, the host. + it('keeps saved Cursor booleans to the terminal model, same-scope or yielding to own args', () => { + const argv = (sessionOptions: Record) => { + const { tabId } = launchAgentThroughHost({ + agent: 'cursor', + worktreeId: WT, + prompt: 'p', + sessionOptions + }) + const preferences = toAgentLaunchPreferences( + queuedLaunch(tabId)?.launch.sessionOptions, + 'cursor' + ) + return resolveAgentSessionOptionLaunch('cursor', { ...preferences }, [], false).args.join(' ') + } + const thinking = { model: 'claude-opus-4-8', thinking: 'true', effort: 'high' } + const yielded = terminalSessionOptionInputs({ + agent: 'cursor', + sessionOptions: thinking, + yieldToAgentArgs: true, + agentArgs: '--force', + shell: 'posix' + }).sessionOptions! + expect(argv(thinking)).toBe('--model claude-opus-4-8-thinking-high') + expect(argv(yielded)).toBe('--model claude-opus-4-8-thinking-high') + expect(argv({ ...thinking, thinking: false })).toBe('--model claude-opus-4-8-high') + expect(argv({ model: 'gpt-5.3-codex', fastMode: true })).toBe('--model gpt-5.3-codex-fast') + }) }) diff --git a/src/renderer/src/lib/agent-launch-through-host.ts b/src/renderer/src/lib/agent-launch-through-host.ts index 9d229f4439f..9f64383a22c 100644 --- a/src/renderer/src/lib/agent-launch-through-host.ts +++ b/src/renderer/src/lib/agent-launch-through-host.ts @@ -21,7 +21,7 @@ export type HostAgentLaunchArgs = { /** Absent uses the settings default; `null` means no arguments. */ agentArgs?: string | null cwd?: string - /** The launch's session options; only string values reach the host, which reads no others. */ + /** The launch's session options; the host decodes booleans sent as strings by catalog kind. */ sessionOptions?: Record launchSource?: LaunchSource quickCommandLabel?: string | null @@ -34,9 +34,8 @@ export type HostAgentLaunchArgs = { function stringSessionOptions(options: Record | undefined): { sessionOptions?: Record } { - const strings = Object.entries(options ?? {}).filter( - (entry): entry is [string, string] => typeof entry[1] === 'string' - ) + // Why: the launch wire carries strings, so a boolean (Cursor Thinking off) rides as 'false'. + const strings = Object.entries(options ?? {}).map(([id, value]) => [id, String(value)]) return strings.length > 0 ? { sessionOptions: Object.fromEntries(strings) } : {} } diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 2b3f58f24ec..13f15057c67 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -44,6 +44,8 @@ export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { onPromptDelivered?: () => void /** The caller keeps the prompt's text if it does not go out (notes), so no composer gets it. */ promptKeptByCaller?: true + /** Options this launch asks for over the saved chat defaults, e.g. an action's model. */ + launchOptions?: Readonly> } /** Carry the decided route and delivery fields in memory so post-create launches and same-session retries reuse them without re-resolving. */ @@ -59,6 +61,7 @@ export type AgentSessionLaunchVerdict = { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void promptKeptByCaller?: true + launchOptions?: Readonly> } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -95,7 +98,8 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), ...(verdict.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}), - ...(verdict.executionHostId ? { executionHostId: verdict.executionHostId } : {}) + ...(verdict.executionHostId ? { executionHostId: verdict.executionHostId } : {}), + ...(verdict.launchOptions ? { launchOptions: verdict.launchOptions } : {}) } } @@ -286,6 +290,7 @@ export function planAgentSessionLaunch( ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), - ...(request.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) + ...(request.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}), + ...(request.launchOptions ? { launchOptions: request.launchOptions } : {}) }) } diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts index 6bfa681d5c1..43505d1cdcb 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-outcome-tracking.ts @@ -1,4 +1,7 @@ import { useAppStore } from '@/store/index' +import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { agentSessionWriteFailureText } from '@/components/native-chat/agent-session-write-notice-text' +import { getAgentLabel } from '@/lib/agent-catalog' import { removeStructuredLaunchSlot } from './structured-agent-session-launch-slot' import { materializeStructuredLaunchSurface, @@ -65,7 +68,7 @@ export function trackLaunchSettlement( promise: Promise ): void { void promise.then( - () => { + (receipt) => { if (state.promise !== promise) { return } @@ -77,6 +80,11 @@ export function trackLaunchSettlement( const { worktreeId, sessionId, executionHostId } = state.intent recordUnlistedCreatedChat(worktreeId, sessionId, executionHostId) } + if (receipt.configurationFailure) { + publishStructuredAgentSessionSends(receipt.sessionId, { + notice: `${getAgentLabel(state.intent.agent)}: ${agentSessionWriteFailureText(receipt.configurationFailure, 'option')}` + }) + } settleStructuredLaunchCallers(state.callers, 'published') notifyStructuredLaunchListeners() }, diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-publication.ts index 3700d07c2e6..26ebd2942e4 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-publication.ts @@ -35,6 +35,9 @@ export function markStructuredAgentSessionLaunchPublished( if (persisted?.executionHostId !== executionHostId) { return false } + if (persisted.explicitLaunchOptions && persisted.createMessageSupport !== true) { + return false + } if (persisted.firstMessage) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false @@ -58,6 +61,9 @@ export function markStructuredAgentSessionLaunchPublished( if (state.callers.outcome === 'pending') { return true } + if (state.intent.explicitLaunchOptions && state.intent.createMessageSupport !== true) { + return false + } if (state.intent.params.firstMessage) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-record.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-record.ts index ccb7df32673..5d1402c0554 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-record.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-record.ts @@ -54,6 +54,8 @@ export type StructuredAgentLaunchPersistedRecord = { expectedRuntimeFence: number | null resumeFrom?: StructuredAgentSessionResumeSource firstMessage?: StructuredAgentSessionFirstMessage + explicitLaunchOptions?: Readonly> + optionsMode?: 'replace-model-selection' options?: Readonly> createMessageSupport?: boolean /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ @@ -66,7 +68,7 @@ export function structuredAgentLaunchRecordFor( intent: StructuredAgentSessionLaunchIntent, lifecycle: StructuredAgentLaunchPersistedLifecycle ): StructuredAgentLaunchPersistedRecord { - const { envelope, resumeFrom, firstMessage, options } = intent.params + const { envelope, resumeFrom, firstMessage, optionsMode, options } = intent.params // A local launch re-reads this machine's settings on reload; only a paired server's seed is kept. const pairedSeed = intent.target.kind === 'local' ? undefined : intent.seedOptions return { @@ -80,7 +82,11 @@ export function structuredAgentLaunchRecordFor( expectedRuntimeFence: envelope.expectedRuntimeFence, ...(resumeFrom ? { resumeFrom } : {}), ...(firstMessage ? { firstMessage } : {}), + ...(optionsMode ? { optionsMode } : {}), ...(options ? { options } : {}), + ...(intent.explicitLaunchOptions + ? { explicitLaunchOptions: intent.explicitLaunchOptions } + : {}), ...(intent.createMessageSupport === undefined ? {} : { createMessageSupport: intent.createMessageSupport }), @@ -119,6 +125,7 @@ export function validRecord(value: unknown): value is Omit< const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined const failedAt = 'failedAt' in value ? value.failedAt : undefined const firstMessage = 'firstMessage' in value ? value.firstMessage : undefined + const optionsMode = 'optionsMode' in value ? value.optionsMode : undefined const options = 'options' in value ? value.options : undefined const createMessageSupport = 'createMessageSupport' in value ? value.createMessageSupport : undefined @@ -134,7 +141,11 @@ export function validRecord(value: unknown): value is Omit< (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && (failedAt === undefined || Number.isFinite(failedAt)) && (firstMessage === undefined || validFirstMessage(firstMessage)) && + (optionsMode === undefined || optionsMode === 'replace-model-selection') && (options === undefined || isAgentSessionOptions(options)) && + (!('explicitLaunchOptions' in value) || + value.explicitLaunchOptions === undefined || + isAgentSessionOptions(value.explicitLaunchOptions)) && (createMessageSupport === undefined || typeof createMessageSupport === 'boolean') && (resumeFrom === undefined || (typeof resumeFrom === 'object' && diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts index 8e5493a807f..4d01f2e9224 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-recovery.ts @@ -1,3 +1,4 @@ +import type { AgentSessionWriteFailure } from '../../../../../shared/agent-session-write-failure' import type { AgentSessionHistoryResult } from '../../../../../shared/agent-session-wire' import type { AgentJournalCursor, @@ -18,6 +19,7 @@ export type StructuredAgentLaunchReceipt = { fence: number firstMessage?: AgentJournalSubmission cursor?: AgentJournalCursor + configurationFailure?: AgentSessionWriteFailure } export type StructuredLaunchRecoveryState = { diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.test.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.test.ts index 9b60845c107..579635dc26d 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.test.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.test.ts @@ -77,7 +77,10 @@ vi.mock('@/lib/agent-catalog', () => ({ import { retryStructuredAgentSessionLaunch } from '../../structured-agent-session-launch' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' -import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { + resetStructuredAgentLaunchRegistryForTests, + getStructuredLaunchStateBySessionId +} from './structured-agent-session-launch-registry' function launchIntent(worktreeId: string, sessionId: string): StructuredAgentSessionLaunchIntent { return { @@ -141,12 +144,18 @@ describe('structured agent launch reload recovery', () => { clientOperationId: string payloadFingerprint: string expectedRuntimeFence: number | null + explicitLaunchOptions?: Readonly> + optionsMode?: 'replace-model-selection' + options?: Readonly> }) => ({ ...launchIntent(args.worktreeId, args.sessionId), agent: args.agent, + explicitLaunchOptions: args.explicitLaunchOptions, params: { ...launchIntent(args.worktreeId, args.sessionId).params, agent: args.agent, + optionsMode: args.optionsMode, + options: args.options, envelope: { sessionId: args.sessionId, clientOperationId: args.clientOperationId, @@ -168,6 +177,9 @@ describe('structured agent launch reload recovery', () => { sessionId, agent: 'codex', lifecycle: 'pending', + explicitLaunchOptions: { model: 'action-model', fastMode: 'false' }, + optionsMode: 'replace-model-selection', + options: { model: 'frozen-model', effort: 'high' }, clientOperationId: 'operation-reloaded', payloadFingerprint: 'fingerprint-reloaded', expectedRuntimeFence: null @@ -180,13 +192,20 @@ describe('structured agent launch reload recovery', () => { .mockResolvedValueOnce([publishedSnapshot(worktreeId, sessionId)]) expect(retryStructuredAgentSessionLaunch(worktreeId, sessionId)).toBe(true) + expect(getStructuredLaunchStateBySessionId(sessionId)?.selection).toMatchObject({ + explicit: { model: 'action-model', fastMode: 'false' }, + held: {} + }) await flushLaunchSettlement() expect(mocks.launch).toHaveBeenCalledWith( expect.objectContaining({ sessionId, worktreeId, + explicitLaunchOptions: { model: 'action-model', fastMode: 'false' }, params: expect.objectContaining({ + optionsMode: 'replace-model-selection', + options: { model: 'frozen-model', effort: 'high' }, envelope: expect.objectContaining({ clientOperationId: 'operation-reloaded', payloadFingerprint: 'fingerprint-reloaded' diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts index 464b299568b..7c3a1a1a455 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-reload.ts @@ -36,7 +36,11 @@ export function restorePersistedStructuredLaunchState( ...(record.resumeFrom ? { resumeFrom: record.resumeFrom } : {}), ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}), ...(record.firstMessage ? { firstMessage: record.firstMessage } : {}), + ...(record.optionsMode ? { optionsMode: record.optionsMode } : {}), ...(record.options ? { options: record.options } : {}), + ...(record.explicitLaunchOptions + ? { explicitLaunchOptions: record.explicitLaunchOptions } + : {}), ...(record.createMessageSupport === undefined ? {} : { createMessageSupport: record.createMessageSupport }) diff --git a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts index af05de1a987..1309cacafbb 100644 --- a/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts +++ b/src/renderer/src/lib/agent-session/launch/structured-agent-session-launch-unconfirmed-or-retried.test.ts @@ -232,7 +232,7 @@ describe('a new start beside an unconfirmed or retried chat', () => { trimmedCommandInput: 'Fix the failing check', agentArgs: '', agentArgsApply: false, - commandTemplate: '{basePrompt}', + recipe: { commandInputTemplate: '{basePrompt}' }, saveTargetValue: 'none', actionId: 'resolveComments', settings: null, diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 17049f43143..dc0a2909e74 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -300,7 +300,7 @@ describe('startFixChecksAgent', () => { expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() }) - it('rejects invalid saved CLI arguments before activating the attached workspace', async () => { + it('leaves hidden CLI validation to the selected launch route', async () => { mocks.resolveSourceControlActionRecipe.mockReturnValue({ commandInputTemplate: '{basePrompt}', agentArgs: '--model "unterminated' @@ -314,13 +314,12 @@ describe('startFixChecksAgent', () => { basePrompt: 'Fix checks', launchSource: 'task_page' }) - ).resolves.toBe(false) + ).resolves.toBe(true) - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() - expect(mocks.toastError).toHaveBeenCalledWith( - 'CLI arguments are invalid: Unclosed quote in command template.' + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ agentArgs: '--model "unterminated' }) ) + expect(mocks.toastError).not.toHaveBeenCalled() }) it('rejects without focusing a terminal when agent launch throws', async () => { diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index 9062952ea3d..f87b9b79400 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -13,7 +13,6 @@ import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspac import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' -import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { pickSourceControlLaunchAgent, readSourceControlLaunchRecipeAgentId @@ -34,6 +33,11 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../../shared/workspace-source' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' +import { + resolveSourceControlActionSessionOptions, + sourceControlActionLaunchOptions, + sourceControlActionAgentArgs +} from '../../../shared/source-control-action-launch-preferences' type StartFixChecksAgentArgs = { repoId: string @@ -211,14 +215,6 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis ) return false } - const agentArgsPlan = planAgentCliArgsSuffix( - recipe().agentArgs, - launchPlatform === 'win32' ? 'powershell' : 'posix' - ) - if (!agentArgsPlan.ok) { - toast.error(agentArgsPlan.error) - return false - } let revealFailed = false const result = launchAgentInNewTab({ requestId: newAgentLaunchRequestId(), @@ -226,7 +222,8 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis worktreeId: targetWorktreeId, groupId: args.groupId ?? targetWorktreeId, prompt, - agentArgs: recipe().agentArgs, + agentArgs: sourceControlActionAgentArgs(recipe(), agent, useAppStore.getState().settings), + ...sourceControlActionLaunchOptions(recipe(), agent), promptDelivery: 'submit-after-ready', launchPlatform, launchSource: args.launchSource, @@ -282,6 +279,10 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis telemetrySource: args.telemetrySource, promptDelivery: 'submit-after-ready', agentArgs: recipe().agentArgs, + resolveAgentArgs: (agent) => + sourceControlActionAgentArgs(recipe(), agent, useAppStore.getState().settings), + resolveSessionOptions: (agent) => resolveSourceControlActionSessionOptions(recipe(), agent), + sessionOptionsYieldToAgentArgs: recipe().launchPreferencesYieldToAgentArgs, ...(agentOverride.kind === 'agent' ? { agentOverride: agentOverride.agent } : {}), openModalFallback: args.openModalFallback }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-args.ts b/src/renderer/src/lib/launch-agent-in-new-tab-args.ts new file mode 100644 index 00000000000..ed416fd1363 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-args.ts @@ -0,0 +1,54 @@ +import type { AgentSessionLaunchRequest } from '@/lib/agent-session-launch-plan' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { LaunchSource } from '../../../shared/telemetry-events' +import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' +import type { LaunchAgentInNewTabRequest } from '@/lib/launch-agent-in-new-tab-host-agents' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' + +export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { + requestedSurface?: AgentSessionLaunchRequest['requestedSurface'] + selectedExecutionHostId?: AgentSessionLaunchRequest['selectedExecutionHostId'] + agent: TuiAgent + worktreeId: string + /** Captured at a new-chat action before the host can delay route selection. */ + focusIntent?: NativeChatComposerFocusIntent + /** Tab group the user launched from; keeps split-group launches in that pane instead of the active group. */ + groupId?: string + /** Optional initial prompt; delivery depends on `promptDelivery` and the agent's prompt mode. */ + prompt?: string + /** Optional CLI arguments appended to the selected agent command; a chat has no command line. */ + agentArgs?: string | null + /** The launch's saved choice; a chat seeds it and a terminal applies it as flags. */ + sessionOptions?: Readonly> + /** On terminals, a flag `agentArgs` sets itself wins over the inherited saved choice. */ + sessionOptionsYieldToAgentArgs?: true + initialCwd?: string | null + /** How to deliver the prompt: `draft` leaves it editable, `submit-after-ready` sends it once the TUI is ready. */ + promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready' + /** Telemetry surface that initiated this launch. Defaults to the tab-bar quick-launch entry point. */ + launchSource?: LaunchSource + /** User-authored Quick Command label for local tabs created from the tab bar. */ + quickCommandLabel?: string | null + /** Shell platform for the startup command; defaults to renderer OS. SSH/WSL worktrees run Linux even from Windows. */ + launchPlatform?: NodeJS.Platform + /** Called after the prompt is actually delivered to the agent input path. */ + onPromptDelivered?: () => void + /** The caller keeps the prompt's text if it does not go out (notes), so no composer gets it. */ + promptKeptByCaller?: true + /** + * Called before `onPromptDelivered` when the paste was written without ever observing the + * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the + * terminal route, whose readiness signal the client watches itself. + */ + onPromptDeliveryUnconfirmed?: () => void + /** Keep terminal launches in a floating workspace from taking global selection. */ + activate?: boolean + /** The launch seeds a workspace being opened, so its PTY spawn must not reshuffle Recent. */ + pendingActivationSpawn?: boolean + /** Lets a workspace reveal itself before the selected surface opens. */ + beforeSurfaceOpen?: ( + surface: { kind: 'local-terminal' } | { kind: 'host-published' } + ) => boolean | void + /** Opens instead of this agent's terminal when the host declines its structured chat. */ + onStructuredHostDeclined?: () => StructuredLaunchTerminal +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts index 72863ceb8e9..ed0e3a84163 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.test.ts @@ -109,7 +109,7 @@ describe('a new tab whose chat route waits on the host agent list', () => { const relaunch = fakeRelaunch() const focusIntent = { request: vi.fn(), cancel: vi.fn() } - const result = launchOnceHostAnswered(route, { ...args, focusIntent }, startupPlan, relaunch) + const result = launchOnceHostAnswered(route, { ...args, focusIntent }, relaunch) expect(result?.surface).toEqual({ kind: 'host-published' }) expect(relaunch).not.toHaveBeenCalled() @@ -162,11 +162,10 @@ describe('a new tab whose chat route waits on the host agent list', () => { } const relaunch = vi.fn((_relaunched: LaunchAgentInNewTabArgs) => ({ surface: { kind: 'local-terminal' as const, tabId: 'tab-1' }, - startupPlan, pasteDraftAfterLaunch: false })) - const result = launchOnceHostAnswered(route, args, startupPlan, relaunch) + const result = launchOnceHostAnswered(route, args, relaunch) await vi.advanceTimersByTimeAsync(HOST_ANSWER_WAIT_MS - 1) expect(relaunch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) @@ -231,7 +230,7 @@ describe("a new tab during startup, before this computer's runtime answered", () const route = awaitingRoute(agent) const relaunch = fakeRelaunch() - const result = launchOnceHostAnswered(route, { ...args, agent }, startupPlan, relaunch) + const result = launchOnceHostAnswered(route, { ...args, agent }, relaunch) // The failed probe has answered; the launch is still waiting for one that lands. await vi.advanceTimersByTimeAsync(20) expect(relaunch).not.toHaveBeenCalled() @@ -248,20 +247,21 @@ describe("a new tab during startup, before this computer's runtime answered", () } ) - it('opens the terminal chat after the cap when they never arrive, and says why', async () => { + it('keeps an awaited terminal refusal notified without claiming a surface opened', async () => { vi.useFakeTimers() const route = awaitingRoute('claude') - const relaunch = fakeRelaunch() + const refused = { delivered: false, failureNotified: true } + const relaunch = vi.fn((_next: LaunchAgentInNewTabArgs) => ({ refused })) const result = launchOnceHostAnswered( route, - { ...args, agent: 'claude' }, - startupPlan, + { ...args, agent: 'claude', prompt: 'keep this prompt' }, relaunch ) await vi.advanceTimersByTimeAsync(HOST_ANSWER_WAIT_MS - 1) expect(relaunch).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) + expect((await result?.promptDeliveryResult)?.failureNotified).toBe(true) await expect(result?.structuredSettlement).resolves.toEqual({ kind: 'cancelled', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts index 48b59869609..7f09a39a9fb 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-host-agents.ts @@ -1,5 +1,6 @@ -import { useAppStore } from '@/store' +import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { useAppStore } from '@/store' import { awaitStructuredRouteHostAnswer, planAgentSessionLaunch, @@ -8,10 +9,44 @@ import { } from '@/lib/agent-session-launch-plan' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' -import type { - LaunchAgentInNewTabArgs, - LaunchAgentInNewTabResult -} from '@/lib/launch-agent-in-new-tab' +import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' +import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' + +/** The user action this launch serves: minted where that action is handled, or carried by the + * route the caller already planned for it. */ +export type LaunchAgentInNewTabRequest = + | { requestId: AgentLaunchRequestId; agentSessionLaunchPlan?: undefined } + | { + /** Keeps a preflighted route authoritative across workspace creation. */ + agentSessionLaunchPlan: AgentSessionLaunchPlan + requestId?: undefined + } + +/** `host-published`: the surface opens once its host answers; `local-chat` opened before it did. */ +export type AgentLaunchSurface = + | { kind: 'local-terminal'; tabId: string } + | { kind: 'local-chat'; tabId: string } + | { kind: 'host-published' } + +export type LaunchAgentInNewTabResult = { + surface: AgentLaunchSurface + startupPlan?: AgentStartupPlan + pasteDraftAfterLaunch: boolean + promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> + /** Structured route only: what the launch did once it settled. The call stays synchronous. */ + terminalCompletion?: StructuredLaunchTerminal['completion'] + structuredSettlement?: Promise +} | null + +export type AgentTabLaunchOutcome = + | LaunchAgentInNewTabResult + | { refused: StructuredPromptDeliveryResult } + +export function shouldQueueTerminalFocusAfterMenuClose( + result: NonNullable +): boolean { + return result.surface.kind === 'host-published' +} /** Bound host-fact waiting to the same deadline as local chat admission. */ export const HOST_ANSWER_WAIT_MS = 3_000 @@ -46,8 +81,7 @@ export function routeNewTabLaunch( export function launchOnceHostAnswered( route: AwaitingHostAnswer, args: LaunchAgentInNewTabArgs, - startupPlan: AgentStartupPlan, - relaunch: (args: LaunchAgentInNewTabArgs) => LaunchAgentInNewTabResult + relaunch: (args: LaunchAgentInNewTabArgs) => AgentTabLaunchOutcome ): LaunchAgentInNewTabResult { if (args.beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { args.focusIntent?.cancel() @@ -63,7 +97,7 @@ export function launchOnceHostAnswered( ) const structuredSettlement = opened.then( (result): Promise | StructuredAgentLaunchSettlement => - !result + !result || 'refused' in result ? { kind: 'cancelled', sessionId: null } : (result.structuredSettlement ?? { kind: 'terminal' }), (error: unknown): StructuredAgentLaunchSettlement => { @@ -75,13 +109,17 @@ export function launchOnceHostAnswered( Boolean(args.prompt?.trim()) && (args.promptDelivery ?? 'auto-submit') !== 'draft' return { surface: { kind: 'host-published' }, - startupPlan, pasteDraftAfterLaunch: false, structuredSettlement, ...(deliversPrompt ? { promptDeliveryResult: opened.then( - (result) => (result ? (result.promptDeliveryResult ?? DELIVERED) : NOT_DELIVERED), + (result) => + result && 'refused' in result + ? result.refused + : result + ? (result.promptDeliveryResult ?? DELIVERED) + : NOT_DELIVERED, () => NOT_DELIVERED ) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts index 99c109fa997..529fb492d05 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-placement.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' +import { resolveSourceControlActionRecipe } from '../../../shared/source-control-ai' +import type { SourceControlActionRecipe } from '../../../shared/source-control-ai-actions' +import { sourceControlActionLaunchOptions } from '../../../shared/source-control-action-launch-preferences' const mockCreateTab = vi.fn() function queuedStartups() { @@ -81,6 +85,13 @@ vi.mock('@/components/native-chat/native-chat-session-option-cache', () => ({ seedNativeChatAppliedSessionOptions: mockSeedNativeChatAppliedSessionOptions })) +vi.mock('@/lib/launch-agent-in-new-tab-structured-route', () => ({ + launchStructuredAgentFromNewTab: () => ({ + surface: { kind: 'host-published' }, + pasteDraftAfterLaunch: false + }) +})) + describe('launchAgentInNewTab terminal tab activation', () => { beforeEach(() => { vi.clearAllMocks() @@ -89,6 +100,24 @@ describe('launchAgentInNewTab terminal tab activation', () => { mockCreateTab.mockReturnValue({ id: 'tab-1' }) }) + it('ignores hidden malformed arguments for chat and rejects them before any terminal creation', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const args = { agent: 'codex' as const, worktreeId: 'wt-1', agentArgs: '--model "unfinished' } + const result = launchAgentInNewTab({ + ...args, + agentSessionLaunchPlan: adoptAgentSessionLaunchVerdict({ + route: 'structured-native-chat', + requestId: 'chat', + agent: 'codex', + worktreeId: 'wt-1' + }) + }) + expect(result?.surface.kind).toBe('host-published') + expect(result?.startupPlan).toBeUndefined() + expect(launchAgentInNewTab({ ...args, requestId: 'terminal' })).toBeNull() + expect(mockCreateTab).not.toHaveBeenCalled() + }) + // Store activation is scoped to the launch workspace, so floating launches cannot move main-window selection. it.each(['wt-1', FLOATING_TERMINAL_WORKTREE_ID])( 'selects the new tab within %s only', @@ -137,4 +166,62 @@ describe('launchAgentInNewTab terminal tab activation', () => { // Chat UI model preferences belong to structured chats, not a terminal fallback. expect(queuedStartups()[0]).not.toHaveProperty('sessionOptions') }) + + it('applies a saved action choice as terminal flags by scope', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + const base = getDefaultSettings('/tmp') + const settings = { + ...base, + sourceControlAi: { + ...base.sourceControlAi!, + actions: { + fixChecks: { + agentId: 'codex' as const, + agentArgs: '--model old --dangerously-bypass-approvals-and-sandbox', + launchPreferences: { + agentId: 'codex' as const, + model: 'gpt-5.6-terra', + options: { effort: 'high' }, + permissionMode: 'ask' as const + } + } + } + } + } + const launch = (fixChecks?: SourceControlActionRecipe) => { + const recipe = resolveSourceControlActionRecipe({ + settings, + repo: fixChecks ? { sourceControlAi: { actionOverrides: { fixChecks } } } : null, + actionId: 'fixChecks' + }) + const options = sourceControlActionLaunchOptions(recipe, 'codex') + launchAgentInNewTab({ + requestId: 'request-3', + agent: 'codex', + worktreeId: 'wt-1', + agentArgs: recipe.agentArgs, + ...options + }) + return { command: queuedStartups().at(-1)?.command, chat: options.sessionOptions } + } + + // Loose permission text owns terminal policy; the explicit model still replaces its raw flag. + const global = launch() + expect(global.command).toContain("'-m' 'gpt-5.6-terra' '-c' 'model_reasoning_effort=high'") + expect(global.command).not.toContain('old') + expect(global.command?.match(/dangerously-bypass/g)).toHaveLength(1) + // A repository's choice shares the same raw-permission rule. + const repoChoice = launch({ + agentId: 'codex', + launchPreferences: { agentId: 'codex', model: 'gpt-5.5', permissionMode: 'full-access' } + }) + expect(repoChoice.command).toContain("'-m' 'gpt-5.5'") + expect(repoChoice.command?.match(/dangerously-bypass/g)).toHaveLength(1) + expect(repoChoice.command).not.toContain('old') + // N1: a legacy override's own raw model beats the inherited choice on terminals only. + const legacy = launch({ agentArgs: '--model mine' }) + expect(legacy.command).toContain("'--model' 'mine'") + expect(legacy.command).not.toMatch(/gpt-5\.6-terra|model_reasoning_effort/) + expect(legacy.chat).toMatchObject({ model: 'gpt-5.6-terra', permissionMode: 'ask' }) + }) }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index 28880db1b3b..a3c02d2fedb 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -2,7 +2,11 @@ import { adoptAgentSessionLaunchVerdict, type AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' -import type { AgentLaunchSurface, LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' +import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' +import type { + AgentLaunchSurface, + AgentTabLaunchOutcome +} from '@/lib/launch-agent-in-new-tab-host-agents' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' @@ -24,14 +28,7 @@ export function launchStructuredAgentFromNewTab(args: { focusIntent?: NativeChatComposerFocusIntent beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] onHostDeclined?: () => StructuredLaunchTerminal - openTerminal: ( - terminalPlan: AgentSessionLaunchPlan, - activate?: false - ) => { - surface: AgentLaunchSurface - terminalCompletion?: StructuredLaunchTerminal['completion'] - promptDeliveryResult?: Promise - } | null + openTerminal: (terminalPlan: AgentSessionLaunchPlan, activate?: false) => AgentTabLaunchOutcome }): StructuredFromNewTab | null { const { plan, beforeSurfaceOpen } = args if (beforeSurfaceOpen?.({ kind: 'host-published' }) === false) { @@ -57,6 +54,9 @@ export function launchStructuredAgentFromNewTab(args: { }), activate ) + if (terminal && 'refused' in terminal) { + return { opened: false, promptDeliveryResult: Promise.resolve(terminal.refused) } + } return { opened: terminal !== null, ...(terminal?.surface.kind === 'local-terminal' ? { tabId: terminal.surface.tabId } : {}), diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index a3dd7f36247..534ac08b74c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -113,6 +113,11 @@ describe('launchAgentInNewTab paired web runtime', () => { pasteDraftAfterLaunch: false }) ) + const startupPlan = result?.startupPlan + expect(startupPlan).toBeDefined() + if (!startupPlan) { + throw new Error('Expected the terminal startup plan') + } expect(mocks.createWebRuntimeSessionTerminal).toHaveBeenCalledWith({ worktreeId: 'wt-1', environmentId: 'web-runtime', @@ -121,12 +126,13 @@ describe('launchAgentInNewTab paired web runtime', () => { ...(activate ? {} : { selectWorktree: false }), agentSessionKind: 'fresh', launchAgent: 'claude', - command: result?.startupPlan.launchCommand, - env: result?.startupPlan.env, - launchConfig: result?.startupPlan.launchConfig, + preparedAgentCommand: false, + command: startupPlan.launchCommand, + env: startupPlan.env, + launchConfig: startupPlan.launchConfig, viewMode: 'terminal' }) - expect(result?.startupPlan.launchConfig.agentArgs).toBe(store.settings.agentDefaultArgs.claude) + expect(startupPlan.launchConfig.agentArgs).toBe(store.settings.agentDefaultArgs.claude) expect(mocks.createTab).not.toHaveBeenCalled() await expect(result?.terminalCompletion).resolves.toMatchObject({ outcome: { status }, @@ -167,6 +173,7 @@ describe('launchAgentInNewTab paired web runtime', () => { activate: true, agentSessionKind: 'fresh', launchAgent: 'codex', + preparedAgentCommand: false, command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", env: { CODEX_PROFILE: 'captured' }, launchConfig: { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index fc5fd5b4381..2f4e9e374a0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,9 +1,7 @@ -import type { - AgentSessionLaunchPlan, - AgentSessionLaunchRequest -} from '@/lib/agent-session-launch-plan' +import { readLaunchPermissionChoice } from '../../../shared/tui-agent-permission-choice' +import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab-args' import { useAppStore } from '@/store' -import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { validateActionTerminalArgs } from './launch-work-item-direct-messages' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' import { persistAgentLaunchTabOrder } from '@/lib/launch-agent-tab-order' import { tuiAgentToAgentKind } from '@/lib/telemetry' @@ -17,103 +15,47 @@ import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-ow import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment' import { launchAgentInWebHostTab } from '@/lib/launch-agent-web-host-tab' import { + agentLaunchPermissionSettings, resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../../shared/tui-agent-launch-defaults' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../shared/tui-agent' -import type { LaunchSource } from '../../../shared/telemetry-events' +import { terminalSessionOptionInputs } from '../../../shared/tui-agent-launch-command' import { resolveAgentLaunchExecutionContext } from '@/lib/launch-agent-execution-context' import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-structured-route' -import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' -import type { StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { launchOnceHostAnswered, - routeNewTabLaunch + routeNewTabLaunch, + type AgentTabLaunchOutcome, + type LaunchAgentInNewTabResult } from '@/lib/launch-agent-in-new-tab-host-agents' -import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' -/** Mint per user action or preserve the request ID already carried by the caller’s plan. */ -type LaunchAgentInNewTabRequest = - | { requestId: AgentLaunchRequestId; agentSessionLaunchPlan?: undefined } - | { - /** Keeps a preflighted route authoritative across workspace creation. */ - agentSessionLaunchPlan: AgentSessionLaunchPlan - requestId?: undefined - } +export type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab-args' -export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { - requestedSurface?: AgentSessionLaunchRequest['requestedSurface'] - selectedExecutionHostId?: AgentSessionLaunchRequest['selectedExecutionHostId'] - agent: TuiAgent - worktreeId: string - /** Captured at a new-chat action before the host can delay route selection. */ - focusIntent?: NativeChatComposerFocusIntent - /** Tab group the user launched from; keeps split-group launches in that pane instead of the active group. */ - groupId?: string - /** Optional initial prompt; delivery depends on `promptDelivery` and the agent's prompt mode. */ - prompt?: string - /** Optional CLI arguments appended to the selected agent command. */ - agentArgs?: string | null - initialCwd?: string | null - /** How to deliver the prompt: `draft` leaves it editable, `submit-after-ready` sends it once the TUI is ready. */ - promptDelivery?: 'auto-submit' | 'draft' | 'submit-after-ready' - /** Telemetry surface that initiated this launch. Defaults to the tab-bar quick-launch entry point. */ - launchSource?: LaunchSource - /** User-authored Quick Command label for local tabs created from the tab bar. */ - quickCommandLabel?: string | null - /** Shell platform for the startup command; defaults to renderer OS. SSH/WSL worktrees run Linux even from Windows. */ - launchPlatform?: NodeJS.Platform - /** Called after the prompt is actually delivered to the agent input path. */ - onPromptDelivered?: () => void - /** The caller keeps the prompt's text if it does not go out (notes), so no composer gets it. */ - promptKeptByCaller?: true - /** Report blind PTY writes before onPromptDelivered so terminal launches cannot claim observed composer delivery. */ - onPromptDeliveryUnconfirmed?: () => void - /** Keep terminal launches in a floating workspace from taking global selection. */ - activate?: boolean - /** The launch seeds a workspace being opened, so its PTY spawn must not reshuffle Recent. */ - pendingActivationSpawn?: boolean - /** Lets a workspace reveal itself before the selected surface opens. */ - beforeSurfaceOpen?: ( - surface: { kind: 'local-terminal' } | { kind: 'host-published' } - ) => boolean | void - /** Opens instead of this agent's terminal when the host declines its structured chat. */ - onStructuredHostDeclined?: () => StructuredLaunchTerminal -} +export { shouldQueueTerminalFocusAfterMenuClose } from '@/lib/launch-agent-in-new-tab-host-agents' -/** `host-published`: the surface opens once its host answers; `local-chat` opened before it did. */ -export type AgentLaunchSurface = - | { kind: 'local-terminal'; tabId: string } - | { kind: 'local-chat'; tabId: string } - | { kind: 'host-published' } - -export type LaunchAgentInNewTabResult = { - surface: AgentLaunchSurface - startupPlan: AgentStartupPlan - pasteDraftAfterLaunch: boolean - promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> - /** Structured route only: what the launch did once it settled. The call stays synchronous. */ - terminalCompletion?: StructuredLaunchTerminal['completion'] - structuredSettlement?: Promise -} | null - -export function shouldQueueTerminalFocusAfterMenuClose( - result: NonNullable -): boolean { - return result.surface.kind === 'host-published' -} - -/** Create the routed agent surface with optional prompt; terminal argv agents embed text and follow-up agents paste after readiness unless promptDelivery overrides. Return null when planning fails. */ -function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { +/** + * Create a new terminal tab and queue the agent's launch command, optionally + * with an initial prompt. + * + * Submission mode follows `promptInjectionMode`: argv/flag agents fold the + * prompt into the launch command; followup-path agents launch empty and get a + * post-ready draft paste. Callers can override via `promptDelivery`. + * + * Returns `null` when no startup plan can be built (e.g. a whitespace-only prompt). + */ +export function launchAgentInNewTabWithOutcome( + args: LaunchAgentInNewTabArgs, + notifyFailure = true +): AgentTabLaunchOutcome { const { agent, worktreeId, groupId, prompt, agentArgs, + sessionOptions, initialCwd, promptDelivery = 'auto-submit', launchSource, @@ -132,38 +74,27 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent return null } const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext + const permissionSettings = agentLaunchPermissionSettings( + agent, + store.settings ?? {}, + sessionOptions + ) const effectiveAgentArgs = - agentArgs !== undefined ? agentArgs : resolveTuiAgentLaunchArgs(agent, store.settings) - const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings, resolvedLaunchPlatform) + agentArgs !== undefined + ? agentArgs + : resolveTuiAgentLaunchArgs( + agent, + permissionSettings, + readLaunchPermissionChoice(sessionOptions) ? 'own' : undefined + ) + const agentEnv = resolveTuiAgentLaunchEnv(agent, permissionSettings, resolvedLaunchPlatform) const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' // Why: a followup-path agent gets its prompt pasted unsubmitted after start, so route it as a draft. const routePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery - const startupPlanBase = { - agent, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, - platform: resolvedLaunchPlatform, - shell: queuedShell, - isRemote, - agentArgs: effectiveAgentArgs, - agentEnv - } - const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ - base: startupPlanBase, - prompt: trimmedPrompt, - promptDelivery, - isFollowupPath - }) - let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined - - if (!startupPlan) { - args.focusIntent?.cancel() - return null - } - - // Resolve structured ownership first, including paired hosts; only terminal routes fall through to host-published PTYs. + // Resolve structured ownership first; only terminal routes plan CLI arguments. const route = routeNewTabLaunch(store, args, { agent, requestedSurface: args.requestedSurface, @@ -172,11 +103,12 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent prompt: trimmedPrompt, promptDelivery: routePromptDelivery, tuiCustomization: { cwd: initialCwd }, + ...(sessionOptions ? { launchOptions: sessionOptions } : {}), onPromptDelivered, ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) }) if ('awaited' in route) { - return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTabInternal) + return launchOnceHostAnswered(route, args, (next) => launchAgentInNewTabWithOutcome(next)) } const { plan } = route if (plan?.route === 'structured-native-chat') { @@ -189,7 +121,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), // The host's "no" opens this same launch as a terminal, with the caller's arguments. openTerminal: (terminalPlan, terminalActivate) => - launchAgentInNewTabInternal({ + launchAgentInNewTabWithOutcome({ ...args, beforeSurfaceOpen: undefined, ...(terminalActivate === false ? { prompt: undefined, activate: false } : {}), @@ -201,7 +133,43 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent args.focusIntent?.cancel() return null } - return { ...structured, startupPlan } + return structured + } + + const terminalShell = queuedShell ?? (resolvedLaunchPlatform === 'win32' ? 'powershell' : 'posix') + if (!validateActionTerminalArgs(agent, agentArgs, terminalShell, notifyFailure)) { + args.focusIntent?.cancel() + return { refused: { delivered: false, failureNotified: notifyFailure } } + } + // The saved choice becomes flags that replace conflicting raw ones, unless it yields to them. + const terminalOptions = terminalSessionOptionInputs({ + agent, + sessionOptions, + yieldToAgentArgs: args.sessionOptionsYieldToAgentArgs, + agentArgs: effectiveAgentArgs, + shell: terminalShell + }) + const startupPlanBase = { + agent, + cmdOverrides: store.settings?.agentCmdOverrides ?? {}, + platform: resolvedLaunchPlatform, + shell: queuedShell, + isRemote, + agentArgs: effectiveAgentArgs, + agentEnv, + ...terminalOptions + } + const { startupPlan, pasteDraftAfterLaunch, submitPastedPrompt } = planLaunchAgentStartupPrompt({ + base: startupPlanBase, + prompt: trimmedPrompt, + promptDelivery, + isFollowupPath + }) + let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined + + if (!startupPlan) { + args.focusIntent?.cancel() + return null } args.focusIntent?.cancel() @@ -251,6 +219,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(groupId ? { groupId } : {}), prompt: trimmedPrompt, ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(terminalOptions.sessionOptions ? { sessionOptions: terminalOptions.sessionOptions } : {}), ...(initialCwd?.trim() ? { cwd: initialCwd } : {}), launchSource: launchSource ?? 'tab_bar_quick_launch', quickCommandLabel, @@ -338,4 +307,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } } -export { launchAgentInNewTabInternal as launchAgentInNewTab } +export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { + const result = launchAgentInNewTabWithOutcome(args, false) + return result && 'refused' in result ? null : result +} diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index 4d643ed5e63..9be1b85eed8 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -14,7 +14,10 @@ import type { AgentPromptDelivery } from '../../../shared/agent-session-host-aut import { translate } from '@/i18n/i18n' import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import type { AgentTerminalLaunchCompletion } from './structured-agent-session-launch-admission' -import { toAgentLaunchPreferences } from '../../../shared/agent-launch-preferences' +import { + agentPermissionNeedsPreparedCommand, + toAgentLaunchPreferences +} from '../../../shared/agent-launch-preferences' /** Paired web tabs are host-owned; never create a local tab, and return tabId:null. */ export function launchAgentInWebHostTab(args: { @@ -50,7 +53,7 @@ export function launchAgentInWebHostTab(args: { onPromptDelivered } = args const hasPrompt = prompt.length > 0 - const launchPreferences = toAgentLaunchPreferences(startupPlan.sessionOptions) + const launchPreferences = toAgentLaunchPreferences(startupPlan.sessionOptions, agent) const structuredPromptDelivery: AgentPromptDelivery = promptDelivery === 'draft' ? 'draft' : 'auto-submit' const launch = { @@ -64,6 +67,7 @@ export function launchAgentInWebHostTab(args: { agentSessionKind: 'fresh', // Why: with or without a prompt, the desktop's launch settings decide args (#31986). launchAgent: agent, + preparedAgentCommand: agentPermissionNeedsPreparedCommand(agent, startupPlan.sessionOptions), command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, @@ -74,7 +78,11 @@ export function launchAgentInWebHostTab(args: { ...(hasPrompt && pastePromptAfterReady === null ? { promptDelivery: structuredPromptDelivery } : {}), - ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(launchPreferences?.mode + ? { agentArgs: startupPlan.launchConfig.agentArgs } + : agentArgs !== undefined + ? { agentArgs } + : {}), ...(launchPreferences ? { launchPreferences } : {}) } as const diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 18b2c0e0268..c7c000fd802 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -45,9 +45,11 @@ export type StructuredAgentSessionLaunchIntent = { params: StructuredAgentSessionCreateParams createMessageSupport?: boolean createPrepared?: true - prepareCreate?: () => void + prepareCreate?: (hostTakesExplicitOptions: boolean) => void beforeCreate?: () => boolean surfaceAddress?: StructuredLaunchSurfaceAddress + /** The saved selection create seeds, read when the intent is built. */ + explicitLaunchOptions?: Readonly> seedOptions?: Readonly> } @@ -141,14 +143,17 @@ function buildStructuredAgentSessionLaunchIntent( export function retryStructuredAgentSessionLaunchIntent( intent: StructuredAgentSessionLaunchIntent ): StructuredAgentSessionLaunchIntent { - return buildStructuredAgentSessionLaunchIntent( - intent.worktreeId, - intent, - intent.agent, - intent.sessionId, - intent.params.resumeFrom, - intent.seedOptions - ) + return { + ...buildStructuredAgentSessionLaunchIntent( + intent.worktreeId, + intent, + intent.agent, + intent.sessionId, + intent.params.resumeFrom, + intent.seedOptions + ), + explicitLaunchOptions: intent.explicitLaunchOptions + } } export function restoreStructuredAgentSessionLaunchIntent(args: { @@ -160,8 +165,11 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { payloadFingerprint: string expectedRuntimeFence: number | null resumeFrom?: StructuredAgentSessionResumeSource + /** A paired server's seed, kept with the launch so a reload shows what create runs. */ + explicitLaunchOptions?: Readonly> seedOptions?: Readonly> firstMessage?: StructuredAgentSessionFirstMessage + optionsMode?: 'replace-model-selection' options?: Readonly> createMessageSupport?: boolean }): StructuredAgentSessionLaunchIntent { @@ -184,12 +192,14 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { agent: args.agent, ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}), ...(args.firstMessage ? { firstMessage: args.firstMessage } : {}), + ...(args.optionsMode ? { optionsMode: args.optionsMode } : {}), ...(args.options ? { options: args.options } : {}) }, ...(args.createMessageSupport === undefined ? {} : { createMessageSupport: args.createMessageSupport }), ...(args.createMessageSupport === undefined ? {} : { createPrepared: true as const }), + ...(args.explicitLaunchOptions ? { explicitLaunchOptions: args.explicitLaunchOptions } : {}), ...launchSeedOptions(state, { target }, args.agent, args.seedOptions) } } diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 0b554a90007..09ecc4ccfb6 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -1,3 +1,13 @@ +import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages' +import { toast } from 'sonner' +import { + deliverLaunchPromptToAgentTab, + seedNativeChatLaunchDraftForAgentTab +} from '@/lib/agent-launch-prompt-delivery' +import { + buildDirectWorkItemAgentStartupPlan, + notifyDirectWorkItemAgentStartTimeout +} from '@/lib/launch-work-item-direct-agent' import { acquireAgentLaunchChoices } from './host-chat-program-acquisition' import { useAppStore } from '@/store' import type { TuiAgent } from '../../../shared/tui-agent' @@ -6,7 +16,6 @@ import { isTuiAgent } from '../../../shared/tui-agent-config' import { isTuiAgentEnabled, pickTuiAgent } from '../../../shared/tui-agent-selection' import { worktreeLaunchWorkspace } from '@/lib/agent-launch-route-input' import { agentChoiceMayOpenAsChat } from '@/lib/host-chat-program' -import { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { resolveExecutionHostAgentStartupShell, @@ -22,6 +31,8 @@ import { export function buildDirectWorkItemStartup(args: { agent: TuiAgent | null agentArgs?: string | null + sessionOptions?: Readonly> + sessionOptionsYieldToAgentArgs?: true draftContent: string promptDelivery: PromptDelivery settings: AppState['settings'] @@ -35,6 +46,8 @@ export function buildDirectWorkItemStartup(args: { return buildDirectWorkItemAgentStartupPlan({ agent: args.agent, agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, + sessionOptionsYieldToAgentArgs: args.sessionOptionsYieldToAgentArgs, draftContent: args.draftContent, promptDelivery: args.promptDelivery, settings: args.settings, @@ -130,3 +143,55 @@ export function beginDirectWorkItemStructuredLaunch(args: { primaryTabId: launch.tab?.id ?? args.primaryTabId } } + +export function finishDirectWorkItemPrompt(args: { + primaryTabId: string | null + effectiveAgent: TuiAgent | null + promptDelivery: PromptDelivery + draftContent: string + startupPlan: ReturnType['startupPlan'] + draftLaunchedNatively: boolean + startupPlanFailed: boolean +}): boolean { + const { + primaryTabId, + effectiveAgent, + promptDelivery, + draftContent, + startupPlan, + draftLaunchedNatively, + startupPlanFailed + } = args + if (startupPlanFailed) { + toast.error(agentLaunchCommandErrorMessage()) + return false + } + + if (primaryTabId && effectiveAgent && promptDelivery === 'draft') { + // Why: the draft rides in on argv or the startup payload, so no paste runs + // below; mirror it into chat the way the new-tab launcher does. + seedNativeChatLaunchDraftForAgentTab({ + tabId: primaryTabId, + agent: effectiveAgent, + text: draftContent + }) + } + if ( + primaryTabId && + startupPlan && + !draftLaunchedNatively && + !(promptDelivery === 'draft' && startupPlan.draftPrompt) + ) { + const submit = promptDelivery === 'submit-after-ready' + const agent = startupPlan.agent + void deliverLaunchPromptToAgentTab({ + tabId: primaryTabId, + agent, + content: draftContent, + submit, + forcePaste: submit, + onTimeout: () => notifyDirectWorkItemAgentStartTimeout(agent, submit) + }) + } + return true +} diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts index 4aef2dffbe8..b0772bbad8b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.test.ts @@ -144,3 +144,21 @@ describe('buildDirectWorkItemAgentStartupPlan global arguments fallback', () => expect(result.startupPlan?.launchCommand).not.toContain('danger-full-access') }) }) + +it('keeps own permission on the direct terminal route with unrelated args and env', () => { + const result = buildDirectWorkItemAgentStartupPlan({ + agent: 'codex', + draftContent: 'Fix checks', + promptDelivery: 'draft', + launchPlatform: 'linux', + settings: { + agentPermissionDefault: 'full-access', + agentDefaultArgs: { codex: '--search' }, + agentDefaultEnv: { codex: { KEEP: 'value' } } + }, + sessionOptions: { permissionMode: 'own' } + }) + expect(result.startupPlan?.launchCommand).not.toContain('bypass') + expect(result.startupPlan?.launchCommand).toContain("'--search'") + expect(result.startupPlan?.env).toEqual({ KEEP: 'value' }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index f02f80d9880..26b53c90ec6 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -1,3 +1,4 @@ +import { readLaunchPermissionChoice } from '../../../shared/tui-agent-permission-choice' import { toast } from 'sonner' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { @@ -12,15 +13,19 @@ import type { StartupCommandDelivery } from '../../../shared/codex-startup-deliv import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import { + agentLaunchPermissionSettings, resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv, type AgentLaunchProfileSettings } from '../../../shared/tui-agent-launch-defaults' +import { terminalSessionOptionInputs } from '../../../shared/tui-agent-launch-command' import { translate } from '@/i18n/i18n' export function buildDirectWorkItemAgentStartupPlan(args: { agent: TuiAgent | null agentArgs?: string | null + sessionOptions?: Readonly> + sessionOptionsYieldToAgentArgs?: true draftContent: string promptDelivery: 'draft' | 'submit-after-ready' settings: @@ -42,11 +47,31 @@ export function buildDirectWorkItemAgentStartupPlan(args: { return { startupPlan: null, draftLaunchedNatively: false, startupPlanFailed: false } } + const permissionSettings = agentLaunchPermissionSettings( + args.agent, + args.settings ?? {}, + args.sessionOptions + ) const effectiveAgentArgs = args.agentArgs === undefined - ? resolveTuiAgentLaunchArgs(args.agent, args.settings) + ? resolveTuiAgentLaunchArgs( + args.agent, + permissionSettings, + readLaunchPermissionChoice(args.sessionOptions) ? 'own' : undefined + ) : args.agentArgs - const effectiveAgentEnv = resolveTuiAgentLaunchEnv(args.agent, args.settings, args.launchPlatform) + const effectiveAgentEnv = resolveTuiAgentLaunchEnv( + args.agent, + permissionSettings, + args.launchPlatform + ) + const sessionOptionInputs = terminalSessionOptionInputs({ + agent: args.agent, + sessionOptions: args.sessionOptions, + yieldToAgentArgs: args.sessionOptionsYieldToAgentArgs, + agentArgs: effectiveAgentArgs, + shell: args.shell ?? (args.launchPlatform === 'win32' ? 'powershell' : 'posix') + }) const draftLaunchPlan = args.promptDelivery === 'submit-after-ready' ? null @@ -58,7 +83,8 @@ export function buildDirectWorkItemAgentStartupPlan(args: { ...(args.shell ? { shell: args.shell } : {}), isRemote: args.isRemote, agentArgs: effectiveAgentArgs, - agentEnv: effectiveAgentEnv + agentEnv: effectiveAgentEnv, + ...sessionOptionInputs }) if (draftLaunchPlan) { @@ -88,6 +114,7 @@ export function buildDirectWorkItemAgentStartupPlan(args: { isRemote: args.isRemote, agentArgs: effectiveAgentArgs, agentEnv: effectiveAgentEnv, + ...sessionOptionInputs, allowEmptyPromptLaunch: true, ...(args.promptDelivery === 'submit-after-ready' ? { suppressStartupUpdatePrompt: true } : {}) }) diff --git a/src/renderer/src/lib/launch-work-item-direct-draft.ts b/src/renderer/src/lib/launch-work-item-direct-draft.ts index 5df2f8d97ec..eaea9f55e0e 100644 --- a/src/renderer/src/lib/launch-work-item-direct-draft.ts +++ b/src/renderer/src/lib/launch-work-item-direct-draft.ts @@ -1,3 +1,5 @@ +import { getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' +import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { getLaunchableWorkItemDraftContent } from '@/lib/linked-work-item-context' import type { LaunchableWorkItem } from '@/lib/launch-work-item-direct-types' @@ -7,3 +9,26 @@ export async function getDirectWorkItemDraftContent( ): Promise { return getLaunchableWorkItemDraftContent(item) } + +export function getDirectWorkItemWorkspaceNames( + item: LaunchableWorkItem, + itemType: LaunchableWorkItem['type'], + itemNumber: number | null +) { + const workspaceIntentName = + itemNumber !== null + ? getWorkspaceIntentName({ + sourceText: item.pasteContent, + workItem: { ...item, type: itemType, number: itemNumber } + }) + : null + const workspaceName = getWorkspaceSeedName({ + explicitName: item.linearIdentifier + ? getLinearIssueWorkspaceName({ identifier: item.linearIdentifier, title: item.title }) + : (workspaceIntentName?.seedName ?? ''), + prompt: '', + linkedIssueNumber: itemType === 'issue' ? (itemNumber ?? null) : null, + linkedPR: itemType === 'pr' ? (itemNumber ?? null) : null + }) + return { workspaceIntentName, workspaceName } +} diff --git a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts index 98984530884..10deaf7a9a9 100644 --- a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts @@ -29,11 +29,18 @@ vi.mock('@/lib/launch-work-item-direct-preflight', async (importOriginal) => ({ resolveDirectSetupDecision: vi.fn().mockResolvedValue({ kind: 'resolved', decision: 'inherit' }) })) vi.mock('@/lib/agent-detection-target-inventory', () => ({ + loadProjectLaunchContext: vi.fn().mockResolvedValue(undefined), ensureDetectedAgentsForRepo: vi.fn().mockResolvedValue(['claude']), ensureDetectedAgentsForNewWorktree: vi.fn().mockResolvedValue(['claude']) })) -vi.mock('@/lib/agent-session-launch-plan', () => ({ - planAgentSessionLaunch: vi.fn(() => ({ route: 'terminal' })) +vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => ({ + ...(await importOriginal>()), + awaitStructuredRouteHostAnswer: vi.fn().mockResolvedValue(undefined), + planAgentSessionLaunch: vi.fn(() => ({ + route: 'terminal-tui', + agent: 'claude', + requestId: 'test-request' + })) })) vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ deliverLaunchPromptToAgentTab: vi.fn(), diff --git a/src/renderer/src/lib/launch-work-item-direct-messages.test.ts b/src/renderer/src/lib/launch-work-item-direct-messages.test.ts index 6b21e624735..c68e8021ff6 100644 --- a/src/renderer/src/lib/launch-work-item-direct-messages.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-messages.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/new-workspace', () => import('./linked-work-item-provider')) vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, translate: (_key: string, fallback: string) => fallback })) diff --git a/src/renderer/src/lib/launch-work-item-direct-messages.ts b/src/renderer/src/lib/launch-work-item-direct-messages.ts index 7b3a4a76795..33d568ee16e 100644 --- a/src/renderer/src/lib/launch-work-item-direct-messages.ts +++ b/src/renderer/src/lib/launch-work-item-direct-messages.ts @@ -1,3 +1,8 @@ +import { toast } from 'sonner' +import { planAgentCliArgsSuffix } from './tui-agent-startup' +import { getAgentLabel } from './agent-catalog' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell' import { isGitLabIssueUrl } from '@/lib/new-workspace' import { translate } from '@/i18n/i18n' @@ -33,3 +38,24 @@ export const agentLaunchCommandErrorMessage = (): string => 'auto.lib.launch.work.item.direct.3de6371df3', 'Could not build the agent launch command.' ) + +export function validateActionTerminalArgs( + agent: TuiAgent, + args: string | null | undefined, + shell: AgentStartupShell, + notify = true +): boolean { + if (planAgentCliArgsSuffix(args, shell).ok) { + return true + } + if (notify) { + toast.error( + translate( + 'settings.sourceControl.actionModel.invalidArgs', + 'The CLI arguments for {{value0}} are invalid. Fix them in Settings → Source Control AI.', + { value0: getAgentLabel(agent) } + ) + ) + } + return false +} diff --git a/src/renderer/src/lib/launch-work-item-direct-preflight.ts b/src/renderer/src/lib/launch-work-item-direct-preflight.ts index 598dbaf4da2..396d377166b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-preflight.ts +++ b/src/renderer/src/lib/launch-work-item-direct-preflight.ts @@ -1,3 +1,15 @@ +import { useAppStore, type AppState } from '@/store' +import { ensureDetectedAgentsForRepo } from './agent-detection-target-inventory' +import { pickTuiAgent } from '../../../shared/tui-agent-selection' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { ProjectLaunchContext } from '../../../shared/rpc-contract/preflight-params' +import type { AgentLaunchRequestId } from './agent-launch-request-id' +import { + awaitStructuredRouteHostAnswer, + planAgentSessionLaunch, + type AgentSessionLaunchRequest +} from './agent-session-launch-plan' import { getSetupConfig } from '@/lib/new-workspace' import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client' import { resolveGitHubPrStartPointForRepo } from '@/lib/github-pr-start-point' @@ -77,3 +89,60 @@ export function resolveDirectLaunchOwnerRow( const target = ipcOrRuntimeTargetForHostId(executionHostId) return target ? { repo: rows[0], executionHostId, target } : null } + +/** Freeze the prospective verdict before create; the actual-agent path handles older hosts. */ +export async function planDirectWorkItemLaunch(args: { + requestId: AgentLaunchRequestId + prospectiveAgent: TuiAgent | null + projectContext?: ProjectLaunchContext + repoId: string + repoOwnerHostId: ExecutionHostId + draftContent: string + promptDelivery: 'draft' | 'submit-after-ready' + resolveSessionOptions?: (agent: TuiAgent) => Readonly> | undefined + sessionOptions?: Readonly> +}) { + const { prospectiveAgent, projectContext } = args + if (!prospectiveAgent) { + return undefined + } + const sessionOptions = args.resolveSessionOptions + ? args.resolveSessionOptions(prospectiveAgent) + : args.sessionOptions + const request: AgentSessionLaunchRequest = { + requestId: args.requestId, + agent: prospectiveAgent, + workspace: { + kind: projectContext?.workspaceKind ?? 'git-worktree', + repoId: args.repoId, + executionHostId: args.repoOwnerHostId, + ...(projectContext ? { executionContext: projectContext.executionContext } : {}) + }, + prompt: args.draftContent, + promptDelivery: args.promptDelivery, + ...(sessionOptions ? { launchOptions: sessionOptions } : {}) + } + await awaitStructuredRouteHostAnswer(useAppStore.getState(), request, 3000) + return planAgentSessionLaunch(useAppStore.getState(), request) +} + +export async function resolveDirectProspectiveAgent( + store: AppState, + repo: Repo, + projectContext: ProjectLaunchContext | undefined, + agentOverride: TuiAgent | undefined +) { + const detectedAgentsPromise = projectContext + ? Promise.resolve(projectContext.agents) + : agentOverride + ? null + : ensureDetectedAgentsForRepo(store, repo) + const prospectiveAgent = + agentOverride ?? + pickTuiAgent( + store.settings?.defaultTuiAgent, + new Set(((await detectedAgentsPromise) ?? []).filter(isTuiAgent)), + store.settings?.disabledTuiAgents + ) + return { detectedAgentsPromise, prospectiveAgent } +} diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index fc1337ac4d4..5c6610390a9 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -16,6 +16,7 @@ import type { AgentSessionLaunchPlan, planAgentSessionLaunch } from '@/lib/agent-session-launch-plan' +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import type { AgentLaunchRequestId } from '@/lib/agent-launch-request-id' import { buildDirectWorkItemStartup, @@ -26,6 +27,7 @@ export type DirectWorkItemAgentLaunchPreparation = { launchConnectionId: string | null unavailable: boolean effectiveAgent: TuiAgent | null + agentArgs?: string | null startupPlan: ReturnType['startupPlan'] draftLaunchedNatively: boolean startupPlanFailed: boolean @@ -82,6 +84,10 @@ export async function prepareDirectWorkItemAgentLaunch(args: { repoId: string agentOverride?: TuiAgent agentArgs?: string | null + sessionOptions?: Readonly> + resolveAgentArgs?: (agent: TuiAgent) => string | null | undefined + resolveSessionOptions?: (agent: TuiAgent) => Readonly> | undefined + sessionOptionsYieldToAgentArgs?: true repoConnectionId: string | null repoHost: RepoHostFields & { path: string } detectedAgentsPromise: Promise | null @@ -95,6 +101,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { repoProjectRuntime?: ProjectExecutionRuntimeResolution planLaunch: typeof planAgentSessionLaunch requestId: AgentLaunchRequestId + preflightPlan?: AgentSessionLaunchPlan }): Promise { const launchConnectionId = getConnectionId(args.worktreeId) ?? args.repoConnectionId const agentSelection = await resolveDirectWorkItemAgent({ @@ -128,33 +135,48 @@ export async function prepareDirectWorkItemAgentLaunch(args: { // Non-critical: activation still has the explicit startup below. }) } - const { startupPlan, draftLaunchedNatively, startupPlanFailed } = buildDirectWorkItemStartup({ - agent: effectiveAgent, - agentArgs: args.agentArgs, - draftContent: args.draftContent, - promptDelivery: args.promptDelivery, - settings: args.settings, - launchHost: resolveDirectWorkItemLaunchHost(args), - terminalWindowsShell: resolveDirectWorkItemTabShell(args), - launchConnectionId - }) - + const agentArgs = + effectiveAgent && args.resolveAgentArgs ? args.resolveAgentArgs(effectiveAgent) : args.agentArgs + const sessionOptions = effectiveAgent + ? args.resolveSessionOptions + ? args.resolveSessionOptions(effectiveAgent) + : args.sessionOptions + : undefined const plan = effectiveAgent === null ? null - : args.planLaunch(args.latestStore, { - requestId: args.requestId, - agent: effectiveAgent, - workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, - prompt: args.draftContent, - promptDelivery: args.promptDelivery - }) + : args.preflightPlan + ? adoptAgentSessionLaunchVerdict({ ...args.preflightPlan, worktreeId: args.worktreeId }) + : args.planLaunch(args.latestStore, { + requestId: args.requestId, + agent: effectiveAgent, + workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, + prompt: args.draftContent, + promptDelivery: args.promptDelivery, + ...(sessionOptions ? { launchOptions: sessionOptions } : {}) + }) const structuredLaunch = plan?.route === 'structured-native-chat' + const { startupPlan, draftLaunchedNatively, startupPlanFailed } = structuredLaunch + ? { startupPlan: null, draftLaunchedNatively: false, startupPlanFailed: false } + : buildDirectWorkItemStartup({ + agent: effectiveAgent, + agentArgs, + sessionOptions, + sessionOptionsYieldToAgentArgs: args.sessionOptionsYieldToAgentArgs, + draftContent: args.draftContent, + promptDelivery: args.promptDelivery, + settings: args.settings, + launchHost: resolveDirectWorkItemLaunchHost(args), + terminalWindowsShell: resolveDirectWorkItemTabShell(args), + launchConnectionId + }) + return { launchConnectionId, unavailable: false, effectiveAgent, + agentArgs, startupPlan, draftLaunchedNatively, startupPlanFailed, diff --git a/src/renderer/src/lib/launch-work-item-direct-types.ts b/src/renderer/src/lib/launch-work-item-direct-types.ts index 4d68b4d36c3..761f84b1539 100644 --- a/src/renderer/src/lib/launch-work-item-direct-types.ts +++ b/src/renderer/src/lib/launch-work-item-direct-types.ts @@ -30,6 +30,12 @@ export type LaunchWorkItemDirectArgs = { telemetrySource?: WorkspaceCreateTelemetrySource agentOverride?: TuiAgent agentArgs?: string | null + /** The saved choice for `agentOverride`; a chat seeds it and a terminal applies it as flags. */ + sessionOptions?: Readonly> + /** On terminals, a flag `agentArgs` sets itself wins over the inherited saved choice. */ + sessionOptionsYieldToAgentArgs?: true + resolveAgentArgs?: (agent: TuiAgent) => string | null | undefined + resolveSessionOptions?: (agent: TuiAgent) => Readonly> | undefined promptDelivery?: 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform } diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index ac267aba9e5..30cef574b9d 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveProjectExecutionRuntime } from '../../../shared/project-execution-runtime' +import * as inventory from './agent-detection-target-inventory' +import * as localCapabilities from '@/runtime/local-runtime-capabilities' +import { RUNTIME_CAPABILITIES } from '../../../shared/protocol-version' import type { AppState } from '@/store' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' @@ -133,13 +137,8 @@ import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-dire import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { pickTuiAgent } from '../../../shared/tui-agent-selection' -const mockApi = { - worktrees: { - resolvePrBase: mocks.resolvePrBase - } -} - describe('launchWorkItemDirect', () => { + afterEach(() => vi.restoreAllMocks()) beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('window', { @@ -222,8 +221,6 @@ describe('launchWorkItemDirect', () => { ]) mocks.store.sshStateByEnvironment = new Map() mocks.store.runtimeStatusByEnvironmentId = new Map() - // @ts-expect-error -- test shim - globalThis.window = { api: mockApi } }) it('rejects invalid per-launch CLI arguments before creating a workspace', async () => { @@ -245,7 +242,7 @@ describe('launchWorkItemDirect', () => { ).resolves.toBe(false) expect(mocks.createWorktree).not.toHaveBeenCalled() - expect(mocks.ensureDetectedAgents).not.toHaveBeenCalled() + expect(mocks.ensureDetectedAgents).toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledWith( 'CLI arguments are invalid: Unclosed quote in command template.' ) @@ -616,6 +613,7 @@ describe('launchWorkItemDirect', () => { openModalFallback: vi.fn(), agentOverride: 'claude', agentArgs: '--model opus', + resolveAgentArgs: () => '--verbose --model opus', launchPlatform: 'linux', promptDelivery: 'submit-after-ready', item: { @@ -630,7 +628,7 @@ describe('launchWorkItemDirect', () => { expect(beginDirectWorkItemStructuredLaunch).toHaveBeenCalledWith( expect.objectContaining({ declinedTerminal: { - agentArgs: '--model opus', + agentArgs: '--verbose --model opus', launchPlatform: 'linux', launchSource: 'task_page' } @@ -822,4 +820,43 @@ describe('launchWorkItemDirect', () => { `command test -n "$fish_pid" && set --erase -g ORCA_PI_PREFILL; command test -z "$fish_pid" && unset ORCA_PI_PREFILL; true` ) }) + it('uses prospective native facts to ignore hidden args before create', async () => { + vi.spyOn(inventory, 'loadProjectLaunchContext').mockResolvedValue({ + agents: ['codex'], + workspaceKind: 'git-worktree', + executionContext: { + kind: 'host', + projectRuntime: resolveProjectExecutionRuntime({ + appPlatform: 'darwin', + projectId: 'p1' + }) + } + }) + vi.spyOn(localCapabilities, 'readLocalRuntimeCapabilitiesOrUnknown').mockReturnValue( + RUNTIME_CAPABILITIES + ) + mocks.store.settings = Object.assign({}, mocks.store.settings, { experimentalNativeChat: true }) + vi.mocked(beginDirectWorkItemStructuredLaunch).mockReturnValueOnce({ + completed: true, + structuredLaunch: true, + primaryTabId: null + }) + await launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentArgs: '--model "unterminated', + resolveSessionOptions: () => ({ model: 'chosen' }), + item: { + type: 'issue', + number: 42, + title: 'Fix', + url: 'https://github.com/acme/repo/issues/42' + } + }) + expect(mocks.createWorktree).toHaveBeenCalledOnce() + expect( + vi.mocked(beginDirectWorkItemStructuredLaunch).mock.calls.at(-1)?.[0].plan?.launchOptions + ).toEqual({ model: 'chosen' }) + }) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 260cb3a589e..8f39b110cfe 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -1,14 +1,9 @@ +import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { toast } from 'sonner' import { useAppStore } from '@/store' -import { - deliverLaunchPromptToAgentTab, - seedNativeChatLaunchDraftForAgentTab -} from '@/lib/agent-launch-prompt-delivery' -import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { - agentLaunchCommandErrorMessage, gitLabIssueNumber, resolvePrHeadErrorMessage, unavailableAgentErrorMessage, @@ -18,18 +13,19 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import type { TuiAgent } from '../../../shared/tui-agent' import type { SetupDecision } from '../../../shared/worktree/create-types' import type { GitPushTarget } from '../../../shared/worktree/types' -import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' import type { buildDirectWorkItemAgentStartupPlan } from '@/lib/launch-work-item-direct-agent' +import { buildDirectWorkItemStartupOpts } from '@/lib/launch-work-item-direct-agent' import { - buildDirectWorkItemStartupOpts, - notifyDirectWorkItemAgentStartTimeout -} from '@/lib/launch-work-item-direct-agent' -import { getDirectWorkItemDraftContent } from '@/lib/launch-work-item-direct-draft' + getDirectWorkItemDraftContent, + getDirectWorkItemWorkspaceNames +} from '@/lib/launch-work-item-direct-draft' import { resolveDirectPrStartPoint, resolveDirectLaunchOwnerRow, - resolveDirectSetupDecision + resolveDirectSetupDecision, + planDirectWorkItemLaunch, + resolveDirectProspectiveAgent } from '@/lib/launch-work-item-direct-preflight' import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' import { @@ -37,14 +33,17 @@ import { resolveRepoExecutionHostPlatform } from '@/lib/execution-host-facts' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { beginDirectWorkItemStructuredLaunch } from '@/lib/launch-work-item-direct-agent-routing' +import { + beginDirectWorkItemStructuredLaunch, + finishDirectWorkItemPrompt +} from '@/lib/launch-work-item-direct-agent-routing' import { prepareDirectWorkItemAgentLaunch } from '@/lib/launch-work-item-direct-route-preparation' import { planAgentSessionLaunch, type AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { ensureDetectedAgentsForRepo } from '@/lib/agent-detection-target-inventory' +import { loadProjectLaunchContext } from '@/lib/agent-detection-target-inventory' /** Create/activate a workspace and deliver work-item context to its agent. Setup decisions, unresolved repos or unavailable agents fall back to the modal; paste failures retain the usable workspace and surface a notice. */ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Promise { @@ -55,9 +54,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom baseBranch, telemetrySource, launchSource, - agentOverride, - agentArgs + agentOverride } = args + let agentArgs = args.agentArgs const store = useAppStore.getState() const owner = resolveDirectLaunchOwnerRow(store.repos, repoId) if (!owner) { @@ -90,14 +89,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } const repoLaunchHost = refineLocalLaunchPlatform(repoLaunchFact, args.launchPlatform) const shell = repoLaunchHost.platform === 'win32' ? 'powershell' : 'posix' - const agentArgsPlan = planAgentCliArgsSuffix(agentArgs, shell) - if (!agentArgsPlan.ok) { - // Validate saved arguments before creation; planning after create would otherwise leave an invalid launch’s workspace behind. - toast.error(agentArgsPlan.error) - return false - } - // Start cold agent detection concurrently without delaying setup policy or workspace creation. - const detectedAgentsPromise = agentOverride ? null : ensureDetectedAgentsForRepo(store, repo) + // Why: agent detection shells out and can be cold/slow. Start it now, but + // don't let it serialize setup-policy resolution or git worktree creation. + const projectContextPromise = loadProjectLaunchContext(repoOwnerTarget, repoId).then( + (context) => ({ context }), + () => null + ) const setupResolution = await resolveDirectSetupDecision(repoId, repo, repoOwnerHostId) if (setupResolution.kind === 'needs-modal') { @@ -114,21 +111,11 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom const finalSetupDecision: SetupDecision = trustDecision === 'skip' ? 'skip' : setupResolution.decision - const workspaceIntentName = - itemNumber !== null - ? getWorkspaceIntentName({ - sourceText: item.pasteContent, - workItem: { ...item, type: itemType, number: itemNumber } - }) - : null - const workspaceName = getWorkspaceSeedName({ - explicitName: item.linearIdentifier - ? getLinearIssueWorkspaceName({ identifier: item.linearIdentifier, title: item.title }) - : (workspaceIntentName?.seedName ?? ''), - prompt: '', - linkedIssueNumber: itemType === 'issue' ? (itemNumber ?? null) : null, - linkedPR: itemType === 'pr' ? (itemNumber ?? null) : null - }) + const { workspaceIntentName, workspaceName } = getDirectWorkItemWorkspaceNames( + item, + itemType, + itemNumber + ) let resolvedBaseBranch = baseBranch let resolvedPushTarget: GitPushTarget | undefined let resolvedBranchNameOverride: string | undefined @@ -148,6 +135,41 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom } } + const projectPreflight = await projectContextPromise + if (!projectPreflight) { + toast.error(unavailableAgentErrorMessage()) + return false + } + const projectContext = projectPreflight.context + const { detectedAgentsPromise, prospectiveAgent } = await resolveDirectProspectiveAgent( + store, + repo, + projectContext, + agentOverride + ) + const requestId = newAgentLaunchRequestId() + const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) + const preflightPlan = await planDirectWorkItemLaunch({ + requestId, + prospectiveAgent, + projectContext, + repoId, + repoOwnerHostId, + draftContent, + promptDelivery, + resolveSessionOptions: args.resolveSessionOptions, + sessionOptions: args.sessionOptions + }) + if (!projectContext || preflightPlan?.route !== 'structured-native-chat') { + const agentArgsPlan = planAgentCliArgsSuffix(agentArgs, shell) + if (!agentArgsPlan.ok) { + // Why: direct launches may create a worktree before the agent startup plan + // is built; reject malformed saved args before touching user workspaces. + toast.error(agentArgsPlan.error) + return false + } + } + let worktreeId: string, worktreePath = '' let primaryTabId: string | null @@ -156,7 +178,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let draftLaunchedNatively = false let plan: AgentSessionLaunchPlan | null = null let structuredLaunchCompleted = false - const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false try { const result = await store.createWorktree( @@ -195,8 +216,12 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom worktreeId, worktreePath, repoId, - agentOverride, + agentOverride: preflightPlan?.agent ?? agentOverride, agentArgs, + ...(args.sessionOptions ? { sessionOptions: args.sessionOptions } : {}), + resolveAgentArgs: args.resolveAgentArgs, + resolveSessionOptions: args.resolveSessionOptions, + sessionOptionsYieldToAgentArgs: args.sessionOptionsYieldToAgentArgs, repoConnectionId, repoHost: repo, detectedAgentsPromise, @@ -208,7 +233,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom repoLaunchHost, repoProjectRuntime, planLaunch: planAgentSessionLaunch, - requestId: newAgentLaunchRequestId() + requestId, + preflightPlan }) if (launchPreparation.unavailable) { activateAndRevealWorktree(worktreeId, { @@ -218,6 +244,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom toast.error(unavailableAgentErrorMessage()) return false } + agentArgs = launchPreparation.agentArgs effectiveAgent = launchPreparation.effectiveAgent startupPlan = launchPreparation.startupPlan draftLaunchedNatively = launchPreparation.draftLaunchedNatively @@ -244,6 +271,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom beforeOpen: revealWorkspace, declinedTerminal: { ...(agentArgs !== undefined ? { agentArgs } : {}), + sessionOptionsYieldToAgentArgs: args.sessionOptionsYieldToAgentArgs, ...(args.launchPlatform ? { launchPlatform: args.launchPlatform } : {}), ...(launchSource ? { launchSource } : {}) } @@ -273,35 +301,13 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return true } - if (startupPlanFailed) { - toast.error(agentLaunchCommandErrorMessage()) - return false - } - - if (primaryTabId && effectiveAgent && promptDelivery === 'draft') { - // Mirror argv/startup-payload drafts into chat because the paste path below will not seed them. - seedNativeChatLaunchDraftForAgentTab({ - tabId: primaryTabId, - agent: effectiveAgent, - text: draftContent - }) - } - if ( - primaryTabId && - startupPlan && - !draftLaunchedNatively && - !(promptDelivery === 'draft' && startupPlan.draftPrompt) - ) { - const submit = promptDelivery === 'submit-after-ready' - const agent = startupPlan.agent - void deliverLaunchPromptToAgentTab({ - tabId: primaryTabId, - agent, - content: draftContent, - submit, - forcePaste: submit, - onTimeout: () => notifyDirectWorkItemAgentStartTimeout(agent, submit) - }) - } - return true + return finishDirectWorkItemPrompt({ + primaryTabId, + effectiveAgent, + promptDelivery, + draftContent, + startupPlan, + draftLaunchedNatively, + startupPlanFailed + }) } diff --git a/src/renderer/src/lib/runtime-agent-background-create.ts b/src/renderer/src/lib/runtime-agent-background-create.ts index 4733be96db5..e44bd2c0940 100644 --- a/src/renderer/src/lib/runtime-agent-background-create.ts +++ b/src/renderer/src/lib/runtime-agent-background-create.ts @@ -5,7 +5,10 @@ import type { StartupCommandDelivery } from '../../../shared/codex-startup-deliv import type { SessionOptionValue } from '../../../shared/native-chat-session-options' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import type { TuiAgent } from '../../../shared/tui-agent' -import { toAgentLaunchPreferences } from '../../../shared/agent-launch-preferences' +import { + agentPermissionNeedsPreparedCommand, + toAgentLaunchPreferences +} from '../../../shared/agent-launch-preferences' import { createAgentSessionCreateOperation, withAgentSessionCreateOperationId @@ -34,32 +37,34 @@ export async function createRuntimeAgentBackgroundTerminal(args: { const keyboardProtocol = buildDefaultTerminalOptions().vtExtensions?.kittyKeyboard const keyboardOptions = createAgentSessionKeyboardOptions(keyboardProtocol) const operation = createAgentSessionCreateOperation() - const launchPreferences = toAgentLaunchPreferences(args.sessionOptions) + const launchPreferences = toAgentLaunchPreferences(args.sessionOptions, args.agent) return await runRemoteAgentSessionLaunch({ environmentId: args.environmentId, - hostAuthority: () => - operation.run(async (clientOperationId) => - callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( - { kind: 'environment', environmentId: args.environmentId }, - 'terminal.createAgentSession', - withAgentSessionCreateOperationId( - { - ...(await keyboardOptions(args.environmentId)), - worktree: toRuntimeWorktreeSelector(args.worktreeId), - agent: args.agent, - ...(args.prompt - ? { prompt: args.prompt, promptDelivery: 'auto-submit' as const } - : {}), - ...(launchPreferences ? { launchPreferences } : {}), - placement: { tabId: args.tabId, leafId: args.leafId }, - // Why: local renderer owns the hidden tab; remote runtime should not reveal UI. - presentation: 'background' - }, - clientOperationId + hostAuthority: agentPermissionNeedsPreparedCommand(args.agent, args.sessionOptions) + ? undefined + : () => + operation.run(async (clientOperationId) => + callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( + { kind: 'environment', environmentId: args.environmentId }, + 'terminal.createAgentSession', + withAgentSessionCreateOperationId( + { + ...(await keyboardOptions(args.environmentId)), + worktree: toRuntimeWorktreeSelector(args.worktreeId), + agent: args.agent, + ...(args.prompt + ? { prompt: args.prompt, promptDelivery: 'auto-submit' as const } + : {}), + ...(launchPreferences ? { launchPreferences } : {}), + placement: { tabId: args.tabId, leafId: args.leafId }, + // Why: local renderer owns the hidden tab; remote runtime should not reveal UI. + presentation: 'background' + }, + clientOperationId + ), + { timeoutMs: 15_000 } + ) ), - { timeoutMs: 15_000 } - ) - ), legacy: ({ skipCompatibilityCheck }) => callRuntimeRpc<{ terminal: RuntimeTerminalCreate }>( { kind: 'environment', environmentId: args.environmentId }, diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index e721e61c474..577441162ff 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -120,7 +120,12 @@ function notifyHostUnreachable(agent: TuiAgent, executionHostId: ExecutionHostId /** What the caller's own terminal launch would have carried beyond the plan's agent and prompt. */ export type DeclinedStructuredLaunchTerminalOptions = Pick< LaunchAgentInNewTabArgs, - 'agentArgs' | 'launchPlatform' | 'launchSource' | 'initialCwd' | 'activate' + | 'agentArgs' + | 'sessionOptionsYieldToAgentArgs' + | 'launchPlatform' + | 'launchSource' + | 'initialCwd' + | 'activate' > /** A caller with no terminal path of its own gets the one a new agent tab would open. */ @@ -131,11 +136,13 @@ export async function openDeclinedStructuredLaunchTerminal(args: { terminal?: DeclinedStructuredLaunchTerminalOptions }): Promise { // Loaded late: the new-tab launcher begins these launches. - const { launchAgentInNewTab } = await import('@/lib/launch-agent-in-new-tab') - const result = launchAgentInNewTab({ + const { launchAgentInNewTabWithOutcome } = await import('@/lib/launch-agent-in-new-tab') + const result = launchAgentInNewTabWithOutcome({ ...args.terminal, agent: args.plan.agent, worktreeId: args.worktreeId, + // The saved choice the chat would have seeded applies to its terminal as flags. + ...(args.plan.launchOptions ? { sessionOptions: args.plan.launchOptions } : {}), ...(args.targetGroupId ? { groupId: args.targetGroupId } : {}), ...(args.terminal?.activate !== false && args.plan.prompt ? { prompt: args.plan.prompt } : {}), ...(args.plan.promptDelivery ? { promptDelivery: args.plan.promptDelivery } : {}), @@ -148,6 +155,9 @@ export async function openDeclinedStructuredLaunchTerminal(args: { worktreeId: args.worktreeId }) }) + if (result && 'refused' in result) { + return { opened: false, promptDeliveryResult: Promise.resolve(result.refused) } + } return { opened: result !== null, ...(result?.surface.kind === 'local-terminal' ? { tabId: result.surface.tabId } : {}), diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index b85f7ffaf41..078497c08ce 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentLaunchReceipt } from './agent-session/launch/structured-agent-session-launch-recovery' import type { StructuredLaunchSurfaceAddress } from './agent-session/launch/structured-agent-session-launch-record' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' import type { @@ -26,6 +27,8 @@ export type StructuredAgentLaunchOptions = { executionHostId?: ExecutionHostId /** The saved selection a paired host reported it will seed; read only by the starting caller. */ hostSeedOptions?: Readonly> + /** Explicit encoded action input, captured durably before any asynchronous preparation. */ + launchOptions?: Readonly> /** Final admission of a source-bound action, immediately before its first create is sent. */ beforeCreate?: () => boolean surfaceAddress?: StructuredLaunchSurfaceAddress @@ -71,7 +74,7 @@ function trackPromptDelivery( export function addStructuredLaunchCaller(args: { group: StructuredLaunchCallerGroup - launchResult: Promise<{ sessionId: string; fence: number }> + launchResult: Promise target: RuntimeClientTarget options: StructuredAgentLaunchOptions stagedPrompt: StagedStructuredLaunchPrompt | null diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index 6fd621d6573..6504ba8af3f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -11,6 +11,7 @@ import { readAgentSessionRefusalReference } from '../../../shared/agent-session- import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' import { callStructuredAgentSession, + supportsStructuredAgentSessionCreateOptionsMode, supportsStructuredAgentSessionCreateMessage } from '@/runtime/structured-agent-session-client' import type { StructuredAgentSessionCreateParams } from '../../../shared/structured-agent-session-create' @@ -117,14 +118,19 @@ export async function launchStructuredAgentSession( await requireHostPermission(intent) if (!intent.createPrepared) { let supported = false + let takesExplicitOptions = false try { supported = await supportsStructuredAgentSessionCreateMessage(intent.target) + takesExplicitOptions = + supported && + intent.explicitLaunchOptions !== undefined && + (await supportsStructuredAgentSessionCreateOptionsMode(intent.target)) } catch { // Unknown capability stays on the compatible create path. } if (!intent.createPrepared) { intent.createMessageSupport = supported - intent.prepareCreate?.() + intent.prepareCreate?.(takesExplicitOptions) intent.createPrepared = true } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index c9c162e6525..419a4b732f6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => { return { call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), supports: vi.fn<() => Promise>(), + supportsMode: vi.fn<() => Promise>(), admit: vi.fn<() => Promise>(), inventory: vi.fn(), settings @@ -30,6 +31,7 @@ vi.mock('../../../shared/layout-owner-switch', () => ({ vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn(async () => ({})) })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionCreateOptionsMode: mocks.supportsMode, supportsStructuredAgentSessionCreateMessage: mocks.supports })) vi.mock('./structured-agent-session-host-admission', () => ({ @@ -62,7 +64,10 @@ import { } from './structured-agent-session-launch' import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' -import { resetStructuredAgentLaunchRegistryForTests } from './agent-session/launch/structured-agent-session-launch-registry' +import { + resetStructuredAgentLaunchRegistryForTests, + getStructuredLaunchStateBySessionId +} from './agent-session/launch/structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests, readStructuredAgentLaunchRecord @@ -76,7 +81,10 @@ import { resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner' import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' -import { getStructuredAgentSessionPendingSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { + getStructuredAgentSessionPendingSends, + getStructuredAgentSessionSendNotice +} from '@/components/native-chat/structured-agent-session-pending-sends' import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' import { clearNativeChatDraftCacheForTests, @@ -146,6 +154,39 @@ const start = () => const draft = (sessionId: string) => readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(sessionId)) +// An older host (no create-message) applies an action's explicit options one setOption at a time. +function startLegacyActionLaunch( + launchOptions: Record, + refusedKey: string, + refusal: Record +) { + mocks.supports.mockResolvedValue(false) + mocks.call.mockImplementation(async (_target, method, params) => { + if (method === 'agentSession.create') { + return created() + } + if (method === 'agentSession.setOption') { + const key = params && typeof params === 'object' && 'key' in params ? params.key : null + return key === refusedKey + ? { ok: false, refusal } + : { ok: true, value: { options: { model: launchOptions.model } } } + } + const send = SendParams.parse(params) + const clientMessageId = send.envelope.clientOperationId + return { ok: true, value: { clientMessageId, submission: submission(clientMessageId) } } + }) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions + }) + mocks.inventory.mockResolvedValue([ + { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } + ]) + return launch +} + describe('the first message owned by create', () => { beforeEach(() => { vi.useFakeTimers() @@ -158,6 +199,7 @@ describe('the first message owned by create', () => { resetStructuredAgentSessionReadOwnersForTests() clearNativeChatDraftCacheForTests() mocks.supports.mockResolvedValue(true) + mocks.supportsMode.mockResolvedValue(true) mocks.admit.mockResolvedValue({ kind: 'admitted' }) mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) }) @@ -243,6 +285,80 @@ describe('the first message owned by create', () => { } ) + it.each(['codex', 'claude'] as const)( + 'creates %s on an unlisted explicit model without importing saved effort', + async (agent) => { + mocks.settings = { + nativeChatSessionOptions: { + [agent]: { model: 'saved-model', valuesByModel: { 'saved-model': { effort: 'low' } } } + } + } + mocks.call.mockImplementation(async () => created()) + try { + const launch = startStructuredAgentLaunch('workspace-1', agent, { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions: { model: 'unlisted-action-model' } + }) + expect(readStructuredAgentLaunchRecord(launch.sessionId)?.explicitLaunchOptions).toEqual({ + model: 'unlisted-action-model' + }) + expect(getStructuredLaunchStateBySessionId(launch.sessionId)?.selection.held).toEqual({}) + await launch.launchResult + expect(createParams().optionsMode).toBe('replace-model-selection') + expect(createParams().options).toEqual({ + model: 'unlisted-action-model', + permissionMode: 'ask' + }) + } finally { + mocks.settings = {} + } + } + ) + + it.each([ + ['a permission-only action leaves the remembered model', undefined, undefined], + [ + 'a model picked during launch keeps the action mode', + 'picked-model', + 'replace-model-selection' + ] + ])('%s', async (_name, picked, optionsMode) => { + mocks.call.mockImplementation(async () => created()) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions: picked + ? { model: 'action-model', effort: 'high', permissionMode: 'full-access' } + : { permissionMode: 'full-access' } + }) + if (picked) { + holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', picked) + } + await launch.launchResult + expect(createParams().optionsMode).toBe(optionsMode) + expect(createParams().options).toEqual({ + permissionMode: 'full-access', + ...(picked ? { model: picked } : {}) + }) + }) + + it('leaves a host without options-mode on its own new-chat permission mode', async () => { + mocks.supportsMode.mockResolvedValue(false) + mocks.call.mockImplementation(async () => created()) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions: { permissionMode: 'full-access' } + }) + await launch.launchResult + expect(createParams().options).toBeUndefined() + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + }) + it('carries a held tier as the picker draws it, keeps the pick through a refusal, and settles it once', async () => { const tiers = (...values: string[]) => values.map((value) => ({ value, label: value })) const efforts = tiers('medium', 'high') @@ -366,7 +482,12 @@ describe('the first message owned by create', () => { } } }) - const launch = start() + const launch = startStructuredAgentLaunch('workspace-1', 'claude', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions: { fastMode: 'true' } + }) mocks.inventory.mockResolvedValue([ { worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] } ]) @@ -380,6 +501,54 @@ describe('the first message owned by create', () => { ]) }) + it('a generated legacy effort refusal does not stop the opening prompt', async () => { + const launch = startLegacyActionLaunch({ model: 'gpt-5.6-sol' }, 'effort', { + code: 'agent_session_operation_invalid', + message: 'unavailable' + }) + const receipt = await launch.launchResult + expect(receipt.configurationFailure).toBeUndefined() + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect( + mocks.call.mock.calls + .filter(([, method]) => method === 'agentSession.setOption') + .map(([, , params]) => params) + ).toEqual([ + expect.objectContaining({ key: 'model', value: 'gpt-5.6-sol' }), + expect.objectContaining({ key: 'effort', value: 'medium' }) + ]) + expect(createParams()).not.toHaveProperty('options') + expect(draft(launch.sessionId)).toBe('') + }) + + it.each(['model', 'effort'])( + 'keeps the legacy chat and text when required %s is refused, with no fresh Retry', + async (refused) => { + const launch = startLegacyActionLaunch({ model: 'action-model', effort: 'high' }, refused, { + code: 'agent_session_operation_invalid', + details: { reason: 'optionRejected' }, + message: 'unavailable' + }) + const receipt = await launch.launchResult + expect(receipt).toMatchObject({ sessionId: launch.sessionId, configurationFailure: {} }) + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ + delivered: false, + inComposer: true, + failureNotified: true + }) + expect(draft(launch.sessionId)).toBe('opening text') + expect(getStructuredAgentSessionSendNotice(launch.sessionId)).toContain('Codex') + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send')).toEqual( + [] + ) + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(false) + expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create') + ).toHaveLength(1) + } + ) + it('keeps the older host acquisition wait outside the send deadline', async () => { const create = deferred() mocks.supports.mockResolvedValue(false) @@ -488,9 +657,21 @@ describe('the first message owned by create', () => { it('recovers a reload by replaying the frozen create when the old row is outside history', async () => { const create = deferred() mocks.call.mockReturnValue(create.promise) - const launch = start() + mocks.supportsMode.mockResolvedValue(false) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local', + prompt: 'opening text', + launchOptions: { model: 'gpt-5.6-sol' } + }) await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const original = createParams() + expect(original.optionsMode).toBeUndefined() + expect(original.options?.effort).toBe('medium') + expect(readStructuredAgentLaunchRecord(launch.sessionId)?.explicitLaunchOptions).toEqual({ + model: 'gpt-5.6-sol' + }) + mocks.supportsMode.mockResolvedValue(true) expect(readStructuredAgentLaunchRecord(launch.sessionId)?.firstMessage).toEqual( original.firstMessage ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 1fe03c268d3..6076f4db32c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -1,4 +1,10 @@ +import { + structuredLaunchCompatibleOptions, + structuredLaunchRequestedOptions +} from '../../../shared/native-chat-session-option-defaults' import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { StructuredAgentSessionCreateParams } from '../../../shared/structured-agent-session-create' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import type { StructuredLaunchState } from './agent-session/launch/structured-agent-session-launch-registry' import { notifyStructuredLaunchListeners } from './agent-session/launch/structured-agent-session-launch-registry' import { @@ -13,18 +19,31 @@ import { import { firstFrameOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' import { applyStructuredLaunchHeldOptions, + applyStructuredLaunchExplicitOptions, holdStructuredLaunchCreatePicks, settleStructuredLaunchCreateOptions } from './structured-agent-session-launch-options' import { structuredAgentSessionDrawnValues } from '../../../shared/structured-agent-session-option-snapshot' import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' -/** Freeze what create carries once; an ambiguous reply always replays the same payload. */ -export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): void { +/** Freeze what create carries once; an ambiguous reply always replays the same payload. + * `hostTakesExplicitOptions`: the host advertises the create options-mode capability. */ +export function prepareStructuredLaunchCreateMessage( + state: StructuredLaunchState, + hostTakesExplicitOptions: boolean +): void { const { intent } = state if (intent.createPrepared) { return } + if (!hostTakesExplicitOptions) { + omitStructuredLaunchPermissionMode(state) + } + // A permission mode alone overlays the host's remembered model selection. + const optionsMode: StructuredAgentSessionCreateParams['optionsMode'] = + hostTakesExplicitOptions && state.selection.explicit?.model !== undefined + ? 'replace-model-selection' + : undefined if (intent.createMessageSupport !== true) { prepareLegacyStructuredLaunchPrompts(intent.sessionId) intent.createPrepared = true @@ -32,11 +51,16 @@ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchStat return } const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) - const options = structuredLaunchCreateOptions(state) + const requested = structuredLaunchCreateOptions(state) + const options = + requested && state.selection.explicit && !optionsMode + ? structuredLaunchCompatibleOptions(intent.agent, requested) + : requested const params = { ...intent.params, ...(firstMessage ? { firstMessage } : {}), - ...(options ? { options } : {}) + ...(options ? { options } : {}), + ...(optionsMode ? { optionsMode } : {}) } intent.params = { ...params, @@ -52,11 +76,26 @@ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchStat notifyStructuredLaunchListeners() } -// Apply held picks and chat model/effort to the opening turn, matching ordinary sends. +/** Hosts before options-mode may refuse an action's mode (older permission tables): the chat takes + * the host's new-chat default, as a phone launch does, and no later setOption re-applies it. */ +function omitStructuredLaunchPermissionMode(state: StructuredLaunchState): void { + const explicit = state.intent.explicitLaunchOptions + if (explicit?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] === undefined) { + return + } + const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: _omitted, ...rest } = explicit + const kept = Object.keys(rest).length ? rest : undefined + state.intent.explicitLaunchOptions = kept + state.selection = { ...state.selection, explicit: kept } +} + +/** Picks held so far, the launch's own permission mode, and the chat's own model and effort: a + * first message starts its turn on them, as a later send does. */ function structuredLaunchCreateOptions( state: StructuredLaunchState ): Readonly> | undefined { const { intent, selection } = state + const requested = structuredLaunchRequestedOptions(selection) const firstFrame = firstFrameOptionState(intent.agent, intent.target, { newLaunch: !intent.params.resumeFrom, worktree: intent.params.worktree, @@ -74,8 +113,12 @@ function structuredLaunchCreateOptions( }) ) holdStructuredLaunchCreatePicks(state, selection.held) + if (requested?.model !== undefined) { + // The view draws under a re-picked model only; an action's own model keeps picks as made. + return requested.model === selection.held.model ? { ...requested, ...held } : requested + } const options = { - ...held, + ...(requested ? { ...requested, ...held } : held), ...structuredAgentSessionSendSelection(view) } return Object.keys(options).length ? options : undefined @@ -87,7 +130,7 @@ export function publishStructuredLaunchCreateOptions( receipt: StructuredAgentLaunchReceipt ): StructuredAgentLaunchReceipt | Promise { if (state.intent.createMessageSupport !== true) { - return applyStructuredLaunchHeldOptions(state, receipt) + return applyStructuredLaunchExplicitOptions(state, receipt) } settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index f62e5937320..939471b69b9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -3,7 +3,11 @@ import type { AgentSessionMutationResult, AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { STRUCTURED_LAUNCH_OPTION_IDS } from '../../../shared/native-chat-session-option-defaults' +import { + STRUCTURED_LAUNCH_OPTION_IDS, + structuredLaunchCompatibleOptions, + structuredLaunchRequestedOptions +} from '../../../shared/native-chat-session-option-defaults' import { createStructuredAgentSessionOperationId, structuredAgentSessionPayloadFingerprint @@ -37,6 +41,8 @@ import { export type StructuredLaunchSelection = { /** What the host runs as far as the launch knows: the create seed, then each accepted pick. */ seed?: Readonly> + /** Read-only projection of durable action input; held contains only subsequent edits. */ + explicit?: Readonly> /** Encoded picks made before publish; the launch applies them before it publishes. */ held: Readonly> /** A paired server has not yet said what create seeds, so nothing can be picked against it. */ @@ -48,6 +54,7 @@ export function initialStructuredLaunchSelection( ): StructuredLaunchSelection { return { seed: intent.seedOptions, + ...(intent.explicitLaunchOptions ? { explicit: intent.explicitLaunchOptions } : {}), held: {}, ...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {}) } @@ -155,6 +162,7 @@ function settleHeldOption( // A pick made while this one was in flight superseded it and stays held for the next pass. const settled = current === encoded state.selection = { + explicit: state.selection.explicit, seed: outcome.kind === 'accepted' ? { ...seed, ...outcome.options } : seed, held: settled ? rest : state.selection.held } @@ -167,6 +175,47 @@ function settleHeldOption( state.callers.onSettled() } +/** Old hosts configure the known chat before submitting; Fast keeps saved-preference admission. */ +export async function applyStructuredLaunchExplicitOptions( + state: StructuredLaunchState, + receipt: StructuredAgentLaunchReceipt +): Promise { + const requested = structuredLaunchRequestedOptions({ + explicit: state.selection.explicit, + held: state.selection.held + }) + if (!requested) { + return applyStructuredLaunchHeldOptions(state, receipt) + } + const compatible = structuredLaunchCompatibleOptions(state.intent.agent, requested) + const entries = Object.entries(compatible).sort(([a], [b]) => + a === 'model' ? -1 : b === 'model' ? 1 : 0 + ) + for (const [id, encoded] of entries) { + if (state.cancelled) { + throw new StructuredAgentSessionLaunchCancelledError() + } + if (id === 'fastMode' && state.selection.held[id] === undefined) { + continue + } + const outcome = await setLaunchOption( + state.intent.target, + receipt.sessionId, + receipt.fence, + id, + encoded + ) + settleHeldOption(state, id, encoded, outcome) + if (outcome.kind === 'refused' && requested[id] !== undefined) { + for (const [key, value] of Object.entries(state.selection.held)) { + settleHeldOption(state, key, value, outcome) + } + return { ...receipt, configurationFailure: outcome.failure } + } + } + return applyStructuredLaunchHeldOptions(state, receipt) +} + const createHeld = new WeakMap>>() /** The held picks this create attempt carries, in whatever form it carries them. */ diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 1674c7df4fe..428f9df5fd0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -29,6 +29,7 @@ export type StructuredPromptDeliveryResult = { type StagedDelivery = Omit & { inComposer: boolean + failureNotified?: boolean } export type StructuredLaunchPromptOptions = { @@ -251,6 +252,21 @@ export function settleStructuredAgentLaunchPrompt(args: { if (!prompt || prompt.discarded) { return { delivered: false, failureNotified: true } } + if (receipt.configurationFailure) { + const result = { + delivered: false, + inComposer: handBackStagedPrompt(prompt), + failureNotified: true + } + prompt.delivery = Promise.resolve(result) + prompt.settleCreate(result) + unstage(prompt) + prompt.discarded = true + return { + ...result, + ...(result.inComposer ? { inComposer: true as const } : { inComposer: undefined }) + } + } if (prompt.createOwned) { prompt.createSend?.settle( receipt.firstMessage @@ -279,7 +295,7 @@ export function settleStructuredAgentLaunchPrompt(args: { : { kind: 'unconfirmed' } ) } - // The chat never started: its text waits in the chat's composer for the person's own Send. + // The launch did not submit: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { unstage(prompt) prompt.discarded = true @@ -293,7 +309,7 @@ export function settleStructuredAgentLaunchPrompt(args: { settled, prompt.whenCreateSettled.then(({ inComposer, ...outcome }) => ({ ...outcome, - failureNotified: false, + failureNotified: outcome.failureNotified ?? false, ...(inComposer ? { inComposer: true as const } : {}) })), prompt.whenDiscarded.then((): StructuredPromptDeliveryResult => ({ diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 49611bedfdb..0b72ba3203f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -105,7 +105,8 @@ function publishWithHeldOptions( state: StructuredLaunchState, created: Promise ): Promise { - state.intent.prepareCreate = () => prepareStructuredLaunchCreateMessage(state) + state.intent.prepareCreate = (hostTakesExplicitOptions) => + prepareStructuredLaunchCreateMessage(state, hostTakesExplicitOptions) return created.then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) } @@ -217,6 +218,7 @@ function structuredAgentLaunchState( options.hostSeedOptions, options.activate ) + intent.explicitLaunchOptions = options.launchOptions ? { ...options.launchOptions } : undefined intent.beforeCreate = options.beforeCreate intent.surfaceAddress = options.surfaceAddress const text = launchPromptText(options) diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 67ac05912cb..1bd6ccd7b37 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -22,7 +22,10 @@ import { STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' -import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../shared/agent-session-create-capabilities' +import { + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_OPTIONS_MODE_RUNTIME_CAPABILITY +} from '../../../shared/agent-session-create-capabilities' import { callRuntimeRpc, runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { @@ -55,6 +58,15 @@ export function supportsStructuredAgentSessionPromptCancel( return structuredAgentSessionHostSupports(target, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY) } +export function supportsStructuredAgentSessionCreateOptionsMode( + target: RuntimeClientTarget +): Promise { + return structuredAgentSessionHostSupports( + target, + AGENT_SESSION_CREATE_OPTIONS_MODE_RUNTIME_CAPABILITY + ) +} + export function supportsStructuredAgentSessionCreateMessage( target: RuntimeClientTarget ): Promise { diff --git a/src/shared/agent-launch-intent.ts b/src/shared/agent-launch-intent.ts index 8f1018202e5..20a15af56c5 100644 --- a/src/shared/agent-launch-intent.ts +++ b/src/shared/agent-launch-intent.ts @@ -40,6 +40,7 @@ export type AgentLaunchIntent = { prompt?: AgentLaunchPrompt /** Structured create options also supply terminal model/effort/mode preferences. */ sessionOptions?: Readonly> + chatOptions?: Readonly> reuseTerminal?: AgentLaunchReusedTerminal /** Tri-state arguments: absent uses defaults, null explicitly clears them. Overrides remain terminal-only; structured chat uses host settings and reports a warning. */ agentArgs?: string | null diff --git a/src/shared/agent-launch-operation.test.ts b/src/shared/agent-launch-operation.test.ts index 1fde4572bcb..080d21e1ab5 100644 --- a/src/shared/agent-launch-operation.test.ts +++ b/src/shared/agent-launch-operation.test.ts @@ -22,6 +22,17 @@ function fingerprintOfWirePayload( } describe('fields the launch fingerprint covers', () => { + it('covers encoded chat choices while omission keeps the previous digest', () => { + expect( + computeAgentLaunchFingerprint({ ...BASE, chatOptions: { model: 'opus', fastMode: 'false' } }) + ).not.toBe(computeAgentLaunchFingerprint(BASE)) + expect( + computeAgentLaunchFingerprint({ ...BASE, chatOptions: { model: 'opus', fastMode: 'false' } }) + ).not.toBe( + computeAgentLaunchFingerprint({ ...BASE, chatOptions: { model: 'opus', fastMode: 'true' } }) + ) + }) + it('separates two launches that differ only in agentArgs', () => { expect(computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model opus' })).not.toBe( computeAgentLaunchFingerprint({ ...BASE, agentArgs: '--model sonnet' }) diff --git a/src/shared/agent-launch-operation.ts b/src/shared/agent-launch-operation.ts index ad70e876abb..791a273c53b 100644 --- a/src/shared/agent-launch-operation.ts +++ b/src/shared/agent-launch-operation.ts @@ -12,6 +12,7 @@ export type AgentLaunchFingerprintInput = { | { kind: 'create-folder-workspace'; create: Readonly> } prompt?: { text: string; delivery: string } sessionOptions?: Readonly> + chatOptions?: Readonly> reuseTerminal?: { handle: string } /** Arguments change operation identity; null explicitly clears defaults and differs from absence. */ agentArgs?: string | null @@ -34,6 +35,7 @@ export function computeAgentLaunchFingerprint(input: AgentLaunchFingerprintInput target: input.target, prompt: input.prompt, sessionOptions: input.sessionOptions, + chatOptions: input.chatOptions, reuseTerminal: input.reuseTerminal, agentArgs: input.agentArgs, cwd: input.cwd, diff --git a/src/shared/agent-launch-preferences.test.ts b/src/shared/agent-launch-preferences.test.ts index 90553a8bdd7..2555233dc25 100644 --- a/src/shared/agent-launch-preferences.test.ts +++ b/src/shared/agent-launch-preferences.test.ts @@ -1,22 +1,53 @@ import { describe, expect, it } from 'vitest' import { toAgentLaunchPreferences } from './agent-launch-preferences' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { + decodeCatalogOptionValue, + encodeCatalogOptionValue +} from './structured-agent-session-option-codec' describe('toAgentLaunchPreferences', () => { - it('keeps only supported string launch preferences', () => { - expect( - toAgentLaunchPreferences({ - model: ' gpt-5 ', - effort: 'high', - mode: 'plan', - fastMode: true - }) - ).toEqual({ model: 'gpt-5', effort: 'high', mode: 'plan' }) + it('keeps the strict preferences, with a mode only where the agent has a terminal flag', () => { + const options = { model: ' gpt-5 ', effort: 'high', permissionMode: 'ask', fastMode: true } + expect(toAgentLaunchPreferences(options, 'codex')).toEqual({ + model: 'gpt-5', + effort: 'high', + mode: 'ask' + }) + // OpenCode's terminal has no permission flag; a released host refuses any option it gets. + expect(toAgentLaunchPreferences({ permissionMode: 'ask' }, 'opencode')).toBeUndefined() + expect(toAgentLaunchPreferences({ permissionMode: 'own' }, 'opencode')).toBeUndefined() + expect(toAgentLaunchPreferences({ permissionMode: 'own' }, 'codex')).toEqual({ mode: 'own' }) + expect(toAgentLaunchPreferences({ permissionMode: 'own' }, 'goose')).toEqual({ mode: 'own' }) + expect(toAgentLaunchPreferences({ permissionMode: 'ask' }, 'antigravity')).toBeUndefined() }) it('answers nothing when no preference survives', () => { - expect( - toAgentLaunchPreferences({ model: ' ', effort: 123, mode: false, fastMode: true }) - ).toBeUndefined() - expect(toAgentLaunchPreferences(undefined)).toBeUndefined() + expect(toAgentLaunchPreferences({ model: ' ', fastMode: true }, 'codex')).toBeUndefined() + expect(toAgentLaunchPreferences(undefined, 'codex')).toBeUndefined() + }) +}) + +describe('a saved boolean option across the wire', () => { + it('round-trips Cursor Thinking into the composed terminal model', () => { + const thinking = getAgentSessionOptionCatalog('cursor')! + .models.find((model) => model.id === 'claude-opus-4-8')! + .options.find((option) => option.id === 'thinking')! + const saved = encodeCatalogOptionValue(thinking.kind, true) + expect(saved).toBe('true') + expect(decodeCatalogOptionValue(thinking.kind, saved!)).toBe(true) + const options = { model: 'claude-opus-4-8', thinking: saved!, effort: 'high' } + expect(toAgentLaunchPreferences(options, 'cursor')?.model).toBe('claude-opus-4-8-thinking-high') + expect(resolveAgentSessionOptionLaunch('cursor', options).args).toEqual([ + '--model', + 'claude-opus-4-8-thinking-high' + ]) + }) + + it('carries a saved permission mode on the existing mode key', () => { + expect(toAgentLaunchPreferences({ permissionMode: 'full-access' }, 'claude')).toEqual({ + mode: 'full-access' + }) }) }) diff --git a/src/shared/agent-launch-preferences.ts b/src/shared/agent-launch-preferences.ts index 8f898f2a784..439366dd99a 100644 --- a/src/shared/agent-launch-preferences.ts +++ b/src/shared/agent-launch-preferences.ts @@ -1,23 +1,41 @@ +import { YOLO_TUI_AGENT_ENV } from './tui-agent-permissions' import type { AgentLaunchPreferences } from './agent-session-host-authority' +import type { AgentType } from './agent-status-types' +import { + readLaunchPermissionChoice, + agentPermissionHasTerminalChoice +} from './tui-agent-permission-choice' +import { isTuiAgent } from './tui-agent-config' +import { composeAgentLaunchModel } from './agent-session-option-launch' -/** Share model/effort/mode conversion so structured and terminal launches read identical preferences. */ +/** Compose model/effort for this terminal; send permission mode only where the agent has a flag. */ export function toAgentLaunchPreferences( - sessionOptions: Readonly> | null | undefined + sessionOptions: Readonly> | null | undefined, + agent: AgentType ): AgentLaunchPreferences | undefined { if (!sessionOptions) { return undefined } - const readString = (key: keyof AgentLaunchPreferences): string | undefined => { - const value = sessionOptions[key] - return typeof value === 'string' && value.trim() ? value.trim() : undefined - } - const model = readString('model') - const effort = readString('effort') - const mode = readString('mode') + const model = composeAgentLaunchModel(agent, sessionOptions) + const effort = + typeof sessionOptions.effort === 'string' && sessionOptions.effort.trim() + ? sessionOptions.effort.trim() + : undefined + const mode = readLaunchPermissionChoice(sessionOptions) const preferences: AgentLaunchPreferences = { ...(model ? { model } : {}), ...(effort ? { effort } : {}), - ...(mode ? { mode } : {}) + ...(mode && isTuiAgent(agent) && agentPermissionHasTerminalChoice(agent, mode) ? { mode } : {}) } return Object.keys(preferences).length > 0 ? preferences : undefined } + +/** Authority v1 cannot carry a composed per-launch permission environment. */ +export function agentPermissionNeedsPreparedCommand( + agent: AgentType, + applied: Readonly> | null | undefined +): boolean { + return ( + Object.hasOwn(YOLO_TUI_AGENT_ENV, agent) && readLaunchPermissionChoice(applied) !== undefined + ) +} diff --git a/src/shared/agent-launch-runtime-capability.ts b/src/shared/agent-launch-runtime-capability.ts index e3ce3cf5c32..dd517b361f4 100644 --- a/src/shared/agent-launch-runtime-capability.ts +++ b/src/shared/agent-launch-runtime-capability.ts @@ -39,6 +39,9 @@ export const AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY = 'agent.launch.tab-close export const AGENT_LAUNCH_SETTINGS_CAPABILITY = 'settings.agent-launch.v1' as const +// Host applies `chatOptions` to whichever surface it opens: seeded on a chat, flags on a terminal. +export const AGENT_LAUNCH_CHAT_OPTIONS_RUNTIME_CAPABILITY = 'agent.launch.chat-options.v1' as const + // Host accepts scoped terminal permission mutations, including Auto. export const AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY = 'settings.agent-launch.auto-permissions.v1' as const @@ -56,6 +59,7 @@ export const AGENT_LAUNCH_PERMISSION_CHOICE_CAPABILITY = 'settings.agent-permiss export const AGENT_LAUNCH_SURFACE_RUNTIME_CAPABILITY = 'agent.launch.surface.v1' as const export const AGENT_LAUNCH_RUNTIME_CAPABILITIES = [ + AGENT_LAUNCH_CHAT_OPTIONS_RUNTIME_CAPABILITY, AGENT_LAUNCH_SETTINGS_CAPABILITY, AGENT_LAUNCH_AUTO_PERMISSIONS_CAPABILITY, AGENT_LAUNCH_NATIVE_CHAT_PERMISSIONS_CAPABILITY, diff --git a/src/shared/agent-session-create-capabilities.ts b/src/shared/agent-session-create-capabilities.ts index cec66a663e3..f2877c02f30 100644 --- a/src/shared/agent-session-create-capabilities.ts +++ b/src/shared/agent-session-create-capabilities.ts @@ -15,6 +15,11 @@ export const AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY = // Terminal create/ensure are strict: send accountEnv only to hosts advertising support. export const AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY = 'agent-session.account-env.v1' as const +// Why: `agentSession.create` is a strict object, so an older host refuses `optionsMode`. A host +// advertising this replaces a chat's model selection with explicit launch options. +export const AGENT_SESSION_CREATE_OPTIONS_MODE_RUNTIME_CAPABILITY = + 'agentSession.create.options-mode.v1' as const + // Send is strict: advertise skills before sending selected paths; name-based agents still use the $name text. export const AGENT_SESSION_SEND_SKILLS_RUNTIME_CAPABILITY = 'agent-session.send-skills.v1' as const @@ -29,6 +34,7 @@ export const AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES = [ AGENT_SESSION_SEND_USAGE_LIMIT_CONTINUATION_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_OPTIONS_MODE_RUNTIME_CAPABILITY, AGENT_SESSION_CREATE_TAB_PLACEMENT_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY, AGENT_SESSION_ACCOUNT_ENV_RUNTIME_CAPABILITY, diff --git a/src/shared/agent-session-option-agent-args.ts b/src/shared/agent-session-option-agent-args.ts index d1885531d5f..26b585f4617 100644 --- a/src/shared/agent-session-option-agent-args.ts +++ b/src/shared/agent-session-option-agent-args.ts @@ -77,12 +77,13 @@ export function removeAgentArgOption( agent: AgentType, tokens: readonly string[], aliases: readonly string[], - matchesValue: (value: string | undefined) => boolean = () => true + matchesValue: (value: string | undefined) => boolean = () => true, + takesValue = true ): string[] { const kept: string[] = [] const required = agentRequiredValueOptions(agent) let cursor = 0 - let found = findOptionOccurrence(tokens, aliases, true, required, cursor) + let found = findOptionOccurrence(tokens, aliases, true, required, cursor, takesValue) while (found) { const end = found.index + found.consumed const keptEnd = matchesValue(found.value) ? found.index : end @@ -90,7 +91,7 @@ export function removeAgentArgOption( kept.push(tokens[index]) } cursor = end - found = findOptionOccurrence(tokens, aliases, true, required, cursor) + found = findOptionOccurrence(tokens, aliases, true, required, cursor, takesValue) } for (let index = cursor; index < tokens.length; index += 1) { kept.push(tokens[index]) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index f5c1032ec06..0f775b99858 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -20,7 +20,7 @@ function removeCodexAgentArgs( 'codex', removeAgentArgOption('codex', tokens, flags), ['-c', '--config'], - (value) => value?.startsWith(`${configKey}=`) === true + (value) => new RegExp(`^\\s*${configKey}\\s*=`).test(value ?? '') ) } diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index e8c972385bd..37935852bd2 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -3,12 +3,24 @@ import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session- import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode' import type { AgentSessionOptionCatalog, + CatalogModel, + CatalogOption, CatalogOptionApply } from './agent-session-option-catalog-types' import type { SessionOptionValue } from './native-chat-session-options' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' +import { agentPermissionArgsForLaunch } from './tui-agent-launch-defaults' +import { + readLaunchPermissionChoice, + agentPermissionHasTerminalChoice +} from './tui-agent-permission-choice' +import { isTuiAgent } from './tui-agent-config' +import { quoteStartupArg, tokenizeStartupCommand } from './tui-agent-startup-shell' +import { decodeCatalogOptionValue } from './structured-agent-session-option-codec' export type ResolvedSessionOptionLaunch = { args: string[] + permissionArgs?: readonly string[] appliedValues: Record } @@ -28,15 +40,16 @@ export function removeOverriddenAgentSessionArgs( values: Record | null | undefined, tokens: readonly string[] ): string[] { + let result = [...tokens] const catalog = getAgentSessionOptionLaunchCatalog(agent) - const modelId = typeof values?.model === 'string' ? values.model : null - if (!catalog || !values || !modelId) { - return [...tokens] + if (!catalog || !values) { + return result } - let result = catalog.modelApply.removeAgentArgs?.(tokens) ?? [...tokens] - const model = findCatalogModel(catalog, modelId) - const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] - for (const option of modelOptions) { + const modelId = typeof values.model === 'string' ? values.model : null + if (modelId) { + result = catalog.modelApply.removeAgentArgs?.(result) ?? result + } + for (const option of launchOptionsFor(catalog, modelId).options) { if (values[option.id] !== undefined && option.apply.removeAgentArgs) { result = option.apply.removeAgentArgs(result) } @@ -44,45 +57,71 @@ export function removeOverriddenAgentSessionArgs( return result } +/** Without a model, the catalog's model-independent options still apply; nothing composes. */ +function launchOptionsFor( + catalog: AgentSessionOptionCatalog, + modelId: string | null +): { model: CatalogModel | null; options: CatalogOption[] } { + const model = (modelId ? findCatalogModel(catalog, modelId) : null) ?? null + return { model, options: model?.options ?? catalog.unknownModelOptions ?? [] } +} + export function resolveAgentSessionOptionLaunch( agent: AgentType, values: Record | null | undefined, trailingAgentArgs: readonly string[] = [], - includeCatalogDefaults = true + includeCatalogDefaults = true, + permissionTrailingArgs = trailingAgentArgs +): ResolvedSessionOptionLaunch { + const resolved = resolveModelOptionLaunch( + agent, + values, + trailingAgentArgs, + includeCatalogDefaults + ) + const permission = readLaunchPermissionChoice(values) + if (!permission || !isTuiAgent(agent) || !agentPermissionHasTerminalChoice(agent, permission)) { + return resolved + } + const userArgs = permissionTrailingArgs.map((token) => quoteStartupArg(token, 'posix')).join(' ') + const text = agentPermissionArgsForLaunch(agent, permission, userArgs) + if (text === null) { + return resolved + } + const parsed = tokenizeStartupCommand(text, 'posix') + const permissionArgs = parsed.ok ? parsed.tokens : [] + return { + args: [...resolved.args, ...permissionArgs], + permissionArgs, + appliedValues: { ...resolved.appliedValues, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permission } + } +} + +function resolveModelOptionLaunch( + agent: AgentType, + values: Record | null | undefined, + trailingAgentArgs: readonly string[], + includeCatalogDefaults: boolean ): ResolvedSessionOptionLaunch { const catalog = getAgentSessionOptionLaunchCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null - if (!catalog || !values || !modelId) { + if (!catalog || !values) { return { args: [], appliedValues: {} } } - const model = findCatalogModel(catalog, modelId) + const { model, options: modelOptions } = launchOptionsFor(catalog, modelId) const appliedValues: Record = {} const args: string[] = [] - const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] - const modelValues = Object.fromEntries( - modelOptions.flatMap((option) => { - const explicitValue = values[option.id] - if (explicitValue !== undefined) { - if ( - !model && - option.kind.type === 'select' && - !option.kind.choices.some((choice) => choice.value === explicitValue) - ) { - return [] - } - return [[option.id, explicitValue]] - } - return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : [] - }) - ) - const composedModelId = catalog.composeModelValue - ? catalog.composeModelValue(modelId, modelValues) - : modelId - const modelOverridden = isOverriddenByAgentArgs(catalog.modelApply, trailingAgentArgs) + const modelValues = launchModelValues(model, modelOptions, values, includeCatalogDefaults) + const modelOverridden = + modelId !== null && isOverriddenByAgentArgs(catalog.modelApply, trailingAgentArgs) + const appliesModel = Boolean(modelId && catalog.modelApply.launchArgs && !modelOverridden) // Why: a repeated model flag can prevent the CLI from starting. - if (catalog.modelApply.launchArgs && !modelOverridden) { + if (modelId && appliesModel && catalog.modelApply.launchArgs) { + const composedModelId = catalog.composeModelValue + ? catalog.composeModelValue(modelId, modelValues) + : modelId args.push(...catalog.modelApply.launchArgs(composedModelId)) appliedValues.model = modelId } @@ -92,7 +131,7 @@ export function resolveAgentSessionOptionLaunch( continue } if (option.apply.composedIntoModel) { - if (catalog.modelApply.launchArgs && !modelOverridden) { + if (appliesModel) { appliedValues[option.id] = value } continue @@ -110,3 +149,45 @@ export function resolveAgentSessionOptionLaunch( } return { args, appliedValues } } + +function launchModelValues( + model: CatalogModel | null, + options: readonly CatalogOption[], + values: Readonly>, + includeCatalogDefaults: boolean +): Record { + return Object.fromEntries( + options.flatMap((option) => { + const raw = values[option.id] + const explicitValue = decodeCatalogOptionValue( + option.kind, + typeof raw === 'string' || typeof raw === 'boolean' ? raw : undefined + ) + if (explicitValue !== undefined) { + if ( + !model && + option.kind.type === 'select' && + !option.kind.choices.some((choice) => choice.value === explicitValue) + ) { + return [] + } + return [[option.id, explicitValue]] + } + return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : [] + }) + ) +} + +/** The model id a launch passes, with the options its argv composes into it (Cursor) folded in. */ +export function composeAgentLaunchModel( + agent: AgentType, + values: Readonly> +): string | undefined { + const modelId = typeof values.model === 'string' ? values.model.trim() : '' + const catalog = getAgentSessionOptionLaunchCatalog(agent) + if (!modelId || !catalog?.composeModelValue) { + return modelId || undefined + } + const { model, options } = launchOptionsFor(catalog, modelId) + return catalog.composeModelValue(modelId, launchModelValues(model, options, values, false)) +} diff --git a/src/shared/agent-startup-plan-inputs-baseline-test-fixture.ts b/src/shared/agent-startup-plan-inputs-baseline-test-fixture.ts new file mode 100644 index 00000000000..7a09962625f --- /dev/null +++ b/src/shared/agent-startup-plan-inputs-baseline-test-fixture.ts @@ -0,0 +1,62 @@ +// Exact function from660243e61ff:agent-startup-plan-inputs.ts; only export/import paths differ. +// Full-source SHA256573ec1fd98d1859f983e625cd593a1e636a53eb67e43871168e0bfc14e84130c. +import type { AgentStartupSettings, AgentStartupPlanInputs } from './agent-startup-plan-inputs' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TuiAgent } from './tui-agent' +import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' +import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import { hasExtraAgentArgs, mergeExtraAgentArgs } from './automation-extra-agent-args' +import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell' + +export function resolveBaselineAgentStartupPlanInputs(args: { + agent: TuiAgent + settings: AgentStartupSettings + platform: NodeJS.Platform + isRemote: boolean + /** Replaces the configured default args for this launch; `null` is "no arguments". */ + agentArgs?: string | null + /** An automation's saved extras, merged over the resolved args; invalid extras throw. */ + extraAgentArgs?: string + /** Pass the actual PTY shell; omission is valid only when the Settings shell is what the PTY receives. */ + shell: AgentStartupShell | undefined + sessionOptions?: Record | undefined + /** Orca types into this launch after start (worker, automation, handoff), not a user. */ + suppressStartupUpdatePrompt?: boolean +}): AgentStartupPlanInputs { + const { agent, settings, platform, isRemote, sessionOptions } = args + // Test absence: launch overrides win and null explicitly means no arguments. + let agentArgs = + args.agentArgs !== undefined ? args.agentArgs : resolveTuiAgentLaunchArgs(agent, settings) + const shell = + args.shell ?? + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + if (hasExtraAgentArgs(args.extraAgentArgs)) { + const merged = mergeExtraAgentArgs({ + agent, + defaultArgs: agentArgs, + extraAgentArgs: args.extraAgentArgs, + shell: resolveStartupShell(platform, shell) + }) + if (!merged.ok) { + throw new Error(merged.error) + } + agentArgs = merged.agentArgs + } + return { + agent, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs, + agentEnv: resolveTuiAgentLaunchEnv(agent, settings, platform), + platform, + shell, + isRemote, + ...(sessionOptions ? { sessionOptions } : {}), + // Explicit session picks outrank configured args; duplicate flag spellings would otherwise let argv order decide. + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), + ...(args.suppressStartupUpdatePrompt ? { suppressStartupUpdatePrompt: true } : {}) + } +} diff --git a/src/shared/agent-startup-plan-inputs.spec.ts b/src/shared/agent-startup-plan-inputs.spec.ts index 15a6c7b88e4..63cf9bba648 100644 --- a/src/shared/agent-startup-plan-inputs.spec.ts +++ b/src/shared/agent-startup-plan-inputs.spec.ts @@ -194,3 +194,50 @@ describe('an Orca-driven launch skips the startup update prompt (#15814, #18153) expect(planFor('claude', true)?.launchCommand).toBe(planFor('claude', false)?.launchCommand) }) }) + +it('projects own permission without dropping unrelated arguments, model or environment on terminal fallback', () => { + const inputs = resolveAgentStartupPlanInputs({ + agent: 'codex', + settings: { + agentPermissionDefault: 'full-access', + agentDefaultArgs: { codex: '--search' }, + agentDefaultEnv: { codex: { KEEP: 'value' } } + }, + platform: 'linux', + isRemote: false, + shell: 'posix', + sessionOptions: { permissionMode: 'own', model: 'gpt-5.5' } + }) + const plan = buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }) + expect(plan?.launchCommand).toContain("'--search'") + expect(plan?.launchCommand).toContain("'-m' 'gpt-5.5'") + expect(plan?.launchCommand).not.toContain('bypass') + expect(plan?.env).toEqual({ KEEP: 'value' }) + expect(plan?.sessionOptions?.permissionMode).toBe('own') +}) + +it.each([undefined, ''])( + 'keeps a terminal-only choice from args %s and projects its flag once for a legacy launch', + (agentArgs) => { + const inputs = resolveAgentStartupPlanInputs({ + agent: 'gemini', + settings: {}, + agentArgs, + platform: 'win32', + isRemote: true, + shell: 'powershell', + sessionOptions: { permissionMode: 'full-access' } + }) + const plan = buildAgentStartupPlan({ ...inputs, prompt: '', allowEmptyPromptLaunch: true }) + expect(plan?.launchConfig.agentArgs.match(/--yolo/g)).toHaveLength(1) + expect(plan?.sessionOptions?.permissionMode).toBe('full-access') + const legacy = buildAgentStartupPlan({ + ...inputs, + agentArgs: plan?.launchConfig.agentArgs, + sessionOptions: undefined, + prompt: '', + allowEmptyPromptLaunch: true + }) + expect(legacy?.launchCommand.match(/--yolo/g)).toHaveLength(1) + } +) diff --git a/src/shared/agent-startup-plan-inputs.ts b/src/shared/agent-startup-plan-inputs.ts index ff34df16fb6..190bd02e097 100644 --- a/src/shared/agent-startup-plan-inputs.ts +++ b/src/shared/agent-startup-plan-inputs.ts @@ -1,7 +1,12 @@ +import { readLaunchPermissionChoice } from './tui-agent-permission-choice' import type { GlobalSettings } from './global-settings-types' import type { SessionOptionValue } from './native-chat-session-options' import type { TuiAgent } from './tui-agent' -import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' +import { + agentLaunchPermissionSettings, + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from './tui-agent-launch-defaults' import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' import { hasExtraAgentArgs, mergeExtraAgentArgs } from './automation-extra-agent-args' import { resolveLocalWindowsAgentStartupShell } from './windows-terminal-shell' @@ -49,10 +54,17 @@ export function resolveAgentStartupPlanInputs(args: { /** Orca types into this launch after start (worker, automation, handoff), not a user. */ suppressStartupUpdatePrompt?: boolean }): AgentStartupPlanInputs { - const { agent, settings, platform, isRemote, sessionOptions } = args + const { agent, platform, isRemote, sessionOptions } = args + const settings = agentLaunchPermissionSettings(agent, args.settings, sessionOptions) // Test absence: launch overrides win and null explicitly means no arguments. let agentArgs = - args.agentArgs !== undefined ? args.agentArgs : resolveTuiAgentLaunchArgs(agent, settings) + args.agentArgs !== undefined + ? args.agentArgs + : resolveTuiAgentLaunchArgs( + agent, + settings, + readLaunchPermissionChoice(sessionOptions) ? 'own' : undefined + ) const shell = args.shell ?? resolveLocalWindowsAgentStartupShell({ diff --git a/src/shared/codex-permission-policy.test.ts b/src/shared/codex-permission-policy.test.ts new file mode 100644 index 00000000000..63003a7279e --- /dev/null +++ b/src/shared/codex-permission-policy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { codexStructuredPermissionPolicy } from './codex-permission-policy' + +const FULL_ACCESS = { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + approvalsReviewer: 'user' +} +const ASK = { approvalPolicy: 'on-request', sandbox: 'read-only', approvalsReviewer: 'user' } + +describe('codexStructuredPermissionPolicy', () => { + it('maps Full access to no prompts and no sandbox', () => { + expect(codexStructuredPermissionPolicy('full-access')).toEqual(FULL_ACCESS) + }) + + it('states every Ask field, the human reviewer too', () => { + expect(codexStructuredPermissionPolicy('ask')).toEqual(ASK) + }) + + it('keeps Approve for me in the configured workspace sandbox', () => { + expect(codexStructuredPermissionPolicy('auto')).toEqual({ + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + approvalsReviewer: 'auto_review' + }) + }) + + it('names a sandbox mode for every mode', () => { + for (const mode of ['ask', 'auto', 'full-access'] as const) { + expect(codexStructuredPermissionPolicy(mode)).not.toHaveProperty('sandboxPolicy') + expect(typeof codexStructuredPermissionPolicy(mode).sandbox).toBe('string') + } + }) + + it('asks for a mode Codex does not have', () => { + expect(codexStructuredPermissionPolicy('accept-edits')).toEqual(ASK) + }) +}) diff --git a/src/main/codex/codex-structured-permission-policy.ts b/src/shared/codex-permission-policy.ts similarity index 66% rename from src/main/codex/codex-structured-permission-policy.ts rename to src/shared/codex-permission-policy.ts index b450c5ef648..977c60016dc 100644 --- a/src/main/codex/codex-structured-permission-policy.ts +++ b/src/shared/codex-permission-policy.ts @@ -1,5 +1,5 @@ -import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' -import type { CodexApprovalsReviewer } from '../../shared/codex-subagent-reviewer' +import type { AgentChatPermissionMode } from './agent-chat-permission-mode' +import type { CodexApprovalsReviewer } from './codex-subagent-reviewer' export type CodexStructuredPermissionPolicy = | { @@ -45,3 +45,18 @@ export function codexStructuredPermissionPolicy( ): CodexStructuredPermissionPolicy { return mode === 'full-access' ? FULL_ACCESS_POLICY : mode === 'auto' ? AUTO_POLICY : ASK_POLICY } + +/** + * Ask for a Codex terminal, as the CLI's own flags. Intended difference from chat Ask: the + * interactive CLI offers only `on-request` and `never` approvals (no `untrusted`), so a read-only + * sandbox is what makes it ask before every edit, while commands follow `on-request`. Every field + * is stated, as for chats, so the user's own `config.toml` cannot turn it into no prompts. + */ +export const CODEX_TERMINAL_ASK_ARGS: readonly string[] = [ + '-a', + 'on-request', + '-s', + 'read-only', + '-c', + 'approvals_reviewer="user"' +] diff --git a/src/shared/command-option-occurrence.ts b/src/shared/command-option-occurrence.ts index 471fc69b2c9..faceae33033 100644 --- a/src/shared/command-option-occurrence.ts +++ b/src/shared/command-option-occurrence.ts @@ -15,7 +15,9 @@ export function findOptionOccurrence( aliases: readonly string[], stopAtTerminator: boolean, valueOptions: readonly string[] = [], - startIndex = 0 + startIndex = 0, + /** False for a flag that takes no value, so the token after it stays where it is. */ + takesValue = true ): { index: number; consumed: number; value?: string } | null { for (let index = startIndex; index < tokens.length; index += 1) { const token = tokens[index] @@ -31,7 +33,7 @@ export function findOptionOccurrence( } const nextToken = tokens[index + 1] const consumesNext = - aliases.includes(token) && nextToken !== undefined && !nextToken.startsWith('-') + takesValue && aliases.includes(token) && nextToken !== undefined && !nextToken.startsWith('-') const alias = aliases.find((name) => matchesOption(token, [name])) const value = consumesNext ? nextToken diff --git a/src/shared/fixtures/source-control-launch-recordings.json b/src/shared/fixtures/source-control-launch-recordings.json new file mode 100644 index 00000000000..18d3ef0da95 --- /dev/null +++ b/src/shared/fixtures/source-control-launch-recordings.json @@ -0,0 +1,111 @@ +{ + "cursor": { + "capture": "QA1257, 2026-10-22, official cursor-agent 2026.10.19-6bbf2c4; process argv excerpt after the runtime wrapper, signed out", + "launchPreferences": { + "agentId": "cursor", + "model": "claude-opus-4-8", + "options": { + "thinking": "true" + } + }, + "agentArgs": "", + "argv": ["--model", "claude-opus-4-8-thinking"] + }, + "antigravity": { + "capture": "QA1416, 2026-10-22, actual saved action and child process; signed out; rendered Settings readback", + "launchPreferences": { + "agentId": "antigravity", + "permissionMode": "ask" + }, + "agentArgs": "", + "command": "agy", + "routeHint": "Applies to chats only" + }, + "opencode": { + "capture": "Recorded ACP config_option_update from src/main/acp/fixtures/opencode-v2-reopen.jsonl line 2", + "update": { + "direction": "in", + "process": "A", + "message": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "config_option_update", + "configOptions": [ + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "opencode/big-pickle", + "options": [ + { + "value": "opencode/beam-free", + "name": "opencode/Beam Free" + }, + { + "value": "opencode/big-pickle", + "name": "opencode/Big Pickle" + }, + { + "value": "opencode/deepseek-v4.1-flash-free", + "name": "opencode/DeepSeek V4.1 Flash Free" + }, + { + "value": "opencode/glm-5.3-flash-free", + "name": "opencode/GLM-5.3-Flash Free" + }, + { + "value": "opencode/longcat-2.5-preview-free", + "name": "opencode/LongCat 2.5 Preview Free" + }, + { + "value": "opencode/mimo-v2.6-flash-free", + "name": "opencode/MiMo-V2.6-Flash Free" + }, + { + "value": "opencode/nemotron-3-ultra-free", + "name": "opencode/Nemotron 3 Ultra Free" + }, + { + "value": "opencode/nemotron-3.5-lightning-free", + "name": "opencode/Nemotron 3.5 Lightning Free" + }, + { + "value": "opencode/nemotron-3.5-super-vl-free", + "name": "opencode/Nemotron 3.5 Super VL Free" + }, + { + "value": "opencode/space-bunny-free", + "name": "opencode/Space Bunny Free" + } + ] + }, + { + "id": "mode", + "name": "Session Mode", + "category": "mode", + "type": "select", + "currentValue": "build", + "options": [ + { + "value": "build", + "name": "Build", + "description": "The default agent. Executes tools based on configured permissions." + }, + { + "value": "plan", + "name": "Plan", + "description": "Read-only agent for exploring the codebase and planning work before implementation." + } + ] + } + ] + } + } + } + } + } +} diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index c29897ab19f..17431f3eb6f 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -6,7 +6,11 @@ import { type NewChatPermissionSettings } from './agent-chat-permission-mode' import type { GlobalSettings } from './global-settings-types' -import { sessionOptionValueIsValid } from './agent-session-option-catalog' +import { + findCatalogModel, + getAgentSessionOptionCatalog, + sessionOptionValueIsValid +} from './agent-session-option-catalog' import type { NativeChatSessionOptionSettingsMutation, PersistedNativeChatSessionOptions, @@ -203,3 +207,53 @@ export function updateNativeChatSessionOptionDefaults(args: { } } } + +/** A different model drops the prior model’s options, keeping the chat’s permission choice. Without + * a requested `model` the result only overlays the shown selection. */ +export function structuredLaunchRequestedOptions(selection: { + explicit?: Readonly> + held: Readonly> + seed?: Readonly> +}): Readonly> | undefined { + const { explicit, held, seed } = selection + if (!explicit) { + return undefined + } + const changed = held.model !== undefined && held.model !== explicit.model + const permissionMode = + explicit[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + return { + ...(permissionMode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode } : {}), + ...(changed ? {} : explicit), + ...held + } +} + +/** A create without model replacement overlays the remembered effort onto a named Claude/Codex + * model; the catalog default approximates omission there. */ +export function structuredLaunchCompatibleOptions( + agent: AgentType, + requested: Readonly> +): Readonly> { + if ( + (agent !== 'claude' && agent !== 'codex') || + !requested.model || + requested.effort !== undefined + ) { + return requested + } + const catalog = getAgentSessionOptionCatalog(agent) + const options = + catalog && (findCatalogModel(catalog, requested.model)?.options ?? catalog.unknownModelOptions) + const effort = options?.find((option) => option.id === 'effort') + return effort?.kind.type === 'select' && effort.kind.defaultValue + ? { ...requested, effort: effort.kind.defaultValue } + : requested +} + +export function seededPermissionMode( + options: Readonly> | undefined +): Record { + const mode = options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] + return mode ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: mode } : {} +} diff --git a/src/shared/preflight-runtime-capabilities.ts b/src/shared/preflight-runtime-capabilities.ts index 863dbcbd1c0..48b01f7aaf1 100644 --- a/src/shared/preflight-runtime-capabilities.ts +++ b/src/shared/preflight-runtime-capabilities.ts @@ -4,10 +4,13 @@ export const PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY = // This host refuses foreign-runtime workspaces with WORKSPACE_ON_OTHER_RUNTIME instead of returning its own agents. export const PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY = 'preflight.other-runtime-refusal.v1' as const +export const PREFLIGHT_PROJECT_LAUNCH_CONTEXT_RUNTIME_CAPABILITY = + 'preflight.project-launch-context.v1' as const /** What such a host answers, instead of probing itself, for another runtime's workspace. */ export const WORKSPACE_ON_OTHER_RUNTIME = 'workspace_on_other_runtime' export const PREFLIGHT_RUNTIME_CAPABILITIES = [ PREFLIGHT_WORKSPACE_SCOPED_RUNTIME_CAPABILITY, + PREFLIGHT_PROJECT_LAUNCH_CONTEXT_RUNTIME_CAPABILITY, PREFLIGHT_OTHER_RUNTIME_REFUSAL_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/rpc-contract/agent-launch-params.ts b/src/shared/rpc-contract/agent-launch-params.ts index dacbc9f8fe1..419c4cf2af0 100644 --- a/src/shared/rpc-contract/agent-launch-params.ts +++ b/src/shared/rpc-contract/agent-launch-params.ts @@ -3,6 +3,7 @@ import { AGENT_LAUNCH_SURFACES } from '../agent-launch-surface' import { z } from 'zod' +import { isAgentSessionOptions } from '../agent-session-record' import { parseAgentSessionOperationTimestamp } from '../agent-session-host-authority' import { parsePaneKey } from '../stable-pane-id' import { isValidHostTerminalTabId } from '../terminal-tab-id' @@ -60,6 +61,8 @@ export const AgentLaunchFields = z.object({ .optional(), /** A chat seeds the options it accepts; a terminal launch reads the model, effort and mode. */ sessionOptions: z.record(z.string(), z.string()).optional(), + /** A saved launch choice; absent `sessionOptions`, a terminal applies it as flags too. */ + chatOptions: z.record(z.string(), z.string()).refine(isAgentSessionOptions).optional(), reuseTerminal: z.object({ handle: z.string().min(1, 'Missing terminal handle') }).optional(), /** Nullable on purpose: `null` is "no arguments", absent is "use the settings default". */ agentArgs: z.string().nullable().optional(), diff --git a/src/shared/rpc-contract/preflight-params.ts b/src/shared/rpc-contract/preflight-params.ts index 711c9de8c27..96db05ab0cc 100644 --- a/src/shared/rpc-contract/preflight-params.ts +++ b/src/shared/rpc-contract/preflight-params.ts @@ -1,4 +1,8 @@ import { z } from 'zod' +import { RepoSelector } from './github-repo-target-params' +import type { TuiAgent } from '../tui-agent' +import type { WorkspaceLaunchKind } from '../workspace-launch-kind' +import type { ProjectExecutionRuntimeResolution } from '../project-execution-runtime' export const PreflightCheck = z.object({ force: z.boolean().optional() @@ -17,3 +21,21 @@ export const PreflightAgentDetection = z.object({ worktreeId: z.string().min(1).optional(), repoId: z.string().min(1).optional() }) + +export const PreflightProjectAgentDetection = PreflightAgentDetection.extend({ + project: RepoSelector.extend({ repo: RepoSelector.shape.repo.pipe(z.string().max(4096)) }) + .strict() + .optional() +}).refine( + (value) => !value.project || (!value.worktreeId && !value.repoId), + 'Choose project, repository or worktree' +) + +/** Facts resolved by the same project owner that creates the workspace. */ +export type ProjectLaunchContext = { + agents: TuiAgent[] + workspaceKind: WorkspaceLaunchKind + executionContext: + | { kind: 'host'; projectRuntime: ProjectExecutionRuntimeResolution } + | { kind: 'ssh' } +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 2e5a4a82073..a8cddd873c4 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -486,7 +486,8 @@ import { PreflightAgentDetection, PreflightCheck, PreflightDetectRemoteAgents, - PreflightDetectRemoteWindowsTerminalCapabilities + PreflightDetectRemoteWindowsTerminalCapabilities, + PreflightProjectAgentDetection } from './preflight-params' import { ProjectHostSetupClone, @@ -1260,7 +1261,7 @@ export const RPC_PARAMS_BY_METHOD = { 'plugins.readPanelEntry': PluginReadPanelEntryParams, 'plugins.setEnabled': PluginSetEnabledParams, 'preflight.check': PreflightCheck, - 'preflight.detectAgents': PreflightAgentDetection, + 'preflight.detectAgents': PreflightProjectAgentDetection, 'preflight.detectRemoteAgents': PreflightDetectRemoteAgents, 'preflight.detectRemoteWindowsTerminalCapabilities': PreflightDetectRemoteWindowsTerminalCapabilities, diff --git a/src/shared/rpc-contract/structured-agent-session-message-params.ts b/src/shared/rpc-contract/structured-agent-session-message-params.ts index 69622209195..80da539d140 100644 --- a/src/shared/rpc-contract/structured-agent-session-message-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-message-params.ts @@ -1,6 +1,9 @@ import { z } from 'zod' +import { isAgentSessionOptions } from '../agent-session-record' import { measureUtf8ByteLength } from '../utf8-byte-limits' +export const AgentSessionOptions = z.record(z.string(), z.string()).refine(isAgentSessionOptions) + export const MAX_PROMPT_BYTES = 256 * 1024 export const MAX_BLOCKS = 64 export const MAX_OPTION_LABEL = 512 diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index bde9e086915..f385509b481 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,5 +1,6 @@ import { SendBody, + AgentSessionOptions, MAX_OPTION_LABEL, MAX_PROMPT_BYTES } from './structured-agent-session-message-params' @@ -23,7 +24,6 @@ export { } from './structured-agent-session-identifiers' import { z } from 'zod' import { CREATE_TAB_FIELDS } from './structured-agent-session-create-tab-params' -import { isAgentSessionOptions } from '../agent-session-record' import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' import { normalizeExecutionHostId } from '../execution-host' import { @@ -130,8 +130,6 @@ export const CreateSupportParams = z }) .strict() -const AgentSessionOptions = z.record(z.string(), z.string()).refine(isAgentSessionOptions) - export const SendParams = z .object({ envelope: MutationEnvelope, @@ -162,7 +160,8 @@ export const CreateIntentParams = z }) .strict() .optional(), - options: AgentSessionOptions.optional() + options: AgentSessionOptions.optional(), + optionsMode: z.literal('replace-model-selection').optional() }) .strict() diff --git a/src/shared/source-control-action-launch-preferences.ts b/src/shared/source-control-action-launch-preferences.ts new file mode 100644 index 00000000000..00b88adb4e0 --- /dev/null +++ b/src/shared/source-control-action-launch-preferences.ts @@ -0,0 +1,125 @@ +import { + resolveTuiAgentLaunchArgs, + type AgentLaunchProfileSettings +} from './tui-agent-launch-defaults' +import { + findCatalogModel, + getAgentSessionOptionCatalog, + type CatalogOption +} from './agent-session-option-catalog' +import type { SourceControlActionRecipe } from './source-control-ai-actions' +import { isTuiAgent } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' +import { isAgentSessionHandleProvider } from './agent-session-provider-handle' +import { STRUCTURED_LAUNCH_SEED_OPTION_IDS } from './native-chat-session-option-defaults' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' +import { + agentPermissionHasTerminalChoice, + type AgentPermissionChoice +} from './tui-agent-permission-choice' + +/** Agents whose model an action can pick: the ones whose session catalog lists models. */ +export function sourceControlActionAgentPicksModel( + agent: string | null | undefined +): agent is TuiAgent { + return isTuiAgent(agent) && Boolean(getAgentSessionOptionCatalog(agent)?.models.length) +} + +/** The one route a saved choice reaches, for the hint under its control; null reaches both. */ +export type SourceControlActionChoiceRoute = 'chats' | 'terminals' | null + +export function sourceControlActionPermissionModeRoute( + agent: TuiAgent, + mode: AgentPermissionChoice +): SourceControlActionChoiceRoute { + return agentPermissionHasTerminalChoice(agent, mode) ? null : 'chats' +} + +function optionReachesChats(agent: TuiAgent, option: CatalogOption): boolean { + return ( + !option.apply.composedIntoModel && + (isAgentSessionHandleProvider(agent) + ? STRUCTURED_LAUNCH_SEED_OPTION_IDS.some((id) => id === option.id) + : option.id === 'effort') + ) +} + +type SourceControlActionOption = { option: CatalogOption; route: SourceControlActionChoiceRoute } + +/** The picked model's options that a chat seeds or a terminal flag carries. */ +export function sourceControlActionOptions( + agent: TuiAgent, + modelId: string +): SourceControlActionOption[] { + const catalog = getAgentSessionOptionCatalog(agent) + const options = + findCatalogModel(catalog ?? { models: [], modelApply: {} }, modelId)?.options ?? + catalog?.unknownModelOptions ?? + [] + return options.flatMap((option): SourceControlActionOption[] => { + const chats = optionReachesChats(agent, option) + const terminals = Boolean(option.apply.launchArgs || option.apply.composedIntoModel) + if (!chats && !terminals) { + return [] + } + return [{ option, route: !terminals ? 'chats' : !chats ? 'terminals' : null }] + }) +} + +/** The action's choice as launch inputs, with whether this repository's own CLI arguments outrank + * an inherited choice on terminals. */ +export function sourceControlActionLaunchOptions( + recipe: Pick< + SourceControlActionRecipe, + 'agentId' | 'launchPreferences' | 'launchPreferencesYieldToAgentArgs' + >, + agent: TuiAgent +): { + sessionOptions?: Readonly> + sessionOptionsYieldToAgentArgs?: true +} { + const sessionOptions = resolveSourceControlActionSessionOptions(recipe, agent) + return sessionOptions + ? { + sessionOptions, + ...(recipe.launchPreferencesYieldToAgentArgs + ? { sessionOptionsYieldToAgentArgs: true as const } + : {}) + } + : {} +} + +/** The resolved agent alone can receive its bound action choice. */ +export function resolveSourceControlActionSessionOptions( + recipe: Pick, + agent: TuiAgent +): Readonly> | undefined { + const preferences = recipe.launchPreferences + if (!preferences || preferences.agentId !== agent) { + return undefined + } + return { + ...(preferences.model ? { ...preferences.options, model: preferences.model } : {}), + ...(preferences.permissionMode + ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: preferences.permissionMode } + : {}) + } +} + +/** An action without its own permission follows Agents, including when it owns unrelated text. */ +export function sourceControlActionAgentArgs( + recipe: Pick, + agent: TuiAgent, + settings: AgentLaunchProfileSettings | null | undefined +): string | undefined { + if ( + recipe.agentArgs === undefined || + (recipe.launchPreferences?.agentId === agent && recipe.launchPreferences.permissionMode) + ) { + return recipe.agentArgs + } + return resolveTuiAgentLaunchArgs(agent, { + ...settings, + agentDefaultArgs: { ...settings?.agentDefaultArgs, [agent]: recipe.agentArgs } + }) +} diff --git a/src/shared/source-control-action-permission-lift.ts b/src/shared/source-control-action-permission-lift.ts new file mode 100644 index 00000000000..89aba8f24c5 --- /dev/null +++ b/src/shared/source-control-action-permission-lift.ts @@ -0,0 +1,64 @@ +import { isTuiAgent } from './tui-agent-config' +import { liftAgentPermissionArgs } from './tui-agent-permission-lift' +import { + SOURCE_CONTROL_LAUNCH_ACTION_IDS, + type AiActionId, + type SourceControlActionRecipe +} from './source-control-ai-actions' +import type { RepoSourceControlAiOverrides } from './source-control-ai-types' + +type PermissionRecipe = Pick & { + agentArgs?: string | null +} + +/** Load keeps stored intent; a deliberate field commit may replace it with an exact typed recipe. */ +export function liftActionPermissionArgs( + recipe: T, + edited = false, + actionId?: AiActionId +): T { + if (actionId && !SOURCE_CONTROL_LAUNCH_ACTION_IDS.some((id) => id === actionId)) { + return recipe + } + if (!isTuiAgent(recipe.agentId) || typeof recipe.agentArgs !== 'string') { + return recipe + } + const lifted = liftAgentPermissionArgs(recipe.agentId, recipe.agentArgs) + if (!lifted.choice) { + return recipe + } + const previous = recipe.launchPreferences + if ( + previous && + (previous.agentId !== recipe.agentId || + (!edited && previous.permissionMode && previous.permissionMode !== lifted.choice)) + ) { + return recipe + } + return { + ...recipe, + agentArgs: lifted.args, + launchPreferences: { ...previous, agentId: recipe.agentId, permissionMode: lifted.choice } + } +} + +/** Only the row's own concrete agent proves which CLI grammar its saved text uses. */ +export function migrateActionPermissionArgs< + T extends NonNullable +>(actions: T | undefined): T | undefined { + if (!actions) { + return actions + } + let next = actions + for (const id of SOURCE_CONTROL_LAUNCH_ACTION_IDS) { + const recipe = actions[id] + if (!recipe) { + continue + } + const lifted = liftActionPermissionArgs(recipe, false, id) + if (lifted !== recipe) { + next = { ...next, [id]: lifted } + } + } + return next +} diff --git a/src/shared/source-control-ai-action-recipes.test.ts b/src/shared/source-control-ai-action-recipes.test.ts index f87354c49fd..37d4c832350 100644 --- a/src/shared/source-control-ai-action-recipes.test.ts +++ b/src/shared/source-control-ai-action-recipes.test.ts @@ -7,6 +7,7 @@ import { resolveSourceControlAiForOperation } from './source-control-ai' import type { GlobalSettings } from './global-settings-types' +import type { SourceControlActionRecipe } from './source-control-ai-actions' function settings(): GlobalSettings { const base = getDefaultSettings('/tmp') @@ -146,6 +147,40 @@ describe('source-control AI action recipes', () => { }) }) + it('gives a repository owning its agent its own default, and partial overrides global ownership', () => { + const base = settings() + base.sourceControlAi = { + ...base.sourceControlAi!, + actions: { + ...base.sourceControlAi!.actions, + fixChecks: { + agentId: 'codex', + launchPreferences: { agentId: 'codex' as const, model: 'gpt-5.6-terra' } + } + } + } + const resolve = (fixChecks: SourceControlActionRecipe) => + resolveSourceControlActionRecipe({ + settings: base, + repo: { sourceControlAi: { actionOverrides: { fixChecks } } }, + actionId: 'fixChecks' + }) + + expect(resolve({ agentId: 'codex', agentArgs: '--search' }).launchPreferences).toBeUndefined() + expect(resolve({ agentId: null }).launchPreferences).toBeUndefined() + expect(resolve({ agentArgs: '--search' }).launchPreferences).toEqual({ + agentId: 'codex', + model: 'gpt-5.6-terra' + }) + expect(resolve({ agentId: 'claude' })).not.toHaveProperty('launchPreferences') + expect( + resolve({ + agentId: 'codex', + launchPreferences: { agentId: 'codex', model: 'gpt-5.5', options: { effort: 'high' } } + }).launchPreferences + ).toEqual({ agentId: 'codex', model: 'gpt-5.5', options: { effort: 'high' } }) + }) + it('lets repo action recipes explicitly clear inherited CLI args', () => { const base = settings() base.sourceControlAi = { diff --git a/src/shared/source-control-ai-actions.test.ts b/src/shared/source-control-ai-actions.test.ts index bb9cf45bf76..e04dd3ed3f8 100644 --- a/src/shared/source-control-ai-actions.test.ts +++ b/src/shared/source-control-ai-actions.test.ts @@ -10,6 +10,7 @@ import { resolveSourceControlActionCommandTemplate, setSourceControlActionAgentDefault } from './source-control-ai-actions' +import { resolveSourceControlActionSessionOptions } from './source-control-action-launch-preferences' describe('source-control AI launch action defaults', () => { it('normalizes safe launch action defaults', () => { @@ -119,6 +120,85 @@ describe('source-control AI launch action defaults', () => { }) }) + it('keeps a saved action model and drops a malformed one', () => { + expect( + normalizeSourceControlAiActionDefaults({ + fixChecks: { + agentId: 'codex', + launchPreferences: { model: ' gpt-5.6-terra ', effort: 'high' } + }, + resolveConflicts: { agentId: 'claude', launchPreferences: { model: ' ', effort: 'high' } }, + resolveComments: { agentId: 'claude', launchPreferences: 'opus' } + }) + ).toEqual({ + fixChecks: { + agentId: 'codex', + launchPreferences: { + agentId: 'codex' as const, + model: 'gpt-5.6-terra', + options: { effort: 'high' } + } + }, + resolveConflicts: { agentId: 'claude' }, + resolveComments: { agentId: 'claude' } + }) + }) + + it('keeps a permission mode the agent offers, with or without a model', () => { + const defaults = normalizeSourceControlAiActionDefaults({ + fixChecks: { agentId: 'opencode', launchPreferences: { permissionMode: 'full-access' } }, + fixCommitFailure: { agentId: 'codex', launchPreferences: { permissionMode: 'accept-edits' } }, + fixPushFailure: { + agentId: 'codex', + launchPreferences: { model: 'gpt-5.6-terra', options: { permissionMode: 'full-access' } } + } + }) + expect(defaults).toEqual({ + fixChecks: { + agentId: 'opencode', + launchPreferences: { agentId: 'opencode', permissionMode: 'full-access' } + }, + fixCommitFailure: { agentId: 'codex' }, + fixPushFailure: { agentId: 'codex' } + }) + expect(resolveSourceControlActionSessionOptions(defaults?.fixChecks ?? {}, 'opencode')).toEqual( + { + permissionMode: 'full-access' + } + ) + }) + + it('drops the saved model when the action changes agent', () => { + const defaults = { + fixChecks: { + agentId: 'codex' as const, + launchPreferences: { agentId: 'codex' as const, model: 'gpt-5.6-terra' } + } + } + + expect(setSourceControlActionAgentDefault(defaults, 'fixChecks', 'codex')).toEqual(defaults) + expect(setSourceControlActionAgentDefault(defaults, 'fixChecks', 'claude')).toEqual({ + fixChecks: { agentId: 'claude' } + }) + }) + + it('gives the saved model and effort only to the recipe agent', () => { + const recipe = { + agentId: 'codex' as const, + launchPreferences: { + agentId: 'codex' as const, + model: 'gpt-5.6-terra', + options: { effort: 'high' } + } + } + + expect(resolveSourceControlActionSessionOptions(recipe, 'codex')).toEqual({ + model: 'gpt-5.6-terra', + effort: 'high' + }) + expect(resolveSourceControlActionSessionOptions(recipe, 'claude')).toBeUndefined() + }) + it('exposes review-comment resolution as a launch action', () => { expect(SOURCE_CONTROL_LAUNCH_ACTION_IDS).toContain('resolveComments') expect(SOURCE_CONTROL_LAUNCH_ACTION_LABELS.resolveComments).toBe('Review comment resolution') diff --git a/src/shared/source-control-ai-actions.ts b/src/shared/source-control-ai-actions.ts index 76ac86aacb9..a777b6807dc 100644 --- a/src/shared/source-control-ai-actions.ts +++ b/src/shared/source-control-ai-actions.ts @@ -1,6 +1,8 @@ import { isCustomAgentId, type CustomAgentId } from './commit-message-agent-spec' import { isTuiAgent } from './tui-agent-config' import type { TuiAgent } from './tui-agent' +import { isAgentSessionOptions } from './agent-session-record' +import { agentPermissionChoices, type AgentPermissionChoice } from './tui-agent-permission-choice' // Keep the variable registry separate for max-lines headroom; no re-export so consumer searches stay complete. @@ -17,10 +19,25 @@ export type SourceControlLaunchActionId = export type SourceControlActionId = SourceControlTextActionId | SourceControlLaunchActionId export type AiActionId = AiTextActionId | SourceControlLaunchActionId +/** The bound agent namespace is independent of the recipe’s default-agent selection. */ +export type SourceControlActionLaunchPreferences = { + agentId: TuiAgent + /** Absent: a chat starts on the agent's remembered selection, a terminal on its arguments. */ + model?: string + options?: Record + /** Absent: a chat takes the new-chat default, a terminal Agent Permissions or its own arguments. */ + permissionMode?: AgentPermissionChoice +} + export type SourceControlActionRecipe = { agentId?: TuiAgent | CustomAgentId | null commandInputTemplate?: string agentArgs?: string + /** Only the bound agent receives these values, on a chat or a terminal. */ + launchPreferences?: SourceControlActionLaunchPreferences + /** Resolved only, never saved: the choice is inherited from Global while this repository sets + * its own CLI arguments, so on terminals a flag those arguments set wins over the choice. */ + launchPreferencesYieldToAgentArgs?: true } export type SourceControlAiActionDefaults = Partial> @@ -121,9 +138,53 @@ export function normalizeSourceControlActionRecipe( if (typeof value.agentArgs === 'string') { normalized.agentArgs = value.agentArgs } + const launchPreferences = normalizeSourceControlActionLaunchPreferences( + value.launchPreferences, + agentId + ) + if (launchPreferences) { + normalized.launchPreferences = launchPreferences + } return Object.keys(normalized).length > 0 ? normalized : undefined } +export function normalizeSourceControlActionLaunchPreferences( + value: unknown, + recipeAgent?: unknown +): SourceControlActionLaunchPreferences | undefined { + if (!isRecord(value)) { + return undefined + } + const agentId = value.agentId ?? recipeAgent + if (!isTuiAgent(agentId)) { + return undefined + } + const model = typeof value.model === 'string' ? value.model.trim() : '' + const permissionMode = agentPermissionChoices(agentId).find( + (mode) => mode === value.permissionMode + ) + // Options belong to a model; the permission mode has its own field. + const options = model + ? (value.options ?? (typeof value.effort === 'string' ? { effort: value.effort } : {})) + : {} + if ( + !isAgentSessionOptions(options) || + Object.keys(options).some( + (key) => !isSafeRecordKey(key) || ['agentId', 'model', 'permissionMode'].includes(key) + ) || + (model && !isAgentSessionOptions({ ...options, model })) || + (!model && !permissionMode) + ) { + return undefined + } + return { + agentId, + ...(model ? { model } : {}), + ...(Object.keys(options).length ? { options } : {}), + ...(permissionMode ? { permissionMode } : {}) + } +} + export function normalizeSourceControlAiActionDefaults( value: unknown ): SourceControlAiActionDefaults | undefined { @@ -154,7 +215,8 @@ export function readSourceControlActionDefault( ...(typeof value?.commandInputTemplate === 'string' ? { commandInputTemplate: value.commandInputTemplate.trim() } : {}), - ...(typeof value?.agentArgs === 'string' ? { agentArgs: value.agentArgs.trim() } : {}) + ...(typeof value?.agentArgs === 'string' ? { agentArgs: value.agentArgs.trim() } : {}), + ...(value?.launchPreferences ? { launchPreferences: value.launchPreferences } : {}) } } @@ -187,7 +249,13 @@ export function setSourceControlActionAgentDefault( actionId: AiActionId, agentId: TuiAgent | CustomAgentId | null ): SourceControlAiActionDefaults { - return setSourceControlActionDefault(defaults, actionId, { agentId }) + const current = defaults?.[actionId] + if (current?.agentId === agentId || !current?.launchPreferences) { + return setSourceControlActionDefault(defaults, actionId, { agentId }) + } + // A model id and permission mode name one agent's choices, so they do not survive a change of agent. + const { launchPreferences: _previousAgentModel, ...rest } = current + return { ...defaults, [actionId]: { ...rest, agentId } } } export function renderSourceControlActionCommandTemplate( diff --git a/src/shared/source-control-ai-operation-policy.ts b/src/shared/source-control-ai-operation-policy.ts index 40229311336..ad40a9ee12a 100644 --- a/src/shared/source-control-ai-operation-policy.ts +++ b/src/shared/source-control-ai-operation-policy.ts @@ -202,19 +202,31 @@ export function resolveSourceControlActionRecipe(input: { source.actions, input.actionId ) - return repoRecipe - ? { - ...globalRecipe, - commandInputTemplate, - ...(repoRecipe.agentId !== undefined ? { agentId: repoRecipe.agentId } : {}), - ...(typeof repoRecipe.commandInputTemplate === 'string' - ? { commandInputTemplate: repoRecipe.commandInputTemplate.trim() } - : {}), - ...(typeof repoRecipe.agentArgs === 'string' - ? { agentArgs: repoRecipe.agentArgs.trim() } - : repoRecipe.agentArgs === null - ? { agentArgs: '' } - : {}) - } - : { ...globalRecipe, commandInputTemplate } + if (!repoRecipe) { + return { ...globalRecipe, commandInputTemplate } + } + // A model belongs to its agent, so a repo on another agent never inherits the global model. + const { launchPreferences: globalLaunchPreferences, ...globalWithoutModel } = globalRecipe + const repoKeepsGlobalAgent = repoRecipe.agentId === undefined + const launchPreferences = + repoRecipe.launchPreferences ?? (repoKeepsGlobalAgent ? globalLaunchPreferences : undefined) + const inheritsChoiceUnderOwnArgs = + launchPreferences !== undefined && + repoRecipe.launchPreferences === undefined && + repoRecipe.agentArgs !== undefined + return { + ...globalWithoutModel, + commandInputTemplate, + ...(launchPreferences ? { launchPreferences } : {}), + ...(inheritsChoiceUnderOwnArgs ? { launchPreferencesYieldToAgentArgs: true as const } : {}), + ...(repoRecipe.agentId !== undefined ? { agentId: repoRecipe.agentId } : {}), + ...(typeof repoRecipe.commandInputTemplate === 'string' + ? { commandInputTemplate: repoRecipe.commandInputTemplate.trim() } + : {}), + ...(typeof repoRecipe.agentArgs === 'string' + ? { agentArgs: repoRecipe.agentArgs.trim() } + : repoRecipe.agentArgs === null + ? { agentArgs: '' } + : {}) + } } diff --git a/src/shared/source-control-ai-recipe-save.test.ts b/src/shared/source-control-ai-recipe-save.test.ts index b4aeaf355b6..aba79607ddd 100644 --- a/src/shared/source-control-ai-recipe-save.test.ts +++ b/src/shared/source-control-ai-recipe-save.test.ts @@ -76,6 +76,31 @@ describe('source-control AI recipe saves', () => { }) }) + it('keeps the action model when saving a repo recipe', () => { + const launchPreferences = { + agentId: 'codex' as const, + model: 'gpt-5.6-terra', + options: { effort: 'high' } + } + const result = saveSourceControlActionRecipe({ + target: { type: 'repo', repoId: 'repo-1' }, + settings: settings(), + repo: null, + actionId: 'fixChecks', + recipe: { agentId: 'codex', commandInputTemplate: '{basePrompt}', launchPreferences } + }) + + expect(result).toMatchObject({ + update: { + sourceControlAi: { + actionOverrides: { + fixChecks: { agentId: 'codex', commandInputTemplate: '{basePrompt}', launchPreferences } + } + } + } + }) + }) + it('clears empty repo source-control AI writes with the JSON boundary sentinel', () => { expect(toSourceControlAiRepoUpdate({ prCreationDefaults: { draft: null } })).toEqual({ sourceControlAi: null diff --git a/src/shared/source-control-ai-recipe-save.ts b/src/shared/source-control-ai-recipe-save.ts index d000fc399f6..cf9a02997af 100644 --- a/src/shared/source-control-ai-recipe-save.ts +++ b/src/shared/source-control-ai-recipe-save.ts @@ -42,6 +42,7 @@ type ReadCompatibleActionRecipe = { agentId?: SourceControlActionRecipe['agentId'] commandInputTemplate?: string | null agentArgs?: string | null + launchPreferences?: SourceControlActionRecipe['launchPreferences'] } const TEXT_ACTION_ID_SET = new Set(SOURCE_CONTROL_TEXT_ACTION_IDS) @@ -90,7 +91,8 @@ function normalizeCompleteRecipe( return { agentId: recipe.agentId ?? null, commandInputTemplate, - ...(agentArgs !== undefined ? { agentArgs } : {}) + ...(agentArgs !== undefined ? { agentArgs } : {}), + ...(recipe.launchPreferences ? { launchPreferences: recipe.launchPreferences } : {}) } } diff --git a/src/shared/source-control-ai-types.ts b/src/shared/source-control-ai-types.ts index b6a17ae048e..27be645b70d 100644 --- a/src/shared/source-control-ai-types.ts +++ b/src/shared/source-control-ai-types.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from './tui-agent' import type { CustomAgentId } from './commit-message-agent-spec' import type { SourceControlAiActionDefaults, + SourceControlActionLaunchPreferences, SourceControlActionId, SourceControlTextActionId, AiTextActionId @@ -59,6 +60,7 @@ export type RepoSourceControlAiOverrides = { agentId?: TuiAgent | CustomAgentId | null commandInputTemplate?: string | null agentArgs?: string | null + launchPreferences?: SourceControlActionLaunchPreferences } > > @@ -74,6 +76,7 @@ export type CompleteSourceControlActionRecipe = { agentId: TuiAgent | CustomAgentId | null commandInputTemplate: string agentArgs?: string + launchPreferences?: SourceControlActionLaunchPreferences } export type WritableRepoSourceControlAiOverrides = { diff --git a/src/shared/source-control-launch-agent-selection.ts b/src/shared/source-control-launch-agent-selection.ts index bbadcd14553..8be9ae4b2e5 100644 --- a/src/shared/source-control-launch-agent-selection.ts +++ b/src/shared/source-control-launch-agent-selection.ts @@ -84,7 +84,11 @@ export function resolveSourceControlLaunchAgentScope(input: { } } -export type SourceControlActionRecipeOverrideField = 'agent' | 'commandTemplate' | 'agentArgs' +export type SourceControlActionRecipeOverrideField = + | 'agent' + | 'commandTemplate' + | 'agentArgs' + | 'launchPreferences' export type SourceControlActionRecipeOverride = { repoId: string @@ -124,6 +128,9 @@ function readRecipeOverrideFields( if (Object.hasOwn(recipe ?? {}, 'agentArgs')) { fields.push('agentArgs') } + if (Object.hasOwn(recipe ?? {}, 'launchPreferences')) { + fields.push('launchPreferences') + } return fields } diff --git a/src/shared/structured-agent-session-create.ts b/src/shared/structured-agent-session-create.ts index c0f1ca552d6..53064bbeed9 100644 --- a/src/shared/structured-agent-session-create.ts +++ b/src/shared/structured-agent-session-create.ts @@ -24,6 +24,7 @@ export type StructuredAgentSessionCreateParams = { tabId?: string /** Sent only to a host advertising `AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY`. */ firstMessage?: StructuredAgentSessionFirstMessage + optionsMode?: 'replace-model-selection' options?: Readonly> /** Sent only to a host advertising `AGENT_SESSION_CREATE_TAB_PLACEMENT_RUNTIME_CAPABILITY`. */ tabPlacement?: { groupId?: string; afterTabId?: string } @@ -56,6 +57,7 @@ export function structuredAgentSessionCreateParams(args: { resumeFrom?: StructuredAgentSessionResumeSource tabId?: string firstMessage?: StructuredAgentSessionFirstMessage + optionsMode?: 'replace-model-selection' options?: Readonly> /** A display hint, outside the fingerprint: a retry may name a place that moved. */ tabPlacement?: StructuredAgentSessionCreateParams['tabPlacement'] @@ -68,6 +70,7 @@ export function structuredAgentSessionCreateParams(args: { ...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {}), ...(args.tabId ? { tabId: args.tabId } : {}), ...(args.firstMessage ? { firstMessage: args.firstMessage } : {}), + ...(args.optionsMode ? { optionsMode: args.optionsMode } : {}), ...(args.options ? { options: args.options } : {}) } return { diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index fc68f362797..ca9a6d4d777 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -50,6 +50,7 @@ export function structuredAgentSessionCreateFingerprint(input: { resumeFrom?: StructuredAgentSessionResumeSource tabId?: string firstMessage?: StructuredAgentSessionFirstMessage + optionsMode?: 'replace-model-selection' options?: Readonly> }): string { return structuredAgentSessionPayloadFingerprint({ @@ -63,6 +64,7 @@ export function structuredAgentSessionCreateFingerprint(input: { // Include tabId in the host digest; retries replay its recorded host-owned ID. tabId: input.tabId, firstMessage: input.firstMessage, + optionsMode: input.optionsMode, options: input.options } }) diff --git a/src/shared/structured-agent-session-option-codec.ts b/src/shared/structured-agent-session-option-codec.ts index 6d56049b512..2d0f97e5eb6 100644 --- a/src/shared/structured-agent-session-option-codec.ts +++ b/src/shared/structured-agent-session-option-codec.ts @@ -21,3 +21,24 @@ export function decodeStructuredAgentSessionOptionValue( } return value === 'true' ? true : value === 'false' ? false : null } + +/** A saved option string as its catalog kind reads it: booleans by kind, not by a fixed id list. */ +export function decodeCatalogOptionValue( + kind: { type: 'select' | 'boolean' }, + value: SessionOptionValue | undefined +): SessionOptionValue | undefined { + if (kind.type !== 'boolean' || typeof value !== 'string') { + return value + } + return value === 'true' ? true : value === 'false' ? false : undefined +} + +export function encodeCatalogOptionValue( + kind: { type: 'select' | 'boolean' }, + value: SessionOptionValue +): string | null { + if (kind.type === 'boolean') { + return typeof value === 'boolean' ? String(value) : null + } + return typeof value === 'string' ? value : null +} diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 70cced94d63..3deeab24a47 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -1,3 +1,4 @@ +import { structuredLaunchRequestedOptions } from './native-chat-session-option-defaults' import { commitStructuredAgentSessionOptionValues, structuredAgentSessionOptionSnapshot, @@ -6,6 +7,7 @@ import { import type { SessionOptionDescriptor } from './native-chat-session-options' import { cloneStructuredAgentSessionOptionRecord, + createStructuredAgentSessionOptionRecord, structuredAgentSessionModelId, structuredAgentSessionOwnValue } from './structured-agent-session-option-state' @@ -19,17 +21,30 @@ function catalogListsSeedModel( return model === undefined || Boolean(state.catalog?.models.some((entry) => entry.id === model)) } -/** Derive provisional values without mutating the record: held picks outrank chat values and launch seeds. - * Keep unlisted models because listing absence cannot disprove they run. Permission mode uses held, host, then seed. */ +/** Derive held picks, host values and launch seeds without mutating the record; retain unlisted models. + * Permission mode uses held, host, explicit launch mode, then seed. */ export function structuredAgentSessionOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, - held: Readonly> + held: Readonly>, + explicit?: Readonly> ): StructuredAgentSessionOptionState { + const requested = structuredLaunchRequestedOptions({ explicit, seed, held }) + // Only a requested model replaces the shown selection; a permission mode alone overlays it. + if (requested?.model !== undefined) { + return commitStructuredAgentSessionOptionValues( + { ...state, record: createStructuredAgentSessionOptionRecord(state.record.agent) }, + requested + ) + } const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: heldPermission, ...heldSelection } = held const view = modelOptionView(state, seed, heldSelection) const permissionMode = - heldPermission ?? state.permissionMode ?? seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? null + heldPermission ?? + state.permissionMode ?? + requested?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? + seed?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ?? + null return view.permissionMode === permissionMode ? view : { ...view, permissionMode } } diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 5f91d0dcf7f..96c08889528 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -46,6 +46,7 @@ describe('per-launch structured feasibility', () => { ['grok', { agent: 'grok' }, 'agent-without-structured-session'], ['openclaude', { agent: 'openclaude' }, 'agent-without-structured-session'], ['a custom start directory', { startsOutsideWorkspaceRoot: true }, 'custom-start-directory'], + ['a host-nested SSH project', { projectExecutionKind: 'ssh' }, 'remote-execution-host'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'], ['an unanswered host', { hostCapabilities: null }, 'runtime-capability-unknown'] diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index 2eed47d806b..ad1fb37a527 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -43,6 +43,7 @@ export type StructuredNativeChatSupportInput = { clientCapabilities?: readonly string[] /** Host-derived. Absent means the kind was never established, which is not evidence of any kind. */ workspaceKind?: WorkspaceLaunchKind + projectExecutionKind?: 'host' | 'ssh' projectRuntime?: ProjectExecutionRuntimeResolution | null startsOutsideWorkspaceRoot?: boolean /** An existing PTY agent keeps its execution transport. */ @@ -78,7 +79,10 @@ export function resolveStructuredNativeChatSupport( ): StructuredNativeChatSupport { // Paired runtimes execute structured sessions themselves; relay SSH hosts cannot. const host = parseExecutionHostId(input.executionHostId) - if (host?.kind !== 'local' && host?.kind !== 'runtime') { + if ( + input.projectExecutionKind === 'ssh' || + (host?.kind !== 'local' && host?.kind !== 'runtime') + ) { return { supported: false, blocker: 'remote-execution-host' } } if (input.reusesTerminal === true) { diff --git a/src/shared/tui-agent-launch-command-argument-values.test.ts b/src/shared/tui-agent-launch-command-argument-values.test.ts index 2e89e605853..136be442e9c 100644 --- a/src/shared/tui-agent-launch-command-argument-values.test.ts +++ b/src/shared/tui-agent-launch-command-argument-values.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' +import recordings from './fixtures/source-control-launch-recordings.json' +import { normalizeSourceControlActionLaunchPreferences } from './source-control-ai-actions' +import { sourceControlActionLaunchOptions } from './source-control-action-launch-preferences' import type { SessionOptionValue } from './native-chat-session-options' -import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { resolveAgentLaunchCommand, terminalSessionOptionInputs } from './tui-agent-launch-command' import { tokenizeStartupCommand } from './tui-agent-startup-shell' import type { TuiAgent } from './tui-agent' @@ -35,6 +38,31 @@ describe.each(['posix', 'powershell', 'cmd'] as const)('argument values on %s', ) } + it('compiles the saved Cursor Thinking choice to the recorded real child arguments', () => { + const capture = recordings.cursor + const options = sourceControlActionLaunchOptions( + { + agentId: 'cursor', + launchPreferences: normalizeSourceControlActionLaunchPreferences(capture.launchPreferences) + }, + 'cursor' + ) + const startup = terminalSessionOptionInputs({ + agent: 'cursor', + sessionOptions: options.sessionOptions, + yieldToAgentArgs: options.sessionOptionsYieldToAgentArgs, + agentArgs: capture.agentArgs, + shell + }) + const result = launchAgent( + 'cursor', + startup.sessionOptions ?? {}, + capture.agentArgs, + startup.sessionOptionsOverrideAgentArgs + ) + expect(result.argv).toMatchObject({ ok: true, tokens: ['cursor-agent', ...capture.argv] }) + }) + it.each(['--model=haiku', '--effort=low'])( 'keeps picks when the system prompt is %s', (value) => { @@ -69,6 +97,30 @@ describe.each(['posix', 'powershell', 'cmd'] as const)('argument values on %s', expect(result.appliedSessionOptions).toEqual({ model: 'opus', effort: 'xhigh' }) }) + it('records policy without defeated selectors, protecting values and the terminator', () => { + const result = launchAgent( + 'claude', + { model: 'opus', permissionMode: 'ask' }, + '--append-system-prompt "--dangerously-skip-permissions" --model haiku --dangerously-skip-permissions -- literal', + true + ) + const tokens = [ + '--append-system-prompt', + '--dangerously-skip-permissions', + '--dangerously-skip-permissions', + '--', + 'literal' + ] + expect(tokenizeStartupCommand(result.resumeLaunch.agentCommand, shell)).toMatchObject({ + ok: true, + tokens: ['claude', ...tokens] + }) + expect(tokenizeStartupCommand(result.resumeLaunch.agentArgs ?? '', shell)).toMatchObject({ + ok: true, + tokens + }) + }) + it('recognizes a real model flag after a prompt whose value is --', () => { const result = launch('--append-system-prompt "--" --model haiku') expect(result.argv).toMatchObject({ diff --git a/src/shared/tui-agent-launch-command-pick-flags.test.ts b/src/shared/tui-agent-launch-command-pick-flags.test.ts index 5dc922cb7e0..15d1d8377a6 100644 --- a/src/shared/tui-agent-launch-command-pick-flags.test.ts +++ b/src/shared/tui-agent-launch-command-pick-flags.test.ts @@ -43,6 +43,22 @@ describe('one copy of each chat-picked flag', () => { } ) + it('treats spaced Codex config model and effort as raw pins or removes them when overridden', () => { + const agentArgs = '-c " model = o3" -c " model_reasoning_effort = low" -c model_verbosity=low' + const inputs = { + agent: 'codex' as const, + agentArgs, + sessionOptions: { model: 'gpt-5.5', effort: 'high' } + } + expect(launch(inputs).appliedSessionOptions).toEqual({}) + const saved = launch({ ...inputs, sessionOptionsOverrideAgentArgs: true }) + expect(saved.appliedSessionOptions).toEqual(inputs.sessionOptions) + expect(saved.resumeLaunch).toEqual({ + agentCommand: "codex '-c' 'model_verbosity=low'", + agentArgs: "'-c' 'model_verbosity=low'" + }) + }) + it('sends only the configured Grok model without the picked model effort', () => { const result = launch({ agent: 'grok', @@ -137,3 +153,48 @@ describe('one copy of each chat-picked flag', () => { expect(result.appliedSessionOptions).toEqual({ model: 'gpt-5.5' }) }) }) + +describe('a saved permission mode on terminals', () => { + it.each([ + ['claude', 'ask', '--permission-mode auto --verbose', "'--permission-mode' 'auto' '--verbose'"], + // A required operand and an argument after -- do not own permission. + [ + 'claude', + 'ask', + "--append-system-prompt '--dangerously-skip-permissions'", + "'--append-system-prompt' '--dangerously-skip-permissions' '--permission-mode' 'default'" + ], + [ + 'claude', + 'ask', + '-- --dangerously-skip-permissions', + "'--permission-mode' 'default' '--' '--dangerously-skip-permissions'" + ], + ['codex', 'ask', '', "'-a' 'on-request' '-s' 'read-only' '-c' 'approvals_reviewer=user'"], + ['codex', 'ask', '--yolo', "'--yolo'"], + ['gemini', 'full-access', '--approval-mode default', "'--approval-mode' 'default'"], + ['opencode', 'full-access', '--print-logs', "'--print-logs'"] + ] as const)( + '%s %s follows shared permission ownership for %s', + (agent, permissionMode, agentArgs, expectedArgs) => { + const plain = launch({ agent, agentArgs: '' }).command + const result = launch({ + agent, + agentArgs, + sessionOptions: { permissionMode }, + sessionOptionsOverrideAgentArgs: true + }) + expect(result.command).toBe(expectedArgs ? `${plain} ${expectedArgs}` : plain) + } + ) + + it('yields to a permission flag the caller keeps', () => { + const result = launch({ + agent: 'claude', + agentArgs: '--dangerously-skip-permissions', + sessionOptions: { permissionMode: 'ask' } + }) + expect(result.command).toBe("claude '--dangerously-skip-permissions'") + expect(result.appliedSessionOptions).toEqual({}) + }) +}) diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index 5d4ecb8ee80..887c9cbaa2f 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -6,7 +6,6 @@ import type { SessionOptionValue } from './native-chat-session-options' import { agentArgTerminatorIndex } from './agent-session-option-agent-args' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import { - planAgentCliArgsSuffix, quoteStartupArg, tokenizeStartupCommand, type AgentStartupShell @@ -22,7 +21,7 @@ export type ResolvedAgentLaunchCommand = | { ok: true command: string - commandWithoutSessionOptions: string + resumeLaunch: { agentCommand: string; agentArgs?: string } appliedSessionOptions: Record } | { ok: false; error: string } @@ -51,10 +50,6 @@ export function resolveAgentLaunchCommand(args: { const launchHead = suppressionArgs.length ? `${command} ${suppressionArgs.map((arg) => quoteStartupArg(arg, args.shell)).join(' ')}` : command - const suffix = planAgentCliArgsSuffix(args.agentArgs, args.shell) - if (!suffix.ok) { - return suffix - } const trailingTokens = args.agentArgs?.trim() ? tokenizeStartupCommand(args.agentArgs.trim(), args.shell) : { ok: true as const, tokens: [], spans: [] } @@ -65,7 +60,8 @@ export function resolveAgentLaunchCommand(args: { args.agent, args.sessionOptions, args.sessionOptionsOverrideAgentArgs ? [] : trailingTokens.tokens, - !args.sessionOptionsOverrideAgentArgs + !args.sessionOptionsOverrideAgentArgs, + trailingTokens.tokens ) if (override && args.sessionOptionsOverrideAgentArgs) { const overrideTokens = tokenizeStartupCommand(override, args.shell) @@ -86,31 +82,33 @@ export function resolveAgentLaunchCommand(args: { return { ok: false, error: - 'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.' + 'Agent command override conflicts with the requested launch preferences. Remove model, effort or permission flags from the command override.' } } } - const optionSuffix = resolvedOptions.args.map((arg) => quoteStartupArg(arg, args.shell)).join(' ') - const commandWithoutSessionOptions = suffix.suffix ? `${command} ${suffix.suffix}` : command - const commandWithOptions = optionSuffix ? `${launchHead} ${optionSuffix}` : launchHead - const overrideTokens = args.sessionOptionsOverrideAgentArgs - ? insertBeforeTerminator( - args.agent, - removeOverriddenAgentSessionArgs(args.agent, args.sessionOptions, trailingTokens.tokens), - resolvedOptions.args - ) - : [] - const commandWithOverrides = overrideTokens.length - ? `${launchHead} ${overrideTokens.map((token) => quoteStartupArg(token, args.shell)).join(' ')}` - : launchHead + const rawTokens = trailingTokens.tokens + const keptTokens = args.sessionOptionsOverrideAgentArgs + ? removeOverriddenAgentSessionArgs(args.agent, resolvedOptions.appliedValues, rawTokens) + : rawTokens + const render = (tokens: readonly string[]): string => + tokens.map((token) => quoteStartupArg(token, args.shell)).join(' ') + const withArgs = (suffix: string, head = command): string => (suffix ? `${head} ${suffix}` : head) + const initialTokens = args.sessionOptionsOverrideAgentArgs + ? insertBeforeTerminator(args.agent, keptTokens, resolvedOptions.args) + : [...resolvedOptions.args, ...keptTokens] + // Restart retains explicit policy and surviving raw pins, while providers restore model seeds. + const resumeArgs = render( + insertBeforeTerminator(args.agent, keptTokens, resolvedOptions.permissionArgs ?? []) + ) return { ok: true, - command: args.sessionOptionsOverrideAgentArgs - ? commandWithOverrides - : suffix.suffix - ? `${commandWithOptions} ${suffix.suffix}` - : commandWithOptions, - commandWithoutSessionOptions, + command: withArgs(render(initialTokens), launchHead), + resumeLaunch: { + agentCommand: withArgs(resumeArgs), + ...(keptTokens.length !== rawTokens.length || resolvedOptions.permissionArgs + ? { agentArgs: resumeArgs } + : {}) + }, appliedSessionOptions: resolvedOptions.appliedValues } } @@ -123,3 +121,32 @@ function insertBeforeTerminator( const terminator = agentArgTerminatorIndex(agent, tokens) return [...tokens.slice(0, terminator), ...inserted, ...tokens.slice(terminator)] } + +/** A launch's saved choice as terminal startup inputs: flags that replace conflicting raw ones, or, + * when it yields to these CLI arguments, only the pieces they do not set themselves. */ +export function terminalSessionOptionInputs(args: { + agent: TuiAgent + sessionOptions: Readonly> | undefined + yieldToAgentArgs?: boolean + agentArgs: string | null | undefined + shell: AgentStartupShell +}): { + sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: true +} { + const tokens = args.agentArgs?.trim() + ? tokenizeStartupCommand(args.agentArgs.trim(), args.shell) + : null + const sessionOptions = + args.sessionOptions && args.yieldToAgentArgs && tokens?.ok + ? resolveAgentSessionOptionLaunch( + args.agent, + { ...args.sessionOptions }, + tokens.tokens, + false + ).appliedValues + : args.sessionOptions && { ...args.sessionOptions } + return sessionOptions && Object.keys(sessionOptions).length > 0 + ? { sessionOptions, sessionOptionsOverrideAgentArgs: true } + : {} +} diff --git a/src/shared/tui-agent-launch-defaults.ts b/src/shared/tui-agent-launch-defaults.ts index e3c3d1f4ccf..d794dee90f7 100644 --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -5,6 +5,8 @@ import { isTuiAgent } from './tui-agent-config' import { YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from './tui-agent-permissions' import { agentPermissionArgs, + readLaunchPermissionChoice, + type AgentPermissionChoice, agentPermissionEnv, hasTypedAgentPermission, PERMISSION_CHOICE_AGENTS, @@ -33,6 +35,22 @@ export type AgentLaunchProfileSettings = Partial< > > +/** Launch intent overrides only Orca's injected permission, preserving the user's args and env. */ +export function agentLaunchPermissionSettings( + agent: TuiAgent, + settings: T, + sessionOptions: Readonly> | undefined +) { + const choice = readLaunchPermissionChoice(sessionOptions) + return choice + ? { + ...settings, + agentPermissionDefault: settings?.agentPermissionDefault ?? 'own', + agentPermissionOverrides: { ...settings?.agentPermissionOverrides, [agent]: choice } + } + : settings +} + // What an untouched agent ran with before permissions were stored as a choice; legacy text only. export const DEFAULT_TUI_AGENT_ARGS: Partial> & Readonly> = YOLO_TUI_AGENT_ARGS @@ -151,18 +169,39 @@ function ownArgs(agent: string, settings: AgentLaunchProfileSettings | null | un return typeof value === 'string' ? value : undefined } +/** Shared injection rule: CLI-owned permission text wins over an Orca choice. */ +export function agentPermissionArgsForLaunch( + agent: TuiAgent, + choice: AgentPermissionChoice, + text: string +): string | null { + return choice === 'own' + ? '' + : agentArgsSetPermission(agent, text) + ? null + : agentPermissionArgs(agent, choice) +} + /** Compose stored permission flags with user arguments, letting explicit user permission text win. Older host text already includes its flags. */ export function resolveTuiAgentLaunchArgs( agent: string, - settings: AgentLaunchProfileSettings | null | undefined + settings: AgentLaunchProfileSettings | null | undefined, + permissionChoice?: AgentPermissionChoice ): string { const own = ownArgs(agent, settings) - if (!hasTypedAgentPermission(settings) || !isTuiAgent(agent)) { + if ( + (!hasTypedAgentPermission(settings) && permissionChoice === undefined) || + !isTuiAgent(agent) + ) { return own ?? getTuiAgentDefaultArgs(agent) } const user = own?.trim() ?? '' - const flag = agentPermissionArgs(agent, resolveAgentPermission(agent, settings).choice) - if (!flag || agentArgsSetPermission(agent, user)) { + const flag = agentPermissionArgsForLaunch( + agent, + permissionChoice ?? resolveAgentPermission(agent, settings).choice, + user + ) + if (!flag) { return user } return user ? `${flag} ${user}` : flag diff --git a/src/shared/tui-agent-permission-choice.test.ts b/src/shared/tui-agent-permission-choice.test.ts index 09f4d3b4774..8e178ecf680 100644 --- a/src/shared/tui-agent-permission-choice.test.ts +++ b/src/shared/tui-agent-permission-choice.test.ts @@ -20,8 +20,16 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from './tui-agent-launch-defaults' -import { applyAllAgentsPermission, resolveAgentPermission } from './tui-agent-permission-choice' -import { agentArgsSetPermission, migrateAgentPermissionChoice } from './tui-agent-permission-lift' +import { + agentPermissionArgs, + applyAllAgentsPermission, + resolveAgentPermission +} from './tui-agent-permission-choice' +import { + agentArgsSetPermission, + liftAgentPermissionArgs, + migrateAgentPermissionChoice +} from './tui-agent-permission-lift' const legacy = ( args: Record, @@ -32,6 +40,16 @@ const legacy = ( }) describe('one-time move of permission flags out of Arguments', () => { + it('lifts the canonical read-only Codex Ask and uses it for stored Ask choices', () => { + const recipe = '-a on-request -s read-only -c approvals_reviewer=user' + expect(agentPermissionArgs('codex', 'ask')).toBe(recipe) + expect(liftAgentPermissionArgs('codex', `${recipe} --search`)).toEqual({ + choice: 'ask', + args: '--search' + }) + expect(resolveTuiAgentLaunchArgs('codex', { agentPermissionDefault: 'ask' })).toBe(recipe) + }) + it('imports Grok modes from recorded 1.0.62 help without taking ownership of other modes', () => { // Installed 1.0.62 (1474f9cbdd91) --help, permission-mode excerpt; not captured argv. const recordedHelp = ` --permission-mode @@ -715,7 +733,8 @@ it.each([false, true])('bounds actual startup/raw resume (%s)', (resume) => { providerSession: { key: 'session_id', id: 's' } }) ) - expect(plan?.launchConfig.agentCommand).toBe(`claude ${raw}`) + // Restart records surviving raw pins; the provider restores the generated model seed. + expect(plan?.launchConfig.agentCommand).toBe(resume ? 'claude' : `claude ${raw}`) expect(plan?.launchCommand).toBe(`claude ${resume ? "'--model' 'opus' '--resume' 's'" : raw}`) expect(work.visits).toBeLessThanOrEqual(40 * (2 * n + 1)) expectSettingsWorkScale(`startup-${resume}`, n, [work.visits]) diff --git a/src/shared/tui-agent-permission-choice.ts b/src/shared/tui-agent-permission-choice.ts index 5afde581c39..7c97e7ab5aa 100644 --- a/src/shared/tui-agent-permission-choice.ts +++ b/src/shared/tui-agent-permission-choice.ts @@ -36,7 +36,7 @@ const ASK_TUI_AGENT_ARGS: Partial> = { claude: '--permission-mode default', 'claude-agent-teams': '--permission-mode default', grok: '--permission-mode default', - codex: '-a on-request -s workspace-write -c approvals_reviewer=user' + codex: '-a on-request -s read-only -c approvals_reviewer=user' } const ACCEPT_EDITS_TUI_AGENT_ARGS: Partial> = { @@ -64,11 +64,32 @@ export function agentPermissionEnv( return choice === 'full-access' ? { ...YOLO_TUI_AGENT_ENV[agent] } : {} } +export function readLaunchPermissionChoice( + values: Readonly> | null | undefined +): AgentPermissionChoice | undefined { + const choice = values?.permissionMode ?? values?.mode + return isAgentPermissionChoice(choice) ? choice : undefined +} + +export function agentPermissionHasTerminalChoice( + agent: TuiAgent, + choice: AgentPermissionChoice +): boolean { + if (choice === 'own') { + return AGENT_PERMISSION_CHOICES.some( + (mode) => mode !== 'own' && agentPermissionHasTerminalChoice(agent, mode) + ) + } + return ( + agentPermissionArgs(agent, choice) !== '' || + Object.keys(agentPermissionEnv(agent, choice)).length > 0 + ) +} + function supportsChoice(agent: TuiAgent, choice: AgentPermissionChoice): boolean { return ( choice === 'own' || - agentPermissionArgs(agent, choice) !== '' || - Object.keys(agentPermissionEnv(agent, choice)).length > 0 || + agentPermissionHasTerminalChoice(agent, choice) || agentChatPermissionModes(agent)?.modes.includes(choice) === true ) } diff --git a/src/shared/tui-agent-resume-startup.ts b/src/shared/tui-agent-resume-startup.ts index a260274998b..a37d14c6ca4 100644 --- a/src/shared/tui-agent-resume-startup.ts +++ b/src/shared/tui-agent-resume-startup.ts @@ -60,7 +60,7 @@ export function buildAgentResumeStartupPlan(args: { ? ({ ok: true, command: resolvedAgentCommand, - commandWithoutSessionOptions: resolvedAgentCommand, + resumeLaunch: { agentCommand: resolvedAgentCommand }, appliedSessionOptions: {} } as const) : resolveAgentLaunchCommand({ @@ -79,7 +79,7 @@ export function buildAgentResumeStartupPlan(args: { const launchConfig = buildSleepingAgentLaunchConfig({ ...args, agentArgs: recorded.agentArgs, - agentCommand: baseCommand.commandWithoutSessionOptions + ...baseCommand.resumeLaunch }) const resumeCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell) const agentCwd = args.providerSession.cwd diff --git a/src/shared/tui-agent-startup-hermes.spec.ts b/src/shared/tui-agent-startup-hermes.spec.ts index 85254eeff7d..2fc17e0c2e7 100644 --- a/src/shared/tui-agent-startup-hermes.spec.ts +++ b/src/shared/tui-agent-startup-hermes.spec.ts @@ -20,6 +20,7 @@ describe('hermes startup plans', () => { const script = unwrapPosixShellScript(plan?.launchCommand) expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') + expect(plan?.launchConfig.agentArgs).toBe('--yolo') }) it.each([ diff --git a/src/shared/tui-agent-startup-session-options.spec.ts b/src/shared/tui-agent-startup-session-options.spec.ts index 63d7e9b7005..874d43003db 100644 --- a/src/shared/tui-agent-startup-session-options.spec.ts +++ b/src/shared/tui-agent-startup-session-options.spec.ts @@ -15,10 +15,11 @@ describe('tui agent startup session options', () => { platform: 'linux', allowEmptyPromptLaunch: true, sessionOptions: { model: 'opus', effort: 'xhigh', fastMode: true }, - agentArgs: '--model haiku' + agentArgs: ' --model haiku ' }) expect(plan?.launchCommand).toBe("claude '--model' 'haiku'") expect(plan?.sessionOptions).toBeUndefined() + expect(plan?.launchConfig.agentArgs).toBe(' --model haiku ') }) it('keeps the model record but drops an effort overridden by user arguments', () => { @@ -48,9 +49,7 @@ describe('tui agent startup session options', () => { expect(plan?.launchCommand).toBe( "codex '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high'" ) - expect(plan?.launchConfig.agentCommand).toBe( - "codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low'" - ) + expect(plan?.launchConfig).toMatchObject({ agentCommand: 'codex', agentArgs: '' }) expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' }) }) @@ -101,9 +100,10 @@ describe('tui agent startup session options', () => { agentArgs: '--share --model opencode/global-default' }) expect(plan?.launchCommand).toBe("opencode '--share' '--model' 'zai-coding-plan/glm-5.3-flash'") - expect(plan?.launchConfig.agentCommand).toBe( - "opencode '--share' '--model' 'opencode/global-default'" - ) + expect(plan?.launchConfig).toMatchObject({ + agentCommand: "opencode '--share'", + agentArgs: "'--share'" + }) expect(plan?.sessionOptions).toEqual({ model: 'zai-coding-plan/glm-5.3-flash' }) }) @@ -136,7 +136,7 @@ describe('tui agent startup session options', () => { ).toEqual({ ok: false, error: - 'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.' + 'Agent command override conflicts with the requested launch preferences. Remove model, effort or permission flags from the command override.' }) }) @@ -224,9 +224,7 @@ describe('tui agent startup session options', () => { expect(plan?.launchCommand).toBe( "codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high' 'resume' 'thread-1'" ) - expect(plan?.launchConfig.agentCommand).toBe( - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium'" - ) + expect(plan?.launchConfig).toMatchObject({ agentCommand: 'codex', agentArgs: '' }) expect(plan?.sessionOptions).toEqual({ model: 'gpt-5.5', effort: 'high' }) }) }) diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 1109e060dd4..a62c0847722 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -85,10 +85,7 @@ export function buildAgentStartupPlan(args: { : baseCommand const launchConfig = buildSleepingAgentLaunchConfig({ ...args, - // Picker flags apply once; resumed providers restore their own state and keep only explicit user args. - agentCommand: recordedCommand.ok - ? recordedCommand.commandWithoutSessionOptions - : baseCommand.commandWithoutSessionOptions + ...(recordedCommand.ok ? recordedCommand.resumeLaunch : baseCommand.resumeLaunch) }) if (!trimmedPrompt) { @@ -250,8 +247,7 @@ export function buildAgentDraftLaunchPlan(args: { agent === 'omp' ? withFreshOmpLaunch(baseCommand.command, shell) : baseCommand.command const launchConfig = buildSleepingAgentLaunchConfig({ ...args, - // Resume must not replay one-time picker flags. - agentCommand: baseCommand.commandWithoutSessionOptions + ...baseCommand.resumeLaunch }) let plan: AgentDraftLaunchPlan | null = null if (config.draftPromptFlag && shell === 'cmd' && /[\r\n]/.test(trimmed)) { diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts b/tests/e2e/cross-version-wire/agent-permission-legacy-spawn.unit.test.ts similarity index 58% rename from src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts rename to tests/e2e/cross-version-wire/agent-permission-legacy-spawn.unit.test.ts index 5b7e8df6f8f..3fe7c03fc8b 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts +++ b/tests/e2e/cross-version-wire/agent-permission-legacy-spawn.unit.test.ts @@ -1,16 +1,37 @@ +import { tokenizeStartupCommand } from '../../../src/shared/tui-agent-startup-shell' +import * as startupInputs from '../../../src/shared/agent-startup-plan-inputs' +import { resolveBaselineAgentStartupPlanInputs } from '../../../src/shared/agent-startup-plan-inputs-baseline-test-fixture' +import { + OrcaRuntimeService, + getDefaultWorkspaceSession +} from '../../../src/main/runtime/orca-runtime-test-mocks.spec' +import '../../../src/main/runtime/orca-runtime-test-lifecycle.spec' +import { + TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, + makeRuntimeStoreWithWorkspaceSession +} from '../../../src/main/runtime/orca-runtime-test-fixtures.spec' +import { committingSpawn } from '../../../src/main/runtime/bind-first-pane.test-fixture' +import type { RuntimePtyController } from '../../../src/main/runtime/runtime-pty-controller-contract' +import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' +import { SESSION_TAB_METHODS } from '../../../src/main/runtime/rpc/methods/session-tabs' +import { TERMINAL_LIFECYCLE_METHODS } from '../../../src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods' +import { AGENT_SESSION_METHODS } from '../../../src/main/runtime/rpc/methods/agent-session' +import { YOLO_TUI_AGENT_ENV } from '../../../src/shared/tui-agent-permissions' +import { createRuntimeAgentBackgroundTerminal } from '../../../src/renderer/src/lib/runtime-agent-background-create' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { launchAgentInWebHostTab } from '../lib/launch-agent-web-host-tab' -import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup' -import { createWebRuntimeSessionTerminal } from './web-runtime-terminal-creation' -import { resetWebSessionCloseIntentForTests } from './web-session-close-intent' +import { launchAgentInWebHostTab } from '../../../src/renderer/src/lib/launch-agent-web-host-tab' +import { buildAgentStartupPlan } from '../../../src/shared/tui-agent-startup' +import { createWebRuntimeSessionTerminal } from '../../../src/renderer/src/runtime/web-runtime-terminal-creation' +import { resetWebSessionCloseIntentForTests } from '../../../src/renderer/src/runtime/web-session-close-intent' import { ENVIRONMENT_ID, WORKTREE_ID, makeSnapshot, resetTerminalCreateEnvironment, stubTerminalCreateEnvironment -} from './web-runtime-session-test-harness' -import { stubRuntimeEnvironmentBridge } from './runtime-environment-bridge-fixture' +} from '../../../src/renderer/src/runtime/web-runtime-session-test-harness' +import { stubRuntimeEnvironmentBridge } from '../../../src/renderer/src/runtime/runtime-environment-bridge-fixture' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -33,7 +54,7 @@ const mocks = vi.hoisted(() => ({ hasMaterializedWebRuntimeBrowserPage: vi.fn() })) -vi.mock('../store', () => ({ +vi.mock('../../../src/renderer/src/store', () => ({ useAppStore: { getState: mocks.getState, setState: mocks.setState, @@ -41,23 +62,23 @@ vi.mock('../store', () => ({ } })) -vi.mock('./web-session-tabs-sync/tracking-lifecycle', () => ({ +vi.mock('../../../src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration })) -vi.mock('./web-session-tabs-sync/snapshot-api', () => ({ +vi.mock('../../../src/renderer/src/runtime/web-session-tabs-sync/snapshot-api', () => ({ applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot })) -vi.mock('./web-session-tabs-sync/tracking-decisions', () => ({ +vi.mock('../../../src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions', () => ({ decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot })) -vi.mock('./web-session-tabs-sync/store-patch', () => ({ +vi.mock('../../../src/renderer/src/runtime/web-session-tabs-sync/store-patch', () => ({ applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) return () => {} } })) -vi.mock('./web-session-tabs-sync/tracking-mappings', () => ({ +vi.mock('../../../src/renderer/src/runtime/web-session-tabs-sync/tracking-mappings', () => ({ resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) @@ -70,11 +91,15 @@ vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab })) -vi.mock('./web-runtime-browser-materialization', () => ({ +vi.mock('../../../src/renderer/src/runtime/web-runtime-browser-materialization', () => ({ hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage })) -afterEach(() => resetWebSessionCloseIntentForTests()) +const currentInputs = startupInputs.resolveAgentStartupPlanInputs +afterEach(() => { + resetWebSessionCloseIntentForTests() + vi.restoreAllMocks() +}) describe('createWebRuntimeSessionTerminal', () => { beforeEach(() => { @@ -85,90 +110,202 @@ describe('createWebRuntimeSessionTerminal', () => { resetTerminalCreateEnvironment() }) - it.each([false, true])( - 'sends the desktop launch settings for an empty-prompt quick launch (host authority: %s)', - async (hostAuthority) => { - const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { - if (request.method === 'status.get') { - return { - id: 'status', - ok: true, - result: { - runtimeId: 'runtime-1', - graphStatus: 'ready', - runtimeProtocolVersion: 3, - minCompatibleRuntimeClientVersion: 2, - capabilities: hostAuthority ? ['agent-session.host-authority.v1'] : [] + it.each([ + { agent: 'goose', mode: 'own', hostMode: 'full-access', authority: true, activate: true }, + { agent: 'goose', mode: 'full-access', hostMode: 'own', authority: true, activate: false }, + { + agent: 'goose', + mode: 'own', + hostMode: 'full-access', + authority: false, + activate: false, + userMode: 'approve' + }, + { agent: 'goose', mode: 'full-access', hostMode: 'own', authority: true, background: true }, + { agent: 'codex', mode: 'ask', hostMode: 'own', authority: true }, + { agent: 'claude', mode: 'ask', hostMode: 'own', authority: true, loose: true }, + { agent: 'goose', mode: 'own', hostMode: 'full-access', authority: true, fail: true } + ] as const)('preserves a complete launch at the pinned old spawn boundary: %j', async (test) => { + const settings = { + agentPermissionDefault: test.hostMode, + agentDefaultEnv: + test.mode === 'own' && !('userMode' in test) + ? {} + : { + [test.agent]: { + PROFILE: 'user', + ...('userMode' in test ? { GOOSE_MODE: test.userMode } : {}) + } } - } - } - if (request.method === 'terminal.createAgentSession') { - return { - id: 'create', - ok: true, - result: { - terminal: { handle: 'term-1', tabId: 'host-tab-1', paneKey: 'host-tab-1:leaf-1' }, - disposition: 'created' - } - } - } - if (request.method === 'session.tabs.createTerminal') { - return { id: 'create', ok: true, result: { tab: { id: 'host-tab-1' } } } - } - return { id: 'list', ok: true, result: makeSnapshot() } - }) - stubRuntimeEnvironmentBridge({ call: runtimeCall }) - const startupPlan = buildAgentStartupPlan({ - agent: 'claude', - prompt: '', - allowEmptyPromptLaunch: true, - cmdOverrides: {}, - agentArgs: '--model "model with spaces"', - agentEnv: { AGENT_PROFILE: 'custom' }, + } + const options = { + permissionMode: test.mode, + ...(test.agent === 'codex' ? { model: 'gpt-5.4', effort: 'high' } : {}) + } + const plan = buildAgentStartupPlan({ + ...currentInputs({ + agent: test.agent, + settings, platform: 'linux', - isRemote: true + isRemote: true, + shell: 'posix', + agentArgs: + 'loose' in test ? '--dangerously-skip-permissions --verbose' : '--profile "with spaces"', + sessionOptions: options + }), + prompt: '', + allowEmptyPromptLaunch: true + }) + if (!plan) { + throw new Error('Missing startup plan') + } + vi.spyOn(startupInputs, 'resolveAgentStartupPlanInputs').mockImplementation( + resolveBaselineAgentStartupPlanInputs + ) + const spawn = vi.fn( + async (_args: Parameters>[0]) => ({ + id: 'baseline-pty' }) - if (!startupPlan) { - throw new Error('Expected a startup plan') + ) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(getDefaultWorkspaceSession()) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getSettings: () => ({ ...runtimeStore.getSettings(), ...settings }) + }) + runtime.setPtyController({ + spawn: committingSpawn(runtime, spawn), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [...SESSION_TAB_METHODS, ...TERMINAL_LIFECYCLE_METHODS, ...AGENT_SESSION_METHODS] + }) + const state = mocks.getState() + mocks.getState.mockReturnValue({ + ...state, + setActiveTabType: vi.fn(), + activeTabTypeByWorktree: {} + }) + const creates: string[] = [] + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'baseline-660243e61ff', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: test.authority ? ['agent-session.host-authority.v1'] : [] + } + } } - - const created = await launchAgentInWebHostTab({ - agent: 'claude', - worktreeId: WORKTREE_ID, + if ( + request.method !== 'terminal.create' && + request.method !== 'terminal.createAgentSession' && + request.method !== 'session.tabs.createTerminal' + ) { + return { id: 'placement', ok: true, result: makeSnapshot() } + } + creates.push(request.method) + if (test.agent === 'goose') { + expect(request.params).toMatchObject({ + command: plan.launchCommand, + env: plan.env ?? {}, + launchConfig: plan.launchConfig, + ...('background' in test + ? { tabId: 'host-tab-1', leafId: 'leaf-1', presentation: 'background' } + : { + cwd: TEST_WORKTREE_PATH, + targetGroupId: 'group', + activate: false, + select: 'activate' in test ? test.activate : true + }) + }) + } + const response = await dispatcher.dispatch( + { id: 'create', authToken: 'test', method: request.method, params: request.params }, + { clientKind: 'runtime', clientId: 'paired-client' } + ) + if ('fail' in test) { + throw new Error('ambiguous create failure') + } + return response + }) + stubRuntimeEnvironmentBridge({ call: runtimeCall }) + if ('background' in test) { + await createRuntimeAgentBackgroundTerminal({ environmentId: ENVIRONMENT_ID, - startupPlan, + worktreeId: TEST_WORKTREE_ID, + tabId: 'host-tab-1', + leafId: 'leaf-1', + agent: test.agent, + sessionOptions: plan.sessionOptions, + legacy: { + command: plan.launchCommand, + env: plan.env ?? {}, + launchConfig: plan.launchConfig, + launchToken: 'token' + } + }) + } else { + const result = await launchAgentInWebHostTab({ + agent: test.agent, + worktreeId: TEST_WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + startupPlan: plan, prompt: '', promptDelivery: 'auto-submit', pastePromptAfterReady: null, submitPastedPrompt: false, - activate: false + activate: 'activate' in test ? test.activate : true, + cwd: TEST_WORKTREE_PATH, + groupId: 'group', + viewMode: 'terminal' }) - expect(created.outcome).toEqual({ status: 'created' }) - const createRequests = runtimeCall.mock.calls - .map(([request]) => request) - .filter( - (request) => request.method !== 'status.get' && request.method !== 'session.tabs.list' - ) - expect(createRequests).toHaveLength(1) - if (hostAuthority) { - expect(createRequests[0]).toMatchObject({ - method: 'terminal.createAgentSession', - params: { agent: 'claude', agentArgs: '--model "model with spaces"' } - }) - expect(createRequests[0].params).not.toHaveProperty('command') - } else { - expect(createRequests[0]).toMatchObject({ - method: 'session.tabs.createTerminal', - params: { - command: startupPlan.launchCommand, - env: startupPlan.env, - launchConfig: startupPlan.launchConfig, - launchAgent: startupPlan.agent - } - }) - } + expect(result.outcome.status).toBe('fail' in test ? 'failed' : 'created') } - ) + expect(creates).toEqual([ + test.agent === 'goose' + ? 'background' in test + ? 'terminal.create' + : 'session.tabs.createTerminal' + : 'terminal.createAgentSession' + ]) + expect(spawn).toHaveBeenCalledTimes(1) + const final = spawn.mock.calls[0][0] + expect(final.cwd).toBe(TEST_WORKTREE_PATH) + expect(final.env).toMatchObject(plan.env ?? {}) + expect(final.env?.GOOSE_MODE).toBe(plan.env?.GOOSE_MODE) + const expected = tokenizeStartupCommand(plan.launchCommand, 'posix') + if (final.command === undefined) { + throw new Error('Missing final command') + } + const actual = tokenizeStartupCommand(final.command, 'posix') + if (!expected.ok || !actual.ok) { + throw new Error('Invalid final command') + } + expect(actual.tokens.toSorted()).toEqual(expected.tokens.toSorted()) + if (test.agent === 'goose') { + expect(final.command).toBe(plan.launchCommand) + expect(final.launchConfig).toEqual(plan.launchConfig) + expect(final.startupCommandDelivery).toBe(plan.startupCommandDelivery) + expect(plan.env?.GOOSE_MODE).toBe( + 'userMode' in test + ? test.userMode + : test.mode === 'own' + ? undefined + : YOLO_TUI_AGENT_ENV.goose?.GOOSE_MODE + ) + } + if ('loose' in test) { + expect(plan.sessionOptions?.permissionMode).toBeUndefined() + } + }) it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => { const runtimeCall = vi diff --git a/tests/e2e/structured-agent-session-launch-admission.unit.test.ts b/tests/e2e/structured-agent-session-launch-admission.unit.test.ts index d2113347d4e..c65cf5678bf 100644 --- a/tests/e2e/structured-agent-session-launch-admission.unit.test.ts +++ b/tests/e2e/structured-agent-session-launch-admission.unit.test.ts @@ -21,7 +21,9 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('sonner', () => ({ toast: { info: mocks.toastInfo, error: mocks.toastError } })) -vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTabWithOutcome: mocks.launchAgentInNewTab +})) vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({ prepareAiVaultSessionForResume: async () => ({ sessionId: 'provider-1' }) })) @@ -214,7 +216,10 @@ describe('a structured chat launch on a paired server', () => { }) await useAppStore.getState().ensureNativeDetectedAgents() mocks.createSupport.mockResolvedValue({ supported: false }) - const openTerminal = vi.fn(() => ({ surface: { kind: 'host-published' as const } })) + const openTerminal = vi.fn(() => ({ + surface: { kind: 'host-published' as const }, + pasteDraftAfterLaunch: false + })) const declined = () => launchStructuredAgentFromNewTab({ plan: plan(), worktreeId, openTerminal }) await expect(declined()?.structuredSettlement).resolves.toMatchObject({ kind: 'failed' })