diff --git a/src/main/codex-cli/codex-read-only-app-server-args.ts b/src/main/codex-cli/codex-read-only-app-server-args.ts index 049905c98c01..524157ef23a5 100644 --- a/src/main/codex-cli/codex-read-only-app-server-args.ts +++ b/src/main/codex-cli/codex-read-only-app-server-args.ts @@ -11,9 +11,13 @@ export const CODEX_READ_ONLY_APP_SERVER_ARGS = [ // Short-lived probes (rate limits, model catalog); plugin startup can launch marketplace // clones that outlive the probe teardown. Other read-only app-servers retain // normal plugin behavior. -export const CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS = [ +export const CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS = [ '-c', 'approval_policy=never', - ...CODEX_DISABLE_PLUGINS_ARGS, + ...CODEX_DISABLE_PLUGINS_ARGS +] as const + +export const CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS = [ + ...CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS, ...CODEX_READ_ONLY_APP_SERVER_TAIL ] as const diff --git a/src/main/codex/codex-model-catalog-probe.test.ts b/src/main/codex/codex-model-catalog-probe.test.ts index a3aeeebc4af0..a06b065e8a77 100644 --- a/src/main/codex/codex-model-catalog-probe.test.ts +++ b/src/main/codex/codex-model-catalog-probe.test.ts @@ -38,6 +38,7 @@ describe('codex model catalog probe', () => { const probe = createCodexModelCatalogProbe({ resolveEnvironment, resolveCommand, + resolveLaunchArgs: () => [], runSession: async (invocation, body) => { invocations.push(invocation) return body({ @@ -67,6 +68,84 @@ describe('codex model catalog probe', () => { } ) + // The picker lists what a chat would run: the same saved Arguments, with the probe's own config + // after them, since Codex applies `-c` in order. + it('lists under the saved Arguments a chat launches with', async () => { + const invocations: CodexAppServerInvocation[] = [] + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: () => '/bin/codex', + resolveLaunchArgs: () => [ + '--dangerously-bypass-approvals-and-sandbox', + '-c', + 'model_provider=azure', + '-c', + 'features.plugins=true', + '-m', + 'gpt-azure' + ], + runSession: async (invocation, body) => { + invocations.push(invocation) + return body({ + request: async () => ({ data: [MODEL_ROW], nextCursor: null }), + notify: () => {} + }) + } + }) + await probe('/homes/a') + expect(invocations[0]!.args).toEqual([ + 'app-server', + '-c', + 'model_provider=azure', + '-c', + 'features.plugins=true', + '-c', + 'model="gpt-azure"', + '-c', + 'approval_policy=never', + '-c', + 'features.plugins=false', + '--disable', + 'plugins' + ]) + }) + + // Codex folds --enable/--disable in after every -c, so only a later --disable beats a saved + // --enable; a probe that starts plugins can leave marketplace clones behind. + it('keeps plugins off when the saved Arguments enable them', async () => { + const invocations: CodexAppServerInvocation[] = [] + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: () => '/bin/codex', + resolveLaunchArgs: () => ['--enable', 'plugins'], + runSession: async (invocation, body) => { + invocations.push(invocation) + return body({ + request: async () => ({ data: [MODEL_ROW], nextCursor: null }), + notify: () => {} + }) + } + }) + await probe('/homes/a') + const args = invocations[0]!.args + expect(args.slice(0, 3)).toEqual(['app-server', '--enable', 'plugins']) + expect(args.slice(-2)).toEqual(['--disable', 'plugins']) + }) + + it('lists nothing when the saved Arguments refuse a chat', async () => { + const runSession = vi.fn() + const probe = createCodexModelCatalogProbe({ + resolveEnvironment: async () => ({ PATH: '/bin' }), + resolveCommand: () => '/bin/codex', + resolveLaunchArgs: () => ['--profile', 'work'], + runSession + }) + await expect(probe('/homes/a')).rejects.toMatchObject({ + argumentProblem: { agent: 'Codex', option: '--profile', problem: 'unsupportedOption' } + }) + expect(runSession).not.toHaveBeenCalled() + }) + it('keeps the listing when config/read never answers', async () => { const server = String.raw` const readline = require('node:readline') @@ -82,6 +161,7 @@ describe('codex model catalog probe', () => { const probe = createCodexModelCatalogProbe({ resolveEnvironment: async () => ({ PATH: '/bin' }), resolveCommand: () => '/bin/codex', + resolveLaunchArgs: () => [], // Real transport against a fake server; a session deadline shorter than the // production one keeps the test fast while still outliving config/read's bound. runSession: (invocation, body) => @@ -106,6 +186,7 @@ describe('codex model catalog probe', () => { const probe = createCodexModelCatalogProbe({ resolveEnvironment: async () => ({ PATH: '/bin' }), resolveCommand: () => '/bin/codex', + resolveLaunchArgs: () => [], runSession: async (_invocation, body) => body({ request: async () => ({ data: [], nextCursor: null }), notify: () => {} }) }) @@ -124,6 +205,7 @@ describe('codex model catalog probe', () => { { agentCmdOverrides: { codex: command } }, options ), + resolveLaunchArgs: () => [], runSession }) await expect(probe('/homes/a')).rejects.toMatchObject({ reason: 'agentCommandNotRunnable' }) diff --git a/src/main/codex/codex-model-catalog-probe.ts b/src/main/codex/codex-model-catalog-probe.ts index 66def74598c2..fe16b3106289 100644 --- a/src/main/codex/codex-model-catalog-probe.ts +++ b/src/main/codex/codex-model-catalog-probe.ts @@ -1,4 +1,5 @@ -import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' +import { CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS } from '../codex-cli/codex-read-only-app-server-args' +import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { runCodexAppServerSession } from './codex-app-server-session' import { fetchCodexModelCatalogListing } from './codex-structured-model-catalog' import { @@ -16,7 +17,7 @@ const CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS = 15_000 export type CodexModelCatalogProbeDeps = Pick< CodexStructuredLaunchResolverDeps, - 'resolveCommand' | 'resolveEnvironment' + 'resolveCommand' | 'resolveEnvironment' | 'resolveLaunchArgs' > & { /** Test seam; production runs the shared short-lived app-server session. */ runSession?: typeof runCodexAppServerSession @@ -33,21 +34,32 @@ function definedEnv(env: NodeJS.ProcessEnv | undefined): Record } /** - * Lists models without a live session: one short-lived read-only app-server + * Lists models without a live session: one short-lived app-server * under the given account home, spawned through the SAME invocation resolver - * a structured session launch uses — a probe that resolved a different binary - * or env could list models the user's sessions cannot see, under their key. + * and saved Arguments a structured session launch uses — a probe that resolved + * a different binary, env or config could list models the user's sessions + * cannot see, under their key. */ export function createCodexModelCatalogProbe( deps: CodexModelCatalogProbeDeps ): AgentModelCatalogProbe { return async (accountHomePath: string): Promise => { const { command, environment } = await resolveCodexStructuredInvocation(deps) + const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs()) const run = deps.runSession ?? runCodexAppServerSession const listing = await run( { command, - args: [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS], + // The probe's own config comes after the Arguments' `-c`, which Codex applies in order. Codex + // folds `--enable`/`--disable` in after every `-c`, disables last, so only `--disable` + // outranks a saved `--enable plugins`. + args: [ + 'app-server', + ...args, + ...CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS, + '--disable', + 'plugins' + ], cliPath: command, env: { ...definedEnv(environment), CODEX_HOME: accountHomePath }, timeoutMs: CODEX_MODEL_CATALOG_PROBE_TIMEOUT_MS diff --git a/src/main/codex/codex-short-lived-app-server-spawn.test.ts b/src/main/codex/codex-short-lived-app-server-spawn.test.ts index fdd3733225d4..f6d044d3f247 100644 --- a/src/main/codex/codex-short-lived-app-server-spawn.test.ts +++ b/src/main/codex/codex-short-lived-app-server-spawn.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' +import { + CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS, + CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS +} from '../codex-cli/codex-read-only-app-server-args' import type { CodexAppServerInvocation } from './codex-app-server-session' const NPM_CODEX_SHIM = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd' @@ -37,6 +40,7 @@ describe('short-lived Codex app-server invocations on Windows', () => { const probe = createCodexModelCatalogProbe({ resolveEnvironment: async () => ({ PATH: 'C:\\bin' }), resolveCommand: () => NPM_CODEX_SHIM, + resolveLaunchArgs: () => [], runSession: async (invocation, body) => { invocations.push(invocation) return body({ @@ -58,6 +62,11 @@ describe('short-lived Codex app-server invocations on Windows', () => { }) await probe('C:\\homes\\a') expect(invocations[0]?.command).toBe(NPM_CODEX_SHIM) - expect(invocations[0]?.args).toEqual([...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]) + expect(invocations[0]?.args).toEqual([ + 'app-server', + ...CODEX_SHORT_LIVED_PROBE_CONFIG_ARGS, + '--disable', + 'plugins' + ]) }) }) diff --git a/src/main/codex/codex-structured-launch-args.test.ts b/src/main/codex/codex-structured-launch-args.test.ts index 0b156acdd703..8fa9e1500e87 100644 --- a/src/main/codex/codex-structured-launch-args.test.ts +++ b/src/main/codex/codex-structured-launch-args.test.ts @@ -3,77 +3,117 @@ import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error' describe('codexStructuredLaunchArgs', () => { - it('preserves root config and feature option order', () => { + it('passes config, feature and strict-config options in order', () => { expect( codexStructuredLaunchArgs([ '-c', 'model_reasoning_effort=high', '--enable', 'unified_exec', - '--config=web_search="live"', + '--config=web_search="cached"', '--disable=some_feature', - '-m', - 'gpt-5.6-sol' + '-c=model_provider=azure', + '--strict-config' ]) ).toEqual([ '-c', 'model_reasoning_effort=high', '--enable', 'unified_exec', - '--config=web_search="live"', - '--disable=some_feature', - '-m', - 'gpt-5.6-sol' + '--config', + 'web_search="cached"', + '--disable', + 'some_feature', + '-c', + 'model_provider=azure', + '--strict-config' + ]) + }) + + // app-server ignores `-m` and `--search`; Codex's own equivalents are config it applies after `-c`. + it('turns -m and --search into the config Codex derives from them, after the user config', () => { + expect( + codexStructuredLaunchArgs(['-m', 'gpt-5.6 "sol"', '--search', '-c', 'model="other"']) + ).toEqual(['-c', 'model="other"', '-c', 'model="gpt-5.6 \\"sol\\""', '-c', 'web_search="live"']) + expect(codexStructuredLaunchArgs(['--model=gpt-5.6-sol'])).toEqual([ + '-c', + 'model="gpt-5.6-sol"' ]) }) - it('drops permission options and config overrides owned by Orca', () => { + // Agent Permissions owns the posture, so neither the options nor their config keys reach Codex. + it('drops permission options and the config that sets permissions', () => { expect( codexStructuredLaunchArgs([ - '--dangerously-bypass-approvals-and-sandbox', + '-s', + 'read-only', + '--sandbox=bogus', '-a', - 'never', - '--sandbox=danger-full-access', + 'untrusted', + '--ask-for-approval=on-failure', '--approve-for-me', + '--not-so-yolo', '-c', 'approval_policy="never"', - '--config=sandbox_mode="danger-full-access"', + '--config', + 'sandbox_mode=danger-full-access', '-c', - 'sandbox_workspace_write.writable_roots=["/tmp"]', + 'approvals_reviewer="auto_review"', '-c', - '"sandbox_workspace_write".writable_roots=["/tmp"]', + 'sandbox_workspace_write.network_access=true', '-c', - '\'approval_policy\'="never"', + '"approval_policy" = "never"', '-c', 'model_reasoning_effort=high' ]) ).toEqual(['-c', 'model_reasoning_effort=high']) }) - it('drops a profile and TUI-only path options without leaving their values as prompts', () => { - expect( - codexStructuredLaunchArgs([ - '--profile', - 'review', - '-pother', - '--cd', - '/another/repo', - '--worktree', - '-c', - 'model_reasoning_effort=high' - ]) - ).toEqual(['-c', 'model_reasoning_effort=high']) + it('still needs a value for a dropped permission option', () => { + expect(() => codexStructuredLaunchArgs(['-s'])).toThrow(StructuredAgentArgumentsError) + expect(() => codexStructuredLaunchArgs(['-a', '--search'])).toThrow( + expect.objectContaining({ + argumentProblem: { agent: 'Codex', option: '-a', problem: 'missingValue' } + }) + ) }) - it('discards operands after the option terminator', () => { + it('drops the bypass flag and terminal-only display options', () => { expect( - codexStructuredLaunchArgs(['-c', 'model_reasoning_effort=high', '--', 'a prompt']) - ).toEqual(['-c', 'model_reasoning_effort=high']) + codexStructuredLaunchArgs([ + '--dangerously-bypass-approvals-and-sandbox', + '--yolo', + '--no-alt-screen', + '--no-daemon', + '-h', + '--version', + '--' + ]) + ).toEqual([]) }) it.each([ { tokens: ['a private prompt'], option: 'prompt', problem: 'positionalPrompt' }, { tokens: ['app-server'], option: 'prompt', problem: 'positionalPrompt' }, + { tokens: ['-'], option: 'prompt', problem: 'positionalPrompt' }, + { tokens: ['--', 'a private prompt'], option: 'prompt', problem: 'positionalPrompt' }, + { tokens: ['--profile', 'private'], option: '--profile', problem: 'unsupportedOption' }, + { tokens: ['-pprivate'], option: '-p', problem: 'unsupportedOption' }, + { tokens: ['--oss'], option: '--oss', problem: 'unsupportedOption' }, + { + tokens: ['--local-provider', 'private'], + option: '--local-provider', + problem: 'unsupportedOption' + }, + { tokens: ['--add-dir', '/private'], option: '--add-dir', problem: 'unsupportedOption' }, + { tokens: ['-C', '/private'], option: '-C', problem: 'unsupportedOption' }, + { tokens: ['--image=private.png'], option: '--image', problem: 'unsupportedOption' }, + { tokens: ['--worktree'], option: '--worktree', problem: 'unsupportedOption' }, + { + tokens: ['--dangerously-bypass-hook-trust'], + option: '--dangerously-bypass-hook-trust', + problem: 'unsupportedOption' + }, { tokens: ['--remote', 'wss://private-host'], option: '--remote', @@ -86,8 +126,10 @@ describe('codexStructuredLaunchArgs', () => { problem: 'unsupportedOption' }, { tokens: ['--unknown-flag=secret'], option: '--unknown-flag', problem: 'unsupportedOption' }, - { tokens: ['--enable'], option: '--enable', problem: 'missingValue' } - ] as const)('rejects unsafe or incomplete arguments: %j', ({ tokens, option, problem }) => { + { tokens: ['--search=secret'], option: '--search', problem: 'unsupportedOption' }, + { tokens: ['--enable'], option: '--enable', problem: 'missingValue' }, + { tokens: ['-m', '--secret'], option: '-m', problem: 'missingValue' } + ] as const)('refuses what a chat cannot honor: %j', ({ tokens, option, problem }) => { const thrown = () => codexStructuredLaunchArgs(tokens) expect(thrown).toThrow(StructuredAgentArgumentsError) try { @@ -96,8 +138,10 @@ describe('codexStructuredLaunchArgs', () => { expect(error).toMatchObject({ argumentProblem: { agent: 'Codex', option, problem } }) - expect(JSON.stringify(error)).not.toContain('secret') - expect(JSON.stringify(error)).not.toContain('private') + for (const text of [JSON.stringify(error), String(error)]) { + expect(text).not.toContain('secret') + expect(text).not.toContain('private') + } } }) }) diff --git a/src/main/codex/codex-structured-launch-args.ts b/src/main/codex/codex-structured-launch-args.ts index 90b39fffec81..64b132822803 100644 --- a/src/main/codex/codex-structured-launch-args.ts +++ b/src/main/codex/codex-structured-launch-args.ts @@ -1,115 +1,143 @@ -// Only root CLI options can precede `app-server`. A prompt or another subcommand -// would change the process Orca starts, and permission options belong to Orca. +// Saved Arguments are written for the interactive `codex` a terminal runs; a chat runs +// `codex app-server`, which reads only config and its own few options. So each interactive option +// either passes to app-server, becomes the config Codex itself derives from it, is dropped because +// it sets permissions (Agent Permissions owns those) or only shapes a terminal, or refuses the +// start by name because a chat can't honor it. import { parseTomlKeyPath } from './config-toml-key-path' import { StructuredAgentArgumentsError } from '../native-chat/structured-agent-arguments-error' -const VALUE_OPTIONS = new Set([ - '-c', - '--config', - '--enable', - '--disable', - '-m', - '--model', - '--local-provider' -]) +type LaunchPlan = { + args: string[] + /** Config the interactive CLI derives from its own options; it applies them after every `-c`. */ + derived: string[] +} + +type OptionRule = + | { takesValue: boolean; apply?: (plan: LaunchPlan, option: string, value: string) => void } + | 'refuse' + +const pass = (plan: LaunchPlan, option: string, value: string): void => { + plan.args.push(option, ...(value ? [value] : [])) +} + +// The permission options' own config keys, dropped with them. +const passConfig = (plan: LaunchPlan, option: string, value: string): void => { + const key = parseTomlKeyPath(value)?.segments[0] + if ( + key !== 'approval_policy' && + key !== 'sandbox_mode' && + key !== 'approvals_reviewer' && + key !== 'sandbox_workspace_write' + ) { + pass(plan, option, value) + } +} -const DROPPED_VALUE_OPTIONS = new Set([ - '-a', - '--ask-for-approval', - '-s', - '--sandbox', - '-p', - '--profile', - '-C', - '--cd', - '--add-dir', - '-i', - '--image' -]) +const derive = + (...overrides: string[]) => + (plan: LaunchPlan): void => { + plan.derived.push(...overrides) + } -const DROPPED_SWITCHES = new Set([ - '--dangerously-bypass-approvals-and-sandbox', - '--yolo', - '--approve-for-me', - '--not-so-yolo', - '--dangerously-bypass-hook-trust', - '--worktree', - '--strict-config', - '--help', - '-h', - '--version', - '-V' -]) +const deriveModel = (plan: LaunchPlan, _option: string, value: string): void => { + // A JSON string is a valid TOML basic string. + plan.derived.push(`model=${JSON.stringify(value)}`) +} -const PASSTHROUGH_SWITCHES = new Set(['--search', '--oss', '--no-alt-screen', '--no-daemon']) +const VALUE = { takesValue: true } +const DROPPED_SWITCH = { takesValue: false } + +// Equivalents are the ones Codex applies itself: `--search` in codex-rs cli/src/main.rs, `-m` as +// `-c model=`. +const RULES: Record = { + '-c': { ...VALUE, apply: passConfig }, + '--config': { ...VALUE, apply: passConfig }, + '--enable': { ...VALUE, apply: pass }, + '--disable': { ...VALUE, apply: pass }, + '--strict-config': { takesValue: false, apply: pass }, + '-m': { ...VALUE, apply: deriveModel }, + '--model': { ...VALUE, apply: deriveModel }, + '--search': { takesValue: false, apply: derive('web_search="live"') }, + // Permissions come from Agent Permissions alone; the rest only shape a terminal. + '-s': VALUE, + '--sandbox': VALUE, + '-a': VALUE, + '--ask-for-approval': VALUE, + '--approve-for-me': DROPPED_SWITCH, + '--not-so-yolo': DROPPED_SWITCH, + '--dangerously-bypass-approvals-and-sandbox': DROPPED_SWITCH, + '--yolo': DROPPED_SWITCH, + '--no-alt-screen': DROPPED_SWITCH, + '--no-daemon': DROPPED_SWITCH, + '--help': DROPPED_SWITCH, + '-h': DROPPED_SWITCH, + '--version': DROPPED_SWITCH, + '-V': DROPPED_SWITCH, + // No app-server equivalent: a different config, provider, server, folder or first prompt. + '-p': 'refuse', + '--profile': 'refuse', + '--oss': 'refuse', + '--local-provider': 'refuse', + '--remote': 'refuse', + '--remote-auth-token-env': 'refuse', + '--add-dir': 'refuse', + '-C': 'refuse', + '--cd': 'refuse', + '-i': 'refuse', + '--image': 'refuse', + '--worktree': 'refuse', + '--dangerously-bypass-hook-trust': 'refuse' +} function optionName(token: string): string { if (token.startsWith('--')) { return token.split('=', 1)[0] } - if (/^-[acmspiC].+/.test(token)) { - return token.slice(0, 2) - } - return token + return /^-[acmspiC].+/.test(token) ? token.slice(0, 2) : token } +// Codex's parser accepts `-c=k=v` as well as `-ck=v`. function inlineValue(token: string, option: string): string | undefined { if (token === option) { return undefined } - return token.startsWith('--') ? token.slice(option.length + 1) : token.slice(2) + return token.startsWith('--') ? token.slice(option.length + 1) : token.slice(2).replace(/^=/, '') } -function isPermissionConfig(value: string): boolean { - const key = parseTomlKeyPath(value)?.segments[0] - return ( - key === 'approval_policy' || - key === 'sandbox_mode' || - key === 'approvals_reviewer' || - key === 'sandbox_workspace_write' - ) -} - -/** Turns saved terminal Arguments into root options for a structured app-server launch. */ +/** Turns saved terminal Arguments into options for after `app-server`. */ export function codexStructuredLaunchArgs(tokens: readonly string[]): string[] { - const args: string[] = [] + const plan: LaunchPlan = { args: [], derived: [] } for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index] if (token === '--') { + // What follows is the terminal's first prompt. + if (index + 1 < tokens.length) { + throw new StructuredAgentArgumentsError('Codex', token, 'positionalPrompt') + } break } + if (!token.startsWith('-') || token === '-') { + throw new StructuredAgentArgumentsError('Codex', token, 'positionalPrompt') + } const option = optionName(token) - if (option === '--remote' || option === '--remote-auth-token-env') { + const rule = RULES[option] + if (!rule || rule === 'refuse') { throw new StructuredAgentArgumentsError('Codex', option, 'unsupportedOption') } - if (DROPPED_SWITCHES.has(option)) { - continue - } - if (PASSTHROUGH_SWITCHES.has(option) && token === option) { - args.push(token) - continue - } - if (VALUE_OPTIONS.has(option) || DROPPED_VALUE_OPTIONS.has(option)) { - const value = inlineValue(token, option) ?? tokens[++index] - if (!value || value === '--' || (value.startsWith('-') && !token.includes('='))) { + let value = '' + if (rule.takesValue) { + const inline = inlineValue(token, option) + value = inline ?? tokens[++index] ?? '' + if (!value || value === '--' || (value.startsWith('-') && inline === undefined)) { throw new StructuredAgentArgumentsError('Codex', option, 'missingValue') } - if ( - DROPPED_VALUE_OPTIONS.has(option) || - (['-c', '--config'].includes(option) && isPermissionConfig(value)) - ) { - continue - } - args.push(token) - if (token === option) { - args.push(value) - } - continue - } - if (!token.startsWith('-')) { - throw new StructuredAgentArgumentsError('Codex', token, 'positionalPrompt') + } else if (token !== option) { + throw new StructuredAgentArgumentsError('Codex', option, 'unsupportedOption') } - throw new StructuredAgentArgumentsError('Codex', token, 'unsupportedOption') + rule.apply?.(plan, option, value) + } + for (const override of plan.derived) { + pass(plan, '-c', override) } - return args + return plan.args } diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index b47599113281..08ac7970fe73 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -96,9 +96,9 @@ describe('codex structured launch resolution', () => { await expect(resolve({ identity: IDENTITY })).rejects.toThrow(/Arguments/) args = ['--enable', 'unified_exec'] expect((await resolve({ identity: IDENTITY })).args).toEqual([ + 'app-server', '--enable', - 'unified_exec', - 'app-server' + 'unified_exec' ]) args = [] expect((await resolve({ identity: IDENTITY })).args).toEqual(['app-server']) @@ -277,29 +277,33 @@ describe('codex structured launch resolution', () => { expect(launch.model).toBe('gpt-chosen') }) - it('uses saved arguments before app-server on a fresh launch', async () => { + it('uses saved arguments after app-server on a fresh launch', async () => { const launch = await resolverFor( record({ - launchArgs: [ - '--profile', - 'review', - '-c', - 'model_reasoning_effort=high', - '--model', - 'gpt-5.6-sol' - ] + launchArgs: ['-c', 'model_reasoning_effort=high', '--model', 'gpt-5.6-sol'] }) )({ identity: IDENTITY }) expect(launch.args).toEqual([ + 'app-server', '-c', 'model_reasoning_effort=high', - '--model', - 'gpt-5.6-sol', - 'app-server' + '-c', + 'model="gpt-5.6-sol"' ]) }) + // Permissions come from Agent Permissions, never from Arguments' own permission options. + it('keeps permission options in Arguments out of the app-server launch', async () => { + const launch = await resolverFor( + record({ + launchArgs: ['-s', 'read-only', '-c', 'approval_policy=untrusted', '--approve-for-me'] + }) + )({ identity: IDENTITY }) + + expect(launch.args).toEqual(['app-server']) + }) + it('pins resume to the rollout file that proved the durable thread', async () => { const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl') const launch = await resolverFor( @@ -317,11 +321,11 @@ describe('codex structured launch resolution', () => { expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current') expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl') expect(launch.args).toEqual([ + 'app-server', '--enable', 'unified_exec', '-c', - 'model_reasoning_effort=high', - 'app-server' + 'model_reasoning_effort=high' ]) }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 183f765f5c86..150793f3fb89 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -93,7 +93,8 @@ export function createCodexStructuredLaunchResolver( const model = record.options?.model return { command, - args: [...args, 'app-server'], + // After the subcommand, so an option app-server doesn't take fails loudly, not silently. + args: ['app-server', ...args], cwd: await resolveAgentSessionLaunchDirectory(deps, record), codexHome: accountHome.path, ...(environment ? { env: { ...environment } as Record } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 263de102f532..8b8a88e514e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -336,7 +336,7 @@ describe('a start the chat needed and did not get', () => { const id = await accept('first') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) const sentence = - "Codex couldn't restart. Saved Arguments contain an unsupported option (--unsafe). Edit them in Settings > Agents > Arguments. Send your message to try again." + "Codex couldn't restart. Codex chats can't use --unsafe from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. Send your message to try again." expect(await errorRows()).toEqual([sentence]) expect(await submission(id)).toMatchObject({ reason: sentence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts index c49b79b06e64..a074e40e7e47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.test.ts @@ -39,7 +39,7 @@ describe('structuredAgentSessionStartFailure', () => { } as const expect(structuredAgentSessionStartFailure({ error: wrapped }, { agentName: 'Codex' })).toEqual({ reason: - "Codex couldn't start. Saved Arguments contain an unsupported option (--unknown). Edit them in Settings > Agents > Arguments. Send your message to try again.", + "Codex couldn't start. Codex chats can't use --unknown from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. Send your message to try again.", rejection: { kind: 'startFailed', argumentProblem } }) const refusal = refuseUnclassified('agent_session_operation_invalid', 'generic start failure') @@ -47,7 +47,7 @@ describe('structuredAgentSessionStartFailure', () => { structuredAgentSessionStartFailure({ refusal, argumentProblem }, { agentName: 'Codex' }) ).toEqual({ reason: - "Codex couldn't restart. Saved Arguments contain an unsupported option (--unknown). Edit them in Settings > Agents > Arguments. Send your message to try again.", + "Codex couldn't restart. Codex chats can't use --unknown from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. Send your message to try again.", rejection: { kind: 'restartFailed', argumentProblem, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index a0f996c80b12..fdeddc0ce986 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -122,7 +122,7 @@ describe('a create that fails before any process spawns', () => { ) ) const sentence = - "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." + "Claude couldn't start. Saved Arguments give --model more than one value. Fix it in Claude's Arguments in Settings → Agents. Send your message to try again." for (const result of [first, replay]) { expect(result).toMatchObject({ ok: false, diff --git a/src/main/native-chat/structured-agent-arguments-error.ts b/src/main/native-chat/structured-agent-arguments-error.ts index f6fca945307d..05a2911d5083 100644 --- a/src/main/native-chat/structured-agent-arguments-error.ts +++ b/src/main/native-chat/structured-agent-arguments-error.ts @@ -1,4 +1,5 @@ import type { AgentSessionArgumentProblem } from '../../shared/agent-session-failure' +import { AGENT_SESSION_ARGUMENT_PROBLEM_WORD } from '../../shared/agent-session-argument-problem' /** A saved Arguments refusal with only the option name, never its value. */ export class StructuredAgentArgumentsError extends Error { @@ -10,11 +11,10 @@ export class StructuredAgentArgumentsError extends Error { problem: AgentSessionArgumentProblem['problem'] ) { const normalizedOption = - problem === 'positionalPrompt' - ? 'prompt' - : (option.match(/^--[a-zA-Z][a-zA-Z0-9-]{0,63}/)?.[0] ?? - option.match(/^-[a-zA-Z]/)?.[0] ?? - '--?') + AGENT_SESSION_ARGUMENT_PROBLEM_WORD[problem] ?? + option.match(/^--[a-zA-Z][a-zA-Z0-9-]{0,63}/)?.[0] ?? + option.match(/^-[a-zA-Z]/)?.[0] ?? + '--?' super(`${agent} structured chat cannot use ${normalizedOption} in saved Arguments`) this.name = 'StructuredAgentArgumentsError' this.argumentProblem = { agent, option: normalizedOption, problem } diff --git a/src/main/native-chat/structured-agent-configured-args.test.ts b/src/main/native-chat/structured-agent-configured-args.test.ts index 58823a406088..3db4cd3a9d35 100644 --- a/src/main/native-chat/structured-agent-configured-args.test.ts +++ b/src/main/native-chat/structured-agent-configured-args.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from 'vitest' import { structuredAgentConfiguredArgs } from './structured-agent-configured-args' +import { + StructuredAgentArgumentsError, + argumentProblemOf +} from './structured-agent-arguments-error' + +function captured(run: () => unknown): unknown { + try { + run() + } catch (error) { + return error + } + return undefined +} describe('structured chat configured Arguments', () => { it.each(['claude', 'codex'] as const)( @@ -37,11 +50,19 @@ describe('structured chat configured Arguments', () => { ).toEqual(['--plugin-dir', String.raw`C:\My Plugins`]) }) - it('refuses unclosed quotes without spawning', () => { - expect(() => - structuredAgentConfiguredArgs('claude', { - agentDefaultArgs: { claude: '--model "unfinished' } + it.each([ + ['claude', 'Claude'], + ['codex', 'Codex'] + ] as const)('refuses unclosed %s quotes as a saved Arguments problem', (agent, agentName) => { + const read = () => + structuredAgentConfiguredArgs(agent, { + agentDefaultArgs: { [agent]: '--model "unfinished secret' } }) - ).toThrow('Arguments are invalid') + expect(read).toThrow(StructuredAgentArgumentsError) + expect(argumentProblemOf(captured(read))).toEqual({ + agent: agentName, + option: 'quote', + problem: 'unclosedQuote' + }) }) }) diff --git a/src/main/native-chat/structured-agent-configured-args.ts b/src/main/native-chat/structured-agent-configured-args.ts index 3b60c7642680..39b564188a4b 100644 --- a/src/main/native-chat/structured-agent-configured-args.ts +++ b/src/main/native-chat/structured-agent-configured-args.ts @@ -3,6 +3,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveTuiAgentLaunchArgs } from '../../shared/tui-agent-launch-defaults' import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import { StructuredAgentArgumentsError } from './structured-agent-arguments-error' /** Use the same grouping rules as terminal launches before the provider filters its owned flags. */ export function structuredAgentConfiguredArgs( @@ -20,8 +21,13 @@ export function structuredAgentConfiguredArgs( resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), shell ) + // An unclosed quote is the tokenizer's only failure. if (!parsed.ok) { - throw new Error(`${agent} Arguments are invalid: ${parsed.error}`) + throw new StructuredAgentArgumentsError( + agent === 'codex' ? 'Codex' : 'Claude', + 'quote', + 'unclosedQuote' + ) } return parsed.tokens } diff --git a/src/main/runtime/structured-agent-model-catalog-wiring.ts b/src/main/runtime/structured-agent-model-catalog-wiring.ts index 0b537dd04893..80a6f70a7fc1 100644 --- a/src/main/runtime/structured-agent-model-catalog-wiring.ts +++ b/src/main/runtime/structured-agent-model-catalog-wiring.ts @@ -55,6 +55,7 @@ export async function modelCatalogHostDeps(input: { | 'resolveClaudeCommand' | 'resolveClaudeLaunchEnv' | 'resolveClaudeAuthPolicy' + | 'resolveLaunchArgs' > envResolvers: { resolveCodexEnvironment: NonNullable @@ -77,6 +78,7 @@ export async function modelCatalogHostDeps(input: { probes: { codex: createCodexModelCatalogProbe({ resolveEnvironment: input.envResolvers.resolveCodexEnvironment, + resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) }), claude: createClaudeModelCatalogProbe({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 4be08aa86945..59b4f33b4953 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -179,7 +179,7 @@ describe('NativeChatStructuredSession launch lifecycle', () => { expect( screen.getByText( - "Codex couldn't start. Saved Arguments contain an unsupported option (--remote). Edit them in Settings > Agents > Arguments." + "Codex couldn't start. Codex chats can't use --remote from saved Arguments. Remove it from Codex's Arguments in Settings → Agents." ) ).toBeTruthy() expect(screen.queryByText('Chat could not be started.')).toBeNull() diff --git a/src/renderer/src/components/native-chat/agent-session-arguments-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-arguments-failure-words-text.ts new file mode 100644 index 000000000000..0d5eba118acf --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-arguments-failure-words-text.ts @@ -0,0 +1,82 @@ +// Desktop words for a saved Arguments refusal, one piece each of the shared sentence. + +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_FAILURE_COPY as COPY, + type AgentSessionFailureCopyId, + type AgentSessionFailureCopyValues +} from '../../../../shared/agent-session-failure-copy' + +type SavedArgumentsCopyId = Extract< + AgentSessionFailureCopyId, + `arguments${string}` | 'removeFromSavedArguments' | 'editSavedArguments' +> + +export const SAVED_ARGUMENTS_PIECES: Record< + SavedArgumentsCopyId, + (values: AgentSessionFailureCopyValues) => string +> = { + argumentsUnsupportedOption: (values) => + translate( + 'components.native-chat.failureWords.argumentsUnsupportedOption', + COPY.argumentsUnsupportedOption, + values + ), + argumentsUnnamedOption: (values) => + translate( + 'components.native-chat.failureWords.argumentsUnnamedOption', + COPY.argumentsUnnamedOption, + values + ), + argumentsMissingValue: (values) => + translate( + 'components.native-chat.failureWords.argumentsMissingValue', + COPY.argumentsMissingValue, + values + ), + argumentsMultipleValues: (values) => + translate( + 'components.native-chat.failureWords.argumentsMultipleValues', + COPY.argumentsMultipleValues, + values + ), + argumentsPositionalPrompt: () => + translate( + 'components.native-chat.failureWords.argumentsPositionalPrompt', + COPY.argumentsPositionalPrompt + ), + argumentsUnclosedQuote: () => + translate( + 'components.native-chat.failureWords.argumentsUnclosedQuote', + COPY.argumentsUnclosedQuote + ), + argumentsProfileHint: () => + translate( + 'components.native-chat.failureWords.argumentsProfileHint', + COPY.argumentsProfileHint + ), + argumentsProviderHint: () => + translate( + 'components.native-chat.failureWords.argumentsProviderHint', + COPY.argumentsProviderHint + ), + argumentsWorkspaceHint: () => + translate( + 'components.native-chat.failureWords.argumentsWorkspaceHint', + COPY.argumentsWorkspaceHint + ), + argumentsImageHint: () => + translate('components.native-chat.failureWords.argumentsImageHint', COPY.argumentsImageHint), + removeFromSavedArguments: (values) => + translate( + 'components.native-chat.failureWords.removeFromSavedArguments', + COPY.removeFromSavedArguments, + values + ), + editSavedArguments: (values) => + translate( + 'components.native-chat.failureWords.editSavedArguments', + COPY.editSavedArguments, + values + ) +} diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index a1b020aeba8a..1eab9f8b988b 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -8,6 +8,7 @@ import { type AgentSessionFailureCopyValues, type AgentSessionFailureSay } from '../../../../shared/agent-session-failure-copy' +import { SAVED_ARGUMENTS_PIECES } from './agent-session-arguments-failure-words-text' import { ATTACHMENT_FAILURE_PIECES } from './agent-session-failure-attachment-words-text' // The pieces a refusal notice says too keep the notice's keys, so each has one translation. @@ -41,31 +42,7 @@ const PIECES: Record - translate( - 'components.native-chat.failureWords.argumentsUnsupportedOption', - COPY.argumentsUnsupportedOption, - values - ), - argumentsMissingValue: (values) => - translate( - 'components.native-chat.failureWords.argumentsMissingValue', - COPY.argumentsMissingValue, - values - ), - argumentsMultipleValues: (values) => - translate( - 'components.native-chat.failureWords.argumentsMultipleValues', - COPY.argumentsMultipleValues, - values - ), - argumentsPositionalPrompt: () => - translate( - 'components.native-chat.failureWords.argumentsPositionalPrompt', - COPY.argumentsPositionalPrompt - ), - editSavedArguments: () => - translate('components.native-chat.failureWords.editSavedArguments', COPY.editSavedArguments), + ...SAVED_ARGUMENTS_PIECES, terminalAgentHoldsChat: () => translate( 'components.native-chat.writeNotice.terminalAgentHoldsChat', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a2f89fb1fce8..ec43f59ed4f7 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2775,8 +2775,15 @@ "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", + "argumentsUnclosedQuote": "Saved Arguments have a quote that isn't closed.", "argumentsPositionalPrompt": "Saved Arguments include a prompt.", - "argumentsUnsupportedOption": "Saved Arguments contain an unsupported option ({{option}}).", + "argumentsUnsupportedOption": "{{agent}} chats can't use {{option}} from saved Arguments.", + "argumentsUnnamedOption": "{{agent}} chats can't use one of the options in saved Arguments.", + "argumentsProfileHint": "To keep the profile's settings, put them in config.toml or pass them with -c.", + "argumentsProviderHint": "Choose the provider with -c model_provider=… and a local model with -m instead.", + "argumentsWorkspaceHint": "A chat already works in its workspace folder.", + "argumentsImageHint": "Attach the image in the chat instead.", + "removeFromSavedArguments": "Remove it from {{agent}}'s Arguments in Settings → Agents.", "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", "attachmentInvalid": "An attachment on this message can't be sent to the agent.", @@ -2802,7 +2809,7 @@ "compactionUnconfirmed": "Compaction completion is unconfirmed.", "couldNotRestart": "{{agent}} couldn't restart.", "couldNotStart": "{{agent}} couldn't start.", - "editSavedArguments": "Edit them in Settings > Agents > Arguments.", + "editSavedArguments": "Fix it in {{agent}}'s Arguments in Settings → Agents.", "emptyMessage": "This message is empty, so it was not sent.", "historyTooLarge": "This conversation's history is too large to restore here.", "hostFault": "Orca ran into a problem, so this didn't go through.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fc97de96510c..e7297a4ad88a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18023,11 +18023,18 @@ "providerRetryLastError": "Last error: {{detail}}.", "previousExitUnverifiable": "Couldn't stop {{agent}} from before.", "sessionNotRestored": "{{agent}} couldn't reopen its earlier session, so this chat continues in a new one. {{agent}} doesn't remember the earlier messages.", - "argumentsUnsupportedOption": "Saved Arguments contain an unsupported option ({{option}}).", + "argumentsUnsupportedOption": "{{agent}} chats can't use {{option}} from saved Arguments.", + "argumentsUnnamedOption": "{{agent}} chats can't use one of the options in saved Arguments.", + "argumentsProfileHint": "To keep the profile's settings, put them in config.toml or pass them with -c.", + "argumentsProviderHint": "Choose the provider with -c model_provider=… and a local model with -m instead.", + "argumentsWorkspaceHint": "A chat already works in its workspace folder.", + "argumentsImageHint": "Attach the image in the chat instead.", + "removeFromSavedArguments": "Remove it from {{agent}}'s Arguments in Settings → Agents.", "argumentsMissingValue": "Saved Arguments need a value for {{option}}.", "argumentsMultipleValues": "Saved Arguments give {{option}} more than one value.", + "argumentsUnclosedQuote": "Saved Arguments have a quote that isn't closed.", "argumentsPositionalPrompt": "Saved Arguments include a prompt.", - "editSavedArguments": "Edit them in Settings > Agents > Arguments." + "editSavedArguments": "Fix it in {{agent}}'s Arguments in Settings → Agents." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d9cafabd60dd..93dcd5740d22 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17560,7 +17560,7 @@ "sendToTryAgain": "Envíe su mensaje para volver a intentarlo.", "sendAgainToTryOnceMore": "Vuelva a enviar su mensaje para intentarlo de nuevo.", "couldNotStart": "{{agent}} no pudo iniciarse.", - "editSavedArguments": "Edítalos en Ajustes > Agentes > Argumentos.", + "editSavedArguments": "Corríjalo en los argumentos de {{agent}} en Ajustes → Agentes.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.", "signInFirst": "Inicie sesión primero.", @@ -17612,8 +17612,15 @@ "answerUnconfirmed": "Su respuesta se registró, pero el agente no la confirmó.", "argumentsMissingValue": "Los argumentos guardados necesitan un valor para {{option}}.", "argumentsMultipleValues": "Los argumentos guardados asignan más de un valor a {{option}}.", + "argumentsUnclosedQuote": "Los argumentos guardados tienen una comilla sin cerrar.", "argumentsPositionalPrompt": "Los argumentos guardados incluyen un mensaje.", - "argumentsUnsupportedOption": "Los argumentos guardados contienen una opción no compatible ({{option}}).", + "argumentsUnsupportedOption": "Los chats de {{agent}} no pueden usar {{option}} de los argumentos guardados.", + "argumentsUnnamedOption": "Los chats de {{agent}} no pueden usar una de las opciones de los argumentos guardados.", + "argumentsProfileHint": "Para conservar los ajustes del perfil, póngalos en config.toml o páselos con -c.", + "argumentsProviderHint": "Elija el proveedor con -c model_provider=… y un modelo local con -m en su lugar.", + "argumentsWorkspaceHint": "Un chat ya trabaja en la carpeta de su espacio de trabajo.", + "argumentsImageHint": "En su lugar, adjunte la imagen en el chat.", + "removeFromSavedArguments": "Quítelo de los argumentos de {{agent}} en Ajustes → Agentes.", "hostFault": "Orca tuvo un problema, así que esto no se completó.", "hostFaultTryAgain": "Orca tuvo un problema, así que esto no se completó. Vuelva a intentarlo.", "hostStopped": "{{agent}} no terminó de iniciarse, así que Orca lo detuvo.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9999b2f99758..d941e87ef117 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17662,7 +17662,7 @@ "sendToTryAgain": "Envoyez votre message pour réessayer.", "sendAgainToTryOnceMore": "Renvoyez votre message pour réessayer.", "couldNotStart": "{{agent}} n'a pas pu démarrer.", - "editSavedArguments": "Modifiez-les dans Paramètres > Agents > Arguments.", + "editSavedArguments": "Corrigez-le dans les arguments de {{agent}} dans Paramètres → Agents.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.", "signInFirst": "Connectez-vous d'abord.", @@ -17714,8 +17714,15 @@ "answerUnconfirmed": "Votre réponse a été enregistrée, mais l'agent ne l'a pas confirmée.", "argumentsMissingValue": "Les arguments enregistrés nécessitent une valeur pour {{option}}.", "argumentsMultipleValues": "Les arguments enregistrés donnent plusieurs valeurs à {{option}}.", + "argumentsUnclosedQuote": "Les arguments enregistrés contiennent un guillemet non fermé.", "argumentsPositionalPrompt": "Les arguments enregistrés contiennent un message.", - "argumentsUnsupportedOption": "Les arguments enregistrés contiennent une option non prise en charge ({{option}}).", + "argumentsUnsupportedOption": "Les chats {{agent}} ne peuvent pas utiliser {{option}} depuis les arguments enregistrés.", + "argumentsUnnamedOption": "Les chats {{agent}} ne peuvent pas utiliser l'une des options des arguments enregistrés.", + "argumentsProfileHint": "Pour conserver les réglages du profil, placez-les dans config.toml ou passez-les avec -c.", + "argumentsProviderHint": "Choisissez le fournisseur avec -c model_provider=… et un modèle local avec -m à la place.", + "argumentsWorkspaceHint": "Un chat travaille déjà dans le dossier de son espace de travail.", + "argumentsImageHint": "Joignez plutôt l'image dans le chat.", + "removeFromSavedArguments": "Retirez-le des arguments de {{agent}} dans Paramètres → Agents.", "hostFault": "Orca a rencontré un problème, l'opération n'a donc pas abouti.", "hostFaultTryAgain": "Orca a rencontré un problème, l'opération n'a donc pas abouti. Réessayez.", "hostStopped": "{{agent}} n'a jamais fini de démarrer, Orca l'a donc arrêté.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ca313e7942ba..97b7fb159785 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17596,7 +17596,7 @@ "sendToTryAgain": "もう一度試すには、メッセージを送信してください。", "sendAgainToTryOnceMore": "メッセージをもう一度送信して、再度お試しください。", "couldNotStart": "{{agent}} を起動できませんでした。", - "editSavedArguments": "設定 > Agent > 引数で編集してください。", + "editSavedArguments": "[設定] → [Agent] の {{agent}} の引数で修正してください。", "couldNotRestart": "{{agent}} を再起動できませんでした。", "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。", "signInFirst": "先にサインインしてください。", @@ -17648,8 +17648,15 @@ "answerUnconfirmed": "回答は記録されましたが、Agent は確認しませんでした。", "argumentsMissingValue": "保存された引数の {{option}} に値が必要です。", "argumentsMultipleValues": "保存された引数の {{option}} に複数の値が指定されています。", + "argumentsUnclosedQuote": "保存された引数に閉じられていない引用符があります。", "argumentsPositionalPrompt": "保存された引数にプロンプトが含まれています。", - "argumentsUnsupportedOption": "保存された引数に未対応のオプション({{option}})が含まれています。", + "argumentsUnsupportedOption": "{{agent}} のチャットでは、保存された引数の {{option}} を使用できません。", + "argumentsUnnamedOption": "{{agent}} のチャットでは、保存された引数のオプションの 1 つを使用できません。", + "argumentsProfileHint": "プロファイルの設定を使い続けるには、config.toml に記述するか -c で指定してください。", + "argumentsProviderHint": "代わりに -c model_provider=… でプロバイダーを、-m でローカルモデルを選択してください。", + "argumentsWorkspaceHint": "チャットはすでにワークスペースフォルダーで動作しています。", + "argumentsImageHint": "代わりにチャットで画像を添付してください。", + "removeFromSavedArguments": "[設定] → [Agent] の {{agent}} の引数から削除してください。", "hostFault": "Orca で問題が発生したため、この操作は完了しませんでした。", "hostFaultTryAgain": "Orca で問題が発生したため、この操作は完了しませんでした。もう一度お試しください。", "hostStopped": "{{agent}} の起動が完了しなかったため、Orca が停止しました。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 49daef9ca7c8..8e76fadb46e9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17596,7 +17596,7 @@ "sendToTryAgain": "다시 시도하려면 메시지를 보내세요.", "sendAgainToTryOnceMore": "메시지를 다시 보내 한 번 더 시도하세요.", "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", - "editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.", + "editSavedArguments": "설정 → 에이전트의 {{agent}} 인수에서 수정하세요.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.", "signInFirst": "먼저 로그인하세요.", @@ -17648,8 +17648,15 @@ "answerUnconfirmed": "응답이 기록되었지만 에이전트가 확인하지 않았습니다.", "argumentsMissingValue": "저장된 인수의 {{option}}에 값이 필요합니다.", "argumentsMultipleValues": "저장된 인수에 {{option}}의 값이 여러 개 지정되어 있습니다.", + "argumentsUnclosedQuote": "저장된 인수에 닫히지 않은 따옴표가 있습니다.", "argumentsPositionalPrompt": "저장된 인수에 프롬프트가 포함되어 있습니다.", - "argumentsUnsupportedOption": "저장된 인수에 지원하지 않는 옵션({{option}})이 포함되어 있습니다.", + "argumentsUnsupportedOption": "{{agent}} 채팅에서는 저장된 인수의 {{option}}을(를) 사용할 수 없습니다.", + "argumentsUnnamedOption": "{{agent}} 채팅에서는 저장된 인수의 옵션 중 하나를 사용할 수 없습니다.", + "argumentsProfileHint": "프로필 설정을 유지하려면 config.toml에 넣거나 -c로 전달하세요.", + "argumentsProviderHint": "대신 -c model_provider=…로 제공자를, -m으로 로컬 모델을 선택하세요.", + "argumentsWorkspaceHint": "채팅은 이미 워크스페이스 폴더에서 작동합니다.", + "argumentsImageHint": "대신 채팅에서 이미지를 첨부하세요.", + "removeFromSavedArguments": "설정 → 에이전트의 {{agent}} 인수에서 제거하세요.", "hostFault": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다.", "hostFaultTryAgain": "Orca에 문제가 발생하여 이 작업이 완료되지 않았습니다. 다시 시도하세요.", "hostStopped": "{{agent}}이(가) 시작을 마치지 못해 Orca가 중지했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b19f3a7a1013..1230a67ba3a5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17561,7 +17561,7 @@ "sendToTryAgain": "请发送您的消息以重试。", "sendAgainToTryOnceMore": "请重新发送您的消息,再试一次。", "couldNotStart": "{{agent}} 无法启动。", - "editSavedArguments": "请在设置 > 智能体 > 参数中编辑。", + "editSavedArguments": "请在设置 → 智能体中修正 {{agent}} 的参数。", "couldNotRestart": "{{agent}} 无法重新启动。", "notSignedIn": "{{agent}} 未使用所选账户登录。", "signInFirst": "请先登录。", @@ -17613,8 +17613,15 @@ "answerUnconfirmed": "您的回答已记录,但智能体未确认。", "argumentsMissingValue": "已保存的参数需要为 {{option}} 指定一个值。", "argumentsMultipleValues": "已保存的参数为 {{option}} 指定了多个值。", + "argumentsUnclosedQuote": "已保存的参数中有未闭合的引号。", "argumentsPositionalPrompt": "已保存的参数中包含提示词。", - "argumentsUnsupportedOption": "已保存的参数包含不支持的选项({{option}})。", + "argumentsUnsupportedOption": "{{agent}} 聊天无法使用已保存参数中的 {{option}}。", + "argumentsUnnamedOption": "{{agent}} 聊天无法使用已保存参数中的某个选项。", + "argumentsProfileHint": "如需保留该配置档的设置,请将其写入 config.toml 或通过 -c 传入。", + "argumentsProviderHint": "请改用 -c model_provider=… 选择提供方,并用 -m 选择本地模型。", + "argumentsWorkspaceHint": "聊天已在其工作区文件夹中运行。", + "argumentsImageHint": "请改为在聊天中附加图片。", + "removeFromSavedArguments": "请在设置 → 智能体中从 {{agent}} 的参数里删除它。", "hostFault": "Orca 遇到问题,因此此操作未完成。", "hostFaultTryAgain": "Orca 遇到问题,因此此操作未完成。请重试。", "hostStopped": "{{agent}} 始终未完成启动,因此 Orca 已将其停止。", diff --git a/src/shared/agent-session-argument-problem-words.ts b/src/shared/agent-session-argument-problem-words.ts new file mode 100644 index 000000000000..94c65a615d06 --- /dev/null +++ b/src/shared/agent-session-argument-problem-words.ts @@ -0,0 +1,59 @@ +// The sentences after "{agent} couldn't start." for a saved Arguments refusal: what is wrong, where +// to change it, and for some options what a chat honors instead. Chosen from the option name alone, +// which every client already receives. + +import type { AgentSessionArgumentProblem } from './agent-session-argument-problem' +import type { + AgentSessionFailureCopyId, + AgentSessionFailureSay +} from './agent-session-failure-copy' + +const PROBLEM_COPY = { + unsupportedOption: 'argumentsUnsupportedOption', + missingValue: 'argumentsMissingValue', + multipleValues: 'argumentsMultipleValues', + positionalPrompt: 'argumentsPositionalPrompt', + unclosedQuote: 'argumentsUnclosedQuote' +} as const satisfies Record + +/** Codex options a chat refuses that have an equivalent, or no need, in a chat. */ +const CODEX_OPTION_HINTS = new Map([ + ['--profile', 'argumentsProfileHint'], + ['-p', 'argumentsProfileHint'], + ['--oss', 'argumentsProviderHint'], + ['--local-provider', 'argumentsProviderHint'], + ['-C', 'argumentsWorkspaceHint'], + ['--cd', 'argumentsWorkspaceHint'], + ['--add-dir', 'argumentsWorkspaceHint'], + ['--worktree', 'argumentsWorkspaceHint'], + ['-i', 'argumentsImageHint'], + ['--image', 'argumentsImageHint'] +]) + +function problemCopy({ option, problem }: AgentSessionArgumentProblem): AgentSessionFailureCopyId { + if (problem === 'unsupportedOption' && option === '--?') { + return 'argumentsUnnamedOption' + } + return PROBLEM_COPY[problem] +} + +export function agentSessionArgumentProblemSentences( + argumentProblem: AgentSessionArgumentProblem, + say: AgentSessionFailureSay +): string[] { + const { agent, option, problem } = argumentProblem + const hint = + agent === 'Codex' && problem === 'unsupportedOption' + ? CODEX_OPTION_HINTS.get(option) + : undefined + return [ + say(problemCopy(argumentProblem), { agent, option }), + say( + problem === 'unsupportedOption' || problem === 'positionalPrompt' + ? 'removeFromSavedArguments' + : 'editSavedArguments', + { agent } + ), + ...(hint ? [say(hint)] : []) + ] +} diff --git a/src/shared/agent-session-argument-problem.ts b/src/shared/agent-session-argument-problem.ts index b6fcc98d3362..1cb10a42d6fe 100644 --- a/src/shared/agent-session-argument-problem.ts +++ b/src/shared/agent-session-argument-problem.ts @@ -2,9 +2,19 @@ export type AgentSessionArgumentProblem = { agent: 'Codex' | 'Claude' option: string - problem: 'unsupportedOption' | 'missingValue' | 'multipleValues' | 'positionalPrompt' + problem: + | 'unsupportedOption' + | 'missingValue' + | 'multipleValues' + | 'positionalPrompt' + | 'unclosedQuote' } +/** Problems that are about no single option carry this fixed word as their option. */ +export const AGENT_SESSION_ARGUMENT_PROBLEM_WORD: Partial< + Record +> = { positionalPrompt: 'prompt', unclosedQuote: 'quote' } + export function readAgentSessionArgumentProblem( value: unknown ): AgentSessionArgumentProblem | undefined { @@ -26,10 +36,11 @@ export function readAgentSessionArgumentProblem( (problem !== 'unsupportedOption' && problem !== 'missingValue' && problem !== 'multipleValues' && - problem !== 'positionalPrompt') || + problem !== 'positionalPrompt' && + problem !== 'unclosedQuote') || typeof option !== 'string' || - (problem === 'positionalPrompt' - ? option !== 'prompt' + (AGENT_SESSION_ARGUMENT_PROBLEM_WORD[problem] !== undefined + ? option !== AGENT_SESSION_ARGUMENT_PROBLEM_WORD[problem] : !/^(?:--[a-zA-Z][a-zA-Z0-9-]{0,63}|-[a-zA-Z]|--\?)$/.test(option)) ) { return undefined diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index b0b872b61b33..8df2553204de 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -17,11 +17,20 @@ export const AGENT_SESSION_FAILURE_COPY = { sendAgainToTryOnceMore: 'Send your message again to try once more.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", - argumentsUnsupportedOption: 'Saved Arguments contain an unsupported option ({{option}}).', + argumentsUnsupportedOption: "{{agent}} chats can't use {{option}} from saved Arguments.", + argumentsUnnamedOption: "{{agent}} chats can't use one of the options in saved Arguments.", argumentsMissingValue: 'Saved Arguments need a value for {{option}}.', argumentsMultipleValues: 'Saved Arguments give {{option}} more than one value.', argumentsPositionalPrompt: 'Saved Arguments include a prompt.', - editSavedArguments: 'Edit them in Settings > Agents > Arguments.', + argumentsUnclosedQuote: "Saved Arguments have a quote that isn't closed.", + argumentsProfileHint: + "To keep the profile's settings, put them in config.toml or pass them with -c.", + argumentsProviderHint: + 'Choose the provider with -c model_provider=… and a local model with -m instead.', + argumentsWorkspaceHint: 'A chat already works in its workspace folder.', + argumentsImageHint: 'Attach the image in the chat instead.', + removeFromSavedArguments: "Remove it from {{agent}}'s Arguments in Settings → Agents.", + editSavedArguments: "Fix it in {{agent}}'s Arguments in Settings → Agents.", terminalAgentHoldsChat: TERMINAL_AGENT_HOLDS_CHAT, quitTerminalAgent: QUIT_TERMINAL_AGENT, startNewChat: START_NEW_CHAT, diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index b797cf353cbe..654dda468166 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -18,6 +18,7 @@ import { type SubmissionRejectionKind } from './agent-session-failure' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' +import { agentSessionArgumentProblemSentences } from './agent-session-argument-problem-words' import { sayAgentSessionFailureEnglish, type AgentSessionFailureCopyId, @@ -139,16 +140,9 @@ function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { return (context, fact, _surface, say) => { const failed = say(verb, agent(say, context)) if (fact.argumentProblem) { - const problemCopy = { - unsupportedOption: 'argumentsUnsupportedOption', - missingValue: 'argumentsMissingValue', - multipleValues: 'argumentsMultipleValues', - positionalPrompt: 'argumentsPositionalPrompt' - } as const return joinSentences([ failed, - say(problemCopy[fact.argumentProblem.problem], { option: fact.argumentProblem.option }), - say('editSavedArguments'), + ...agentSessionArgumentProblemSentences(fact.argumentProblem, say), ...startRetry(say, context) ]) } diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 1501453361fe..ff6f009be88c 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -737,13 +737,55 @@ describe('saved Arguments refusals', () => { throw new Error('saved refusal was not read') } expect(agentSessionWriteNoticeEnglish(agentSessionRefusalCauseParts(parsed))).toBe( - "Codex couldn't start. Saved Arguments contain an unsupported option (--remote). Edit them in Settings > Agents > Arguments." + "Codex couldn't start. Codex chats can't use --remote from saved Arguments. Remove it from Codex's Arguments in Settings → Agents." ) }) + it.each([ + [ + { agent: 'Codex', option: '--profile', problem: 'unsupportedOption' }, + "Codex couldn't start. Codex chats can't use --profile from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. To keep the profile's settings, put them in config.toml or pass them with -c." + ], + [ + { agent: 'Codex', option: '--oss', problem: 'unsupportedOption' }, + "Codex couldn't start. Codex chats can't use --oss from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. Choose the provider with -c model_provider=… and a local model with -m instead." + ], + [ + { agent: 'Codex', option: '-C', problem: 'unsupportedOption' }, + "Codex couldn't start. Codex chats can't use -C from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. A chat already works in its workspace folder." + ], + [ + { agent: 'Codex', option: '-i', problem: 'unsupportedOption' }, + "Codex couldn't start. Codex chats can't use -i from saved Arguments. Remove it from Codex's Arguments in Settings → Agents. Attach the image in the chat instead." + ], + [ + { agent: 'Codex', option: '--?', problem: 'unsupportedOption' }, + "Codex couldn't start. Codex chats can't use one of the options in saved Arguments. Remove it from Codex's Arguments in Settings → Agents." + ], + [ + { agent: 'Codex', option: 'prompt', problem: 'positionalPrompt' }, + "Codex couldn't start. Saved Arguments include a prompt. Remove it from Codex's Arguments in Settings → Agents." + ], + [ + { agent: 'Claude', option: 'quote', problem: 'unclosedQuote' }, + "Claude couldn't start. Saved Arguments have a quote that isn't closed. Fix it in Claude's Arguments in Settings → Agents." + ] + ] as const)('names the %j problem and where to fix it', (argumentProblem, sentence) => { + const parsed = parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed', argumentProblem } + }) + if (!parsed) { + throw new Error('saved refusal was not read') + } + expect(agentSessionWriteNoticeEnglish(agentSessionRefusalCauseParts(parsed))).toBe(sentence) + }) + it.each([ { agent: 'Codex', option: '--remote', problem: 'futureProblem' }, - { agent: 'Codex', option: '--remote=private', problem: 'unsupportedOption' } + { agent: 'Codex', option: '--remote=private', problem: 'unsupportedOption' }, + { agent: 'Codex', option: '--private', problem: 'unclosedQuote' } ])('ignores an unrecognized or unsafe argument detail', (argumentProblem) => { const refused = parseAgentSessionWriteFailure({ kind: 'refused',