diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index b900189be9..56e41dc6fe 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -91,7 +91,9 @@ function createLocalSandboxRunner( }; } -function buildRuntime() { +function buildRuntime( + onSetConfigOption?: (input: { key: string; value: string }) => void, +) { return { ensureSession: async () => ({ backendSessionId: "backend-session", @@ -105,6 +107,9 @@ function buildRuntime() { result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), cancel: async () => {}, }), + setConfigOption: async (input: { key: string; value: string }) => { + onSetConfigOption?.(input); + }, close: async () => {}, }; } @@ -120,12 +125,13 @@ async function runExecutor( } = {}, ) { const runtimeOptions: Record[] = []; + const configOptions: Array<{ key: string; value: string }> = []; const meta: Record[] = []; const logs: Array<{ stream: string; text: string }> = []; const execute = createAcpxEngineExecutor({ createRuntime: (options) => { runtimeOptions.push(options as unknown as Record); - return buildRuntime() as never; + return buildRuntime(({ key, value }) => configOptions.push({ key, value })) as never; }, }); @@ -151,10 +157,110 @@ async function runExecutor( } as never); expect(result.exitCode).toBe(0); - return { logs, meta, runtimeOptions, result }; + return { logs, meta, runtimeOptions, configOptions, result }; } describe("shared ACPX engine runtime behavior", () => { + it("sets Codex model, effort, and fast mode through CODEX_CONFIG without session config calls", async () => { + const { configOptions, meta } = await runExecutor({ + agent: "codex", + model: "gpt-5.6-sol", + modelReasoningEffort: "high", + fastMode: true, + }); + + expect(JSON.parse(String((meta[0]?.env as Record).CODEX_CONFIG))).toEqual({ + model: "gpt-5.6-sol", + model_reasoning_effort: "high", + service_tier: "fast", + features: { fast_mode: true }, + }); + expect(configOptions).toEqual([]); + expect(meta[0]?.commandNotes).toContain( + "Requested ACPX model: gpt-5.6-sol (set via CODEX_CONFIG at startup).", + ); + }); + + it("forwards arbitrary Codex model IDs verbatim without picker-dependent session config", async () => { + const arbitraryModel = "gpt-999-test-does-not-exist"; + const { configOptions, meta } = await runExecutor({ + agent: "codex", + model: arbitraryModel, + reasoningEffort: "xhigh", + fastMode: true, + }); + + const codexConfig = JSON.parse( + String((meta[0]?.env as Record).CODEX_CONFIG), + ) as Record; + expect(codexConfig.model).toBe(arbitraryModel); + expect(codexConfig.model_reasoning_effort).toBe("xhigh"); + expect(configOptions).toEqual([]); + }); + + it("merges user CODEX_CONFIG while runtime model settings win", async () => { + const { meta } = await runExecutor({ + agent: "codex", + model: "gpt-runtime", + fastMode: true, + env: { + CODEX_CONFIG: JSON.stringify({ + model: "gpt-user", + approval_policy: "never", + features: { experimental_feature: true, fast_mode: false }, + }), + }, + }); + + expect(JSON.parse(String((meta[0]?.env as Record).CODEX_CONFIG))).toEqual({ + model: "gpt-runtime", + approval_policy: "never", + service_tier: "fast", + features: { experimental_feature: true, fast_mode: true }, + }); + }); + + it("warns when runtime settings replace malformed user CODEX_CONFIG", async () => { + const { logs, meta } = await runExecutor({ + agent: "codex", + model: "gpt-runtime", + env: { CODEX_CONFIG: "not-json" }, + }); + + expect(JSON.parse(String((meta[0]?.env as Record).CODEX_CONFIG))).toEqual({ + model: "gpt-runtime", + }); + expect(logs).toContainEqual({ + stream: "stderr", + text: "[paperclip] Ignoring invalid user CODEX_CONFIG while applying runtime Codex settings; expected a JSON object.\n", + }); + }); + + it("keeps Claude startup model handling and Gemini session config handling unchanged", async () => { + const claude = await runExecutor({ agent: "claude", model: "claude-opus-4-7" }); + expect((claude.meta[0]?.env as Record).ANTHROPIC_MODEL).toBe( + "claude-opus-4-7", + ); + expect(claude.configOptions).toEqual([]); + + const gemini = await runExecutor({ + agent: "gemini", + model: "gemini-2.5-pro", + thinkingEffort: "high", + }); + expect(gemini.configOptions).toEqual([ + { key: "model", value: "gemini-2.5-pro" }, + { key: "effort", value: "high" }, + ]); + }); + + it("does not inject CODEX_CONFIG or session config when Codex overrides are absent", async () => { + const { configOptions, meta } = await runExecutor({ agent: "codex" }); + + expect((meta[0]?.env as Record).CODEX_CONFIG).toBeUndefined(); + expect(configOptions).toEqual([]); + }); + it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => { const { meta } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js" }, diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 1d2583d70b..d23841e35f 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -724,6 +724,49 @@ function normalizeRequestedThinkingEffort(config: Record): stri ).trim(); } +function buildCodexStartupConfig(input: { + existingConfig: string | undefined; + requestedModel: string; + requestedThinkingEffort: string; + fastMode: boolean; +}): { value: string | null; invalidExistingConfig: boolean } { + const hasRuntimeConfig = Boolean( + input.requestedModel || input.requestedThinkingEffort || input.fastMode, + ); + if (!hasRuntimeConfig) return { value: null, invalidExistingConfig: false }; + + let existing: Record = {}; + let invalidExistingConfig = false; + if (input.existingConfig) { + try { + existing = parseObject(JSON.parse(input.existingConfig)); + } catch { + invalidExistingConfig = true; + existing = {}; + } + } + + return { + value: JSON.stringify({ + ...existing, + ...(input.requestedModel ? { model: input.requestedModel } : {}), + ...(input.requestedThinkingEffort + ? { model_reasoning_effort: input.requestedThinkingEffort } + : {}), + ...(input.fastMode + ? { + service_tier: "fast", + features: { + ...parseObject(existing.features), + fast_mode: true, + }, + } + : {}), + }), + invalidExistingConfig, + }; +} + function isCompatibleSession( params: Record, runtime: Pick, @@ -1074,6 +1117,21 @@ async function buildRuntime(input: { if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) { env.ANTHROPIC_MODEL = requestedModel; } + if (acpxAgent === "codex") { + const codexStartupConfig = buildCodexStartupConfig({ + existingConfig: env.CODEX_CONFIG, + requestedModel, + requestedThinkingEffort, + fastMode, + }); + if (codexStartupConfig.invalidExistingConfig) { + await input.ctx.onLog( + "stderr", + "[paperclip] Ignoring invalid user CODEX_CONFIG while applying runtime Codex settings; expected a JSON object.\n", + ); + } + if (codexStartupConfig.value) env.CODEX_CONFIG = codexStartupConfig.value; + } let skillPromptInstructions = ""; let skillsIdentity: Record = { mode: "unsupported" }; @@ -1289,19 +1347,23 @@ async function buildRuntime(input: { function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> { const options: Array<{ key: string; value: string }> = []; - // Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at - // startup; skip set_config_option to avoid ACP-server model-name validation - // that rejects bare IDs like "claude-opus-4-7" in some runtime versions. - if (prepared.requestedModel && prepared.acpxAgent !== "claude") { + // Claude and Codex runtime config is pre-set via startup env vars; skip + // set_config_option to avoid ACP-server picker validation rejecting valid + // backend model IDs that are not advertised by the local ACP server. + if ( + prepared.requestedModel && + prepared.acpxAgent !== "claude" && + prepared.acpxAgent !== "codex" + ) { options.push({ key: "model", value: prepared.requestedModel }); } - if (prepared.requestedThinkingEffort) { + if (prepared.requestedThinkingEffort && prepared.acpxAgent !== "codex") { options.push({ - key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort", + key: "effort", value: prepared.requestedThinkingEffort, }); } - if (prepared.fastMode) { + if (prepared.fastMode && prepared.acpxAgent !== "codex") { options.push( { key: "service_tier", value: "fast" }, { key: "features.fast_mode", value: "true" }, @@ -2059,6 +2121,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { ? [ prepared.acpxAgent === "claude" ? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).` + : prepared.acpxAgent === "codex" + ? `Requested ACPX model: ${prepared.requestedModel} (set via CODEX_CONFIG at startup).` : `Requested ACPX model: ${prepared.requestedModel}.`, ] : []), diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index c573ad1ead..501f089800 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -477,14 +477,15 @@ describe("codex_local ACP lane", () => { mode: "persistent", cwd: root, }); - expect(runtimes[0]?.setConfigInputs.map((input) => [input.key, input.value])).toEqual([ - ["model", "gpt-5.5"], - ["reasoning_effort", "high"], - ["service_tier", "fast"], - ["features.fast_mode", "true"], - ]); + expect(runtimes[0]?.setConfigInputs).toEqual([]); expect(meta[0]?.commandNotes?.join("\n")).toContain("Prepared ACPX Codex skill home"); expect(meta[0]?.env?.CODEX_HOME).toBe(path.join(root, "codex-home")); + expect(JSON.parse(String(meta[0]?.env?.CODEX_CONFIG))).toEqual({ + model: "gpt-5.5", + model_reasoning_effort: "high", + service_tier: "fast", + features: { fast_mode: true }, + }); }); it("classifies ACP refresh-token auth failures", async () => {