fix(acpx): configure Codex models at startup (#9700)

Move Codex ACPX model, reasoning effort, and fast-mode settings into CODEX_CONFIG startup config so arbitrary model IDs avoid ACP session picker validation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-16 11:41:48 -05:00 committed by GitHub
parent 9a92124c63
commit 5a5c918705
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 187 additions and 16 deletions

View File

@ -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<string, unknown>[] = [];
const configOptions: Array<{ key: string; value: string }> = [];
const meta: Record<string, unknown>[] = [];
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxEngineExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as unknown as Record<string, unknown>);
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<string, string>).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<string, string>).CODEX_CONFIG),
) as Record<string, unknown>;
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<string, string>).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<string, string>).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<string, string>).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<string, string>).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" },

View File

@ -724,6 +724,49 @@ function normalizeRequestedThinkingEffort(config: Record<string, unknown>): 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<string, unknown> = {};
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<string, unknown>,
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
@ -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<string, unknown> = { 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}.`,
]
: []),

View File

@ -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 () => {