Fall back to Codex CLI when ACP rejects session configuration

A codex_local agent configured with a manual model id (e.g. gpt-5.5-pro)
failed every ACP-lane run with acpx_session_config_failed: codex-acp
validates session/set_config_option values against its internal model
list and rejects ids it does not advertise (ACP -32602), even though the
Codex CLI lane accepts manual model ids via --model.

The existing ACP->CLI fallback only covered thrown startup errors, not
structured failed results from the configure_session phase. Auto-selected
ACP now falls back to the CLI lane when the ACP server rejects session
configuration, preserving the configured model/effort/fast-mode via
startup args. Explicit engine=acp remains strict and fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Fable 5 committed 2026-07-10 13:33:50 +00:00
1 parent cc81eefb60
commit 8c46620678
5 files changed
+97 -3

No files matched your search

@@ -5,6 +5,11 @@ export const DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS = "deny";
export const DEFAULT_ACP_ENGINE_TIMEOUT_SEC = 0;
export const DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS = 0;
// The ACP server rejected a session/set_config_option value (for example a
// manual model id its validation list does not include). Adapters with a
// non-ACP lane can treat this as recoverable and retry there.
export const ACPX_SESSION_CONFIG_FAILED_ERROR_CODE = "acpx_session_config_failed";
export const ACPX_ADAPTER_AGENT_IDS = {
claude_local: "claude",
codex_local: "codex",
@@ -54,6 +54,7 @@ import {
type AcpRuntimeTurnResult,
} from "acpx/runtime";
import {
ACPX_SESSION_CONFIG_FAILED_ERROR_CODE,
DEFAULT_ACP_ENGINE_AGENT,
DEFAULT_ACP_ENGINE_MODE,
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
@@ -1442,7 +1443,7 @@ function classifyError(
if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing";
if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable";
if (phase === "ensure_session") return "acpx_session_init_failed";
if (phase === "configure_session") return "acpx_session_config_failed";
if (phase === "configure_session") return ACPX_SESSION_CONFIG_FAILED_ERROR_CODE;
if (phase === "turn") return "acpx_turn_failed";
return null;
})();
+1 -1
View File
@@ -104,5 +104,5 @@ Notes:
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- Fast mode is supported on GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`.
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
- Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly.
- Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable or the ACP server rejects the session configuration (for example a manual model id its set_config_option validation does not list); explicit engine="acp" fails loudly.
`;
@@ -158,4 +158,75 @@ describe("codex_local ACP startup fallback", () => {
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
});
it("falls back to Codex CLI when auto-selected ACP rejects session configuration", async () => {
executeCodexAcp.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
errorMessage:
'Agent rejected session/set_config_option for "model"="gpt-5.5-pro": Invalid params (ACP -32602).',
errorCode: "acpx_session_config_failed",
provider: "acpx",
model: "gpt-5.5-pro",
resultJson: { phase: "configure_session" },
summary: "config rejected",
} as never);
const ctx = buildContext({ model: "gpt-5.5-pro" });
const result = await execute(ctx as never);
expect(result.exitCode).toBe(0);
expect(result.summary).toBe("hello");
expect(executeCodexAcp).toHaveBeenCalledTimes(1);
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining("Codex ACP rejected session configuration"),
);
expect(ctx.onLog).toHaveBeenCalledWith(
"stderr",
expect.stringContaining("gpt-5.5-pro"),
);
});
it("returns the session-config failure unchanged when engine=acp is explicit", async () => {
const failed = {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "Agent rejected session/set_config_option",
errorCode: "acpx_session_config_failed",
provider: "acpx",
resultJson: { phase: "configure_session" },
summary: "config rejected",
};
executeCodexAcp.mockResolvedValueOnce(failed as never);
const ctx = buildContext({ engine: "acp" });
const result = await execute(ctx as never);
expect(result).toBe(failed);
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
});
it("does not fall back for other failed ACP results", async () => {
const failed = {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "turn failed",
errorCode: "acpx_turn_failed",
provider: "acpx",
resultJson: { phase: "turn" },
summary: "turn failed",
};
executeCodexAcp.mockResolvedValueOnce(failed as never);
const ctx = buildContext();
const result = await execute(ctx as never);
expect(result).toBe(failed);
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
});
});
@@ -64,6 +64,7 @@ import {
formatOutputInactivityMonitorErrorMessage,
resolveCodexInactivityTimeout,
} from "./output-inactivity-monitor.js";
import { ACPX_SESSION_CONFIG_FAILED_ERROR_CODE } from "@paperclipai/adapter-utils/acpx-engine/constants";
import {
createCodexAcpExecutor,
formatCodexAcpFallbackMessage,
@@ -330,8 +331,9 @@ export async function ensureCodexSkillsInjected(
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const engineSelection = await resolveCodexExecutionEngineForRun(ctx);
if (engineSelection.engine === "acp") {
let acpResult: AdapterExecutionResult | null = null;
try {
return await executeCodexAcp(ctx);
acpResult = await executeCodexAcp(ctx);
} catch (err) {
if (engineSelection.explicit) throw err;
const reason = err instanceof Error ? err.message : String(err);
@@ -340,6 +342,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
formatCodexAcpFallbackMessage(`Codex ACP startup failed: ${reason}`),
);
}
if (acpResult) {
// A session/set_config_option rejection (e.g. a manual model id the ACP
// server's validation list does not include) is recoverable: the CLI
// lane applies model/effort/fast-mode via startup args instead of ACP
// session controls. Explicit engine=acp stays strict and fails loudly.
const sessionConfigRejected =
acpResult.errorCode === ACPX_SESSION_CONFIG_FAILED_ERROR_CODE;
if (engineSelection.explicit || !sessionConfigRejected) return acpResult;
await ctx.onLog(
"stderr",
formatCodexAcpFallbackMessage(
`Codex ACP rejected session configuration: ${acpResult.errorMessage ?? "unknown error"}`,
),
);
}
}
if (!engineSelection.explicit && engineSelection.fallbackReason) {
await ctx.onLog("stderr", formatCodexAcpFallbackMessage(engineSelection.fallbackReason));