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:
parent
9a92124c63
commit
5a5c918705
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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}.`,
|
||||
]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue