From bb2b68b34e30f50c2a3a2443bdacbb9640d69648 Mon Sep 17 00:00:00 2001 From: 91wan Date: Sun, 26 Apr 2026 02:56:03 +0800 Subject: [PATCH] fix(acp): pass Codex ACP model thinking overrides Fix ACP Codex model/thinking override propagation.\n\nThanks @91wan. --- CHANGELOG.md | 1 + docs/tools/acp-agents.md | 6 +- extensions/acpx/src/runtime.test.ts | 276 ++++++++++++++++++++++- extensions/acpx/src/runtime.ts | 256 ++++++++++++++++++++- src/acp/control-plane/manager.core.ts | 8 + src/acp/control-plane/manager.test.ts | 103 ++++++++- src/acp/control-plane/runtime-options.ts | 30 +++ src/acp/runtime/types.ts | 4 + src/agents/acp-spawn.test.ts | 4 +- src/agents/acp-spawn.ts | 11 +- src/agents/tools/sessions-spawn-tool.ts | 1 + src/config/sessions/types.ts | 2 + 12 files changed, 693 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a876f6aef91..3ac07fd9073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai - Agents/Anthropic/Bedrock: preserve stripped thinking-only assistant replay turns with non-empty omitted-reasoning text so provider adapters keep strict user/assistant turn shape. Thanks @wujiaming88. +- ACP/Codex: pass `sessions_spawn(runtime="acp")` model and thinking overrides into Codex ACP startup, normalize `openai-codex/*` refs and slash reasoning suffixes, and recognize managed Codex ACP wrapper commands without blocking current `gpt-5.5` sessions. Fixes #40393. (#71643) Thanks @91wan. - Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532. - Browser/CDP: explain that loopback Browserless or other externally managed CDP services need `attachOnly: true` and matching Browserless `EXTERNAL` endpoint when reporting local port ownership conflicts, and fall back to the configured bare WebSocket root when a discovered Browserless endpoint rejects CDP. Fixes #49815. - Gateway/reload: preserve indefinite `gateway.reload.deferralTimeoutMs: 0` semantics for channel hot reload deferrals so active agent runs are not interrupted by a forced channel restart. (#71637) Thanks @Poo-Squirry. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 66e1fdfef50..478f602d609 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -329,7 +329,8 @@ Interface details: - `resumeSessionId` (optional): resume an existing ACP session instead of creating a new one. The agent replays its conversation history via `session/load`. Requires `runtime: "acp"`. - `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events. - When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`.acp-stream.jsonl`) you can tail for full relay history. -- `model` (optional): explicit model override for the ACP child session. Honored for `runtime: "acp"` so the child uses the requested model instead of silently falling back to the target agent default. +- `model` (optional): explicit model override for the ACP child session. Honored for `runtime: "acp"` so the child uses the requested model instead of silently falling back to the target agent default. Codex ACP spawns normalize OpenClaw Codex refs such as `openai-codex/gpt-5.4` to Codex ACP startup config before `session/new`; slash forms such as `openai-codex/gpt-5.4/high` also set Codex ACP reasoning effort. +- `thinking` (optional): explicit thinking/reasoning effort for the ACP child session. For Codex ACP, `minimal` maps to low effort, `low`/`medium`/`high`/`xhigh` map directly, and `off` omits the reasoning-effort startup override. ## Delivery model @@ -522,7 +523,8 @@ Notes: Equivalent operations: -- `/acp model ` maps to runtime config key `model`. +- `/acp model ` maps to runtime config key `model`. For Codex ACP, OpenClaw normalizes `openai-codex/` to the adapter model id and maps slash reasoning suffixes such as `openai-codex/gpt-5.4/high` to Codex ACP `reasoning_effort`. +- `/acp set thinking ` maps to runtime config key `thinking`. For Codex ACP, OpenClaw sends the corresponding `reasoning_effort` where the adapter supports one. - `/acp permissions ` maps to runtime config key `approval_policy`. - `/acp timeout ` maps to runtime config key `timeout`. - `/acp cwd ` updates runtime cwd override directly. diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 13e534858b2..ab0048295a0 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AcpRuntime } from "../runtime-api.js"; -import { AcpxRuntime } from "./runtime.js"; +import { AcpxRuntime, __testing } from "./runtime.js"; type TestSessionStore = { load(sessionId: string): Promise | undefined>; @@ -9,6 +9,8 @@ type TestSessionStore = { const DOCUMENTED_OPENCLAW_BRIDGE_COMMAND = "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"; +const CODEX_ACP_COMMAND = "npx @zed-industries/codex-acp@^0.11.1"; +const CODEX_ACP_WRAPPER_COMMAND = `node "/tmp/openclaw/acpx/codex-acp-wrapper.mjs"`; function makeRuntime( baseStore: TestSessionStore, @@ -20,6 +22,7 @@ function makeRuntime( close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; getStatus: NonNullable; + setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; }; @@ -27,6 +30,7 @@ function makeRuntime( close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; getStatus: NonNullable; + setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; }; @@ -55,6 +59,7 @@ function makeRuntime( close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; getStatus: NonNullable; + setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; }; @@ -66,6 +71,7 @@ function makeRuntime( close: AcpRuntime["close"]; ensureSession: AcpRuntime["ensureSession"]; getStatus: NonNullable; + setConfigOption: NonNullable; isHealthy(): boolean; probeAvailability(): Promise; }; @@ -79,6 +85,274 @@ describe("AcpxRuntime fresh reset wrapper", () => { vi.restoreAllMocks(); }); + it("normalizes OpenClaw Codex model ids for ACP startup", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore, { + agentRegistry: { + resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName), + list: () => ["codex", "openclaw"], + }, + }); + const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "codex", + }); + + await runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + model: "openai-codex/gpt-5.4", + }); + + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-5.4", + }), + ); + }); + + it("leaves Codex ACP startup defaults alone when no model or thinking is provided", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore, { + agentRegistry: { + resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName), + list: () => ["codex", "openclaw"], + }, + }); + const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "codex", + }); + + await runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + }); + + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + agent: "codex", + }), + ); + expect(ensure.mock.calls[0]?.[0]).not.toHaveProperty("model"); + expect(ensure.mock.calls[0]?.[0]).not.toHaveProperty("thinking"); + }); + + it("does not normalize model startup for non-Codex ACP agents", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore, { + agentRegistry: { + resolve: (agentName: string) => (agentName === "main" ? CODEX_ACP_COMMAND : agentName), + list: () => ["main", "codex", "openclaw"], + }, + }); + const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:main:acp:test", + backend: "acpx", + runtimeSessionName: "main", + }); + + await runtime.ensureSession({ + sessionKey: "agent:main:acp:test", + agent: "main", + mode: "persistent", + model: "openai-codex/gpt-5.5", + }); + + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + agent: "main", + model: "openai-codex/gpt-5.5", + }), + ); + }); + + it("injects Codex ACP startup config into the scoped registry", () => { + expect(__testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true); + expect(__testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true); + expect( + __testing.appendCodexAcpConfigOverrides(CODEX_ACP_COMMAND, { + model: "gpt-5.4", + reasoningEffort: "medium", + }), + ).toBe( + "npx @zed-industries/codex-acp@^0.11.1 -c model=gpt-5.4 -c model_reasoning_effort=medium", + ); + expect(__testing.isCodexAcpCommand("openclaw acp")).toBe(false); + }); + + it("passes gpt-5.5 Codex ACP startup through instead of blocking it", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore, { + agentRegistry: { + resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName), + list: () => ["codex", "openclaw"], + }, + }); + const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "codex", + }); + + await runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + model: "openai-codex/gpt-5.5", + }); + + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-5.5", + }), + ); + }); + + it("maps explicit Codex ACP thinking to startup reasoning effort", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore, { + agentRegistry: { + resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName), + list: () => ["codex", "openclaw"], + }, + }); + const ensure = vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "codex", + }); + + await runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + model: "openai-codex/gpt-5.4", + thinking: "x-high", + }); + + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-5.4/xhigh", + }), + ); + }); + + it("normalizes Codex ACP model config controls to adapter ids", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => ({ + acpxRecordId: "agent:codex:acp:test", + agentCommand: CODEX_ACP_COMMAND, + })), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore); + const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined); + const handle: Parameters>[0]["handle"] = { + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "agent:codex:acp:test", + acpxRecordId: "agent:codex:acp:test", + }; + + await runtime.setConfigOption({ + handle, + key: "model", + value: "openai-codex/gpt-5.4", + }); + + expect(setConfigOption).toHaveBeenNthCalledWith(1, { + handle, + key: "model", + value: "gpt-5.4", + }); + expect(setConfigOption).toHaveBeenCalledOnce(); + }); + + it("normalizes Codex ACP slash reasoning suffixes to config controls", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => ({ + acpxRecordId: "agent:codex:acp:test", + agentCommand: CODEX_ACP_COMMAND, + })), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore); + const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined); + const handle: Parameters>[0]["handle"] = { + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "agent:codex:acp:test", + acpxRecordId: "agent:codex:acp:test", + }; + + await runtime.setConfigOption({ + handle, + key: "model", + value: "openai-codex/gpt-5.4/high", + }); + + expect(setConfigOption).toHaveBeenNthCalledWith(1, { + handle, + key: "model", + value: "gpt-5.4", + }); + expect(setConfigOption).toHaveBeenNthCalledWith(2, { + handle, + key: "reasoning_effort", + value: "high", + }); + }); + + it("normalizes Codex ACP thinking config controls to reasoning effort", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => ({ + acpxRecordId: "agent:codex:acp:test", + agentCommand: CODEX_ACP_COMMAND, + })), + save: vi.fn(async () => {}), + }; + const { runtime, delegate } = makeRuntime(baseStore); + const setConfigOption = vi.spyOn(delegate, "setConfigOption").mockResolvedValue(undefined); + const handle: Parameters>[0]["handle"] = { + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "agent:codex:acp:test", + acpxRecordId: "agent:codex:acp:test", + }; + + await runtime.setConfigOption({ + handle, + key: "thinking", + value: "minimal", + }); + + expect(setConfigOption).toHaveBeenCalledWith({ + handle, + key: "reasoning_effort", + value: "low", + }); + }); + it("keeps stale persistent loads hidden until a fresh record is saved", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never), diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 6c77f8f90c7..9acdf7fe201 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { ACPX_BACKEND_ID, AcpxRuntime as BaseAcpxRuntime, @@ -13,7 +14,7 @@ import { type AcpRuntimeOptions, type AcpRuntimeStatus, } from "acpx/runtime"; -import type { AcpRuntime } from "../runtime-api.js"; +import { AcpRuntimeError, type AcpRuntime } from "../runtime-api.js"; type AcpSessionStore = AcpRuntimeOptions["sessionStore"]; type AcpSessionRecord = Parameters[0]; @@ -60,6 +61,27 @@ function createResetAwareSessionStore(baseStore: AcpSessionStore): ResetAwareSes const OPENCLAW_BRIDGE_EXECUTABLE = "openclaw"; const OPENCLAW_BRIDGE_SUBCOMMAND = "acp"; +const CODEX_ACP_AGENT_ID = "codex"; +const CODEX_ACP_OPENCLAW_PREFIX = "openai-codex/"; +const CODEX_ACP_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); +const CODEX_ACP_THINKING_ALIASES = new Map([ + ["off", undefined], + ["minimal", "low"], + ["low", "low"], + ["medium", "medium"], + ["high", "high"], + ["x-high", "xhigh"], + ["x_high", "xhigh"], + ["extra-high", "xhigh"], + ["extra_high", "xhigh"], + ["extra high", "xhigh"], + ["xhigh", "xhigh"], +]); + +type CodexAcpModelOverride = { + model?: string; + reasoningEffort?: string; +}; function normalizeAgentName(value: string | undefined): string | undefined { const normalized = value?.trim().toLowerCase(); @@ -175,6 +197,149 @@ function isOpenClawBridgeCommand(command: string | undefined): boolean { return /^openclaw(?:\.[cm]?js)?$/i.test(scriptName) && parts[2] === OPENCLAW_BRIDGE_SUBCOMMAND; } +function isCodexAcpPackageSpec(value: string): boolean { + return /^@zed-industries\/codex-acp(?:@.+)?$/i.test(value.trim()); +} + +function isCodexAcpCommand(command: string | undefined): boolean { + if (!command) { + return false; + } + const parts = unwrapEnvCommand(splitCommandParts(command.trim())); + if (!parts.length) { + return false; + } + if (parts.some(isCodexAcpPackageSpec)) { + return true; + } + const commandName = basename(parts[0] ?? ""); + if (/^codex-acp(?:\.exe)?$/i.test(commandName)) { + return true; + } + if (commandName !== "node") { + return false; + } + const scriptName = basename(parts[1] ?? ""); + return /^codex-acp(?:-wrapper)?(?:\.[cm]?js)?$/i.test(scriptName); +} + +function failUnsupportedCodexAcpModel(rawModel: string, detail?: string): never { + throw new AcpRuntimeError( + "ACP_INVALID_RUNTIME_OPTION", + detail ?? + `Codex ACP model "${rawModel}" is not supported. Use openai-codex/ or /.`, + ); +} + +function failUnsupportedCodexAcpThinking(rawThinking: string): never { + throw new AcpRuntimeError( + "ACP_INVALID_RUNTIME_OPTION", + `Codex ACP thinking level "${rawThinking}" is not supported. Use off, minimal, low, medium, high, or xhigh.`, + ); +} + +function normalizeCodexAcpReasoningEffort(rawThinking: string | undefined): string | undefined { + const normalized = rawThinking?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (!CODEX_ACP_THINKING_ALIASES.has(normalized)) { + failUnsupportedCodexAcpThinking(rawThinking ?? ""); + } + return CODEX_ACP_THINKING_ALIASES.get(normalized); +} + +function normalizeCodexAcpModelOverride( + rawModel: string | undefined, + rawThinking?: string, +): CodexAcpModelOverride | undefined { + const raw = rawModel?.trim(); + const thinkingReasoningEffort = normalizeCodexAcpReasoningEffort(rawThinking); + + if (!raw) { + return thinkingReasoningEffort ? { reasoningEffort: thinkingReasoningEffort } : undefined; + } + + let value = raw; + if (value.toLowerCase().startsWith(CODEX_ACP_OPENCLAW_PREFIX)) { + value = value.slice(CODEX_ACP_OPENCLAW_PREFIX.length); + } + const parts = value.split("/"); + if (parts.length > 2) { + failUnsupportedCodexAcpModel( + raw, + `Codex ACP model "${raw}" is not supported. Use openai-codex/ or /.`, + ); + } + const model = (parts[0] ?? "").trim(); + const modelReasoningEffort = normalizeCodexAcpReasoningEffort(parts[1]); + if (!model) { + failUnsupportedCodexAcpModel( + raw, + `Codex ACP model "${raw}" is not supported. Use openai-codex/ or /.`, + ); + } + const reasoningEffort = thinkingReasoningEffort ?? modelReasoningEffort; + if (reasoningEffort && !CODEX_ACP_REASONING_EFFORTS.has(reasoningEffort)) { + failUnsupportedCodexAcpThinking(reasoningEffort); + } + return { + model, + ...(reasoningEffort ? { reasoningEffort } : {}), + }; +} + +function codexAcpSessionModelId(override: CodexAcpModelOverride): string { + if (!override.model) { + return ""; + } + return override.reasoningEffort + ? `${override.model}/${override.reasoningEffort}` + : override.model; +} + +function quoteShellArg(value: string): string { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function appendCodexAcpConfigOverrides(command: string, override: CodexAcpModelOverride): string { + const configArgs = override.model ? [`model=${override.model}`] : []; + if (override.reasoningEffort) { + configArgs.push(`model_reasoning_effort=${override.reasoningEffort}`); + } + if (configArgs.length === 0) { + return command; + } + return `${command} ${configArgs.map((arg) => `-c ${quoteShellArg(arg)}`).join(" ")}`; +} + +function createModelScopedAgentRegistry(params: { + agentRegistry: AcpAgentRegistry; + scope: AsyncLocalStorage; +}): AcpAgentRegistry { + return { + resolve(agentName: string): string | undefined { + const command = params.agentRegistry.resolve(agentName); + const override = params.scope.getStore(); + if ( + !override || + normalizeAgentName(agentName) !== CODEX_ACP_AGENT_ID || + typeof command !== "string" || + !isCodexAcpCommand(command) + ) { + return command; + } + return appendCodexAcpConfigOverrides(command, override); + }, + list(): string[] { + return params.agentRegistry.list(); + }, + }; +} + function resolveAgentCommand(params: { agentName: string | undefined; agentRegistry: AcpAgentRegistry; @@ -211,6 +376,10 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean { export class AcpxRuntime implements AcpRuntime { private readonly sessionStore: ResetAwareSessionStore; private readonly agentRegistry: AcpAgentRegistry; + private readonly scopedAgentRegistry: AcpAgentRegistry; + private readonly codexAcpModelOverrideScope = new AsyncLocalStorage< + CodexAcpModelOverride | undefined + >(); private readonly delegate: BaseAcpxRuntime; private readonly bridgeSafeDelegate: BaseAcpxRuntime; private readonly probeDelegate: BaseAcpxRuntime; @@ -221,9 +390,14 @@ export class AcpxRuntime implements AcpRuntime { ) { this.sessionStore = createResetAwareSessionStore(options.sessionStore); this.agentRegistry = options.agentRegistry; + this.scopedAgentRegistry = createModelScopedAgentRegistry({ + agentRegistry: this.agentRegistry, + scope: this.codexAcpModelOverrideScope, + }); const sharedOptions = { ...options, sessionStore: this.sessionStore, + agentRegistry: this.scopedAgentRegistry, }; this.delegate = new BaseAcpxRuntime(sharedOptions, testOptions); this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options) @@ -259,6 +433,18 @@ export class AcpxRuntime implements AcpRuntime { return this.resolveDelegateForAgent(readAgentFromHandle(handle)); } + private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise { + const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey); + const recordCommand = readAgentCommandFromRecord(record); + if (recordCommand) { + return recordCommand; + } + return resolveAgentCommandForName({ + agentName: readAgentFromHandle(handle), + agentRegistry: this.agentRegistry, + }); + } + isHealthy(): boolean { return this.probeDelegate.isHealthy(); } @@ -271,8 +457,32 @@ export class AcpxRuntime implements AcpRuntime { return this.probeDelegate.doctor(); } - ensureSession(input: Parameters[0]): Promise { - return this.resolveDelegateForAgent(input.agent).ensureSession(input); + async ensureSession( + input: Parameters[0], + ): Promise { + const command = resolveAgentCommandForName({ + agentName: input.agent, + agentRegistry: this.agentRegistry, + }); + const delegate = this.resolveDelegateForCommand(command); + const codexModelOverride = + normalizeAgentName(input.agent) === CODEX_ACP_AGENT_ID && isCodexAcpCommand(command) + ? normalizeCodexAcpModelOverride(input.model, input.thinking) + : undefined; + + if (!codexModelOverride) { + return delegate.ensureSession(input); + } + + const normalizedInput = { + ...input, + ...(codexAcpSessionModelId(codexModelOverride) + ? { model: codexAcpSessionModelId(codexModelOverride) } + : {}), + }; + return this.codexAcpModelOverrideScope.run(codexModelOverride, () => + delegate.ensureSession(normalizedInput), + ); } async *runTurn(input: Parameters[0]): AsyncIterable { @@ -299,6 +509,39 @@ export class AcpxRuntime implements AcpRuntime { input: Parameters>[0], ): Promise { const delegate = await this.resolveDelegateForHandle(input.handle); + const command = await this.resolveCommandForHandle(input.handle); + if ( + (input.key === "model" || + input.key === "thinking" || + input.key === "thought_level" || + input.key === "reasoning_effort") && + isCodexAcpCommand(command) + ) { + const override = + input.key === "model" + ? normalizeCodexAcpModelOverride(input.value) + : normalizeCodexAcpModelOverride(undefined, input.value); + if (!override && input.key !== "model") { + return; + } + if (override) { + if (override.model) { + await delegate.setConfigOption({ + ...input, + key: "model", + value: override.model, + }); + } + if (override.reasoningEffort) { + await delegate.setConfigOption({ + ...input, + key: "reasoning_effort", + value: override.reasoningEffort, + }); + } + return; + } + } await delegate.setConfigOption(input); } @@ -334,4 +577,11 @@ export { encodeAcpxRuntimeHandleState, }; +export const __testing = { + appendCodexAcpConfigOverrides, + codexAcpSessionModelId, + isCodexAcpCommand, + normalizeCodexAcpModelOverride, +}; + export type { AcpAgentRegistry, AcpRuntimeOptions, AcpSessionRecord, AcpSessionStore }; diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index 83346f145a8..f8a2957eae7 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -316,6 +316,8 @@ export class AcpSessionManager { ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), }); const requestedCwd = initialRuntimeOptions.cwd; + const requestedModel = initialRuntimeOptions.model; + const requestedThinking = initialRuntimeOptions.thinking; this.enforceConcurrentSessionLimit({ cfg: input.cfg, sessionKey, @@ -327,6 +329,8 @@ export class AcpSessionManager { agent, mode: input.mode, resumeSessionId: input.resumeSessionId, + ...(requestedModel ? { model: requestedModel } : {}), + ...(requestedThinking ? { thinking: requestedThinking } : {}), cwd: requestedCwd, }), fallbackCode: "ACP_SESSION_INIT_FAILED", @@ -1378,6 +1382,8 @@ export class AcpSessionManager { const mode = params.meta.mode; const runtimeOptions = resolveRuntimeOptionsFromMeta(params.meta); const cwd = runtimeOptions.cwd ?? normalizeText(params.meta.cwd); + const model = normalizeText(runtimeOptions.model); + const thinking = normalizeText(runtimeOptions.thinking); const configuredBackend = (params.meta.backend || params.cfg.acp?.backend || "").trim(); const cached = this.getCachedRuntimeState(params.sessionKey); if (cached) { @@ -1434,6 +1440,8 @@ export class AcpSessionManager { agent, mode, ...(resumeSessionId ? { resumeSessionId } : {}), + ...(model ? { model } : {}), + ...(thinking ? { thinking } : {}), cwd, }), fallbackCode: "ACP_SESSION_INIT_FAILED", diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 9130d405cf2..4d345c6dc32 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -104,7 +104,15 @@ function createRuntime(): { setConfigOption: ReturnType; } { const ensureSession = vi.fn( - async (input: { sessionKey: string; agent: string; mode: "persistent" | "oneshot" }) => ({ + async (input: { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + model?: string; + thinking?: string; + cwd?: string; + resumeSessionId?: string; + }) => ({ sessionKey: input.sessionKey, backend: "acpx", runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`, @@ -1064,6 +1072,82 @@ describe("AcpSessionManager", () => { ); }); + it("passes persisted model runtime options into ensureSession after restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:demo-binding:default:model-restart"; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: { + ...readySessionMeta(), + runtimeOptions: { + model: "openai-codex/gpt-5.4", + }, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-restart-model", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + model: "openai-codex/gpt-5.4", + }), + ); + }); + + it("passes persisted thinking runtime options into ensureSession after restart", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + const sessionKey = "agent:codex:acp:binding:demo-binding:default:thinking-restart"; + hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => { + const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey; + return { + sessionKey: key, + storeSessionKey: key, + acp: { + ...readySessionMeta(), + runtimeOptions: { + thinking: "high", + }, + }, + }; + }); + + const manager = new AcpSessionManager(); + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "after restart", + mode: "prompt", + requestId: "r-binding-restart-thinking", + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + thinking: "high", + }), + ); + }); + it("does not resume persisted ACP identity for oneshot sessions after restart", async () => { const runtimeState = createRuntime(); hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ @@ -1308,6 +1392,7 @@ describe("AcpSessionManager", () => { acp: readySessionMeta({ runtimeOptions: { model: "openai-codex/gpt-5.4", + thinking: "high", }, }), }); @@ -1320,12 +1405,21 @@ describe("AcpSessionManager", () => { mode: "persistent", runtimeOptions: { model: "openai-codex/gpt-5.4", + thinking: "high", }, }); expect(extractRuntimeOptionsFromUpserts()).toContainEqual({ model: "openai-codex/gpt-5.4", + thinking: "high", }); + expect(runtimeState.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:codex:acp:session-a", + model: "openai-codex/gpt-5.4", + thinking: "high", + }), + ); }); it("preserves runtimeOptions cwd when initializeSession cwd is omitted", async () => { @@ -2601,6 +2695,7 @@ describe("AcpSessionManager", () => { runtimeOptions: { runtimeMode: "plan", model: "openai-codex/gpt-5.4", + thinking: "high", permissionProfile: "strict", timeoutSeconds: 120, }, @@ -2627,6 +2722,12 @@ describe("AcpSessionManager", () => { value: "openai-codex/gpt-5.4", }), ); + expect(runtimeState.setConfigOption).toHaveBeenCalledWith( + expect.objectContaining({ + key: "thinking", + value: "high", + }), + ); expect(runtimeState.setConfigOption).toHaveBeenCalledWith( expect.objectContaining({ key: "approval_policy", diff --git a/src/acp/control-plane/runtime-options.ts b/src/acp/control-plane/runtime-options.ts index c9d632f8c70..1461ada8517 100644 --- a/src/acp/control-plane/runtime-options.ts +++ b/src/acp/control-plane/runtime-options.ts @@ -8,6 +8,7 @@ export { normalizeText } from "../normalize-text.js"; const MAX_RUNTIME_MODE_LENGTH = 64; const MAX_MODEL_LENGTH = 200; +const MAX_THINKING_LENGTH = 32; const MAX_PERMISSION_PROFILE_LENGTH = 80; const MAX_CWD_LENGTH = 4096; const MIN_TIMEOUT_SECONDS = 1; @@ -81,6 +82,14 @@ export function validateRuntimeModelInput(rawModel: unknown): string { }); } +export function validateRuntimeThinkingInput(rawThinking: unknown): string { + return validateBoundedText({ + value: rawThinking, + field: "Thinking level", + maxLength: MAX_THINKING_LENGTH, + }); +} + export function validateRuntimePermissionProfileInput(rawProfile: unknown): string { return validateBoundedText({ value: rawProfile, @@ -145,6 +154,7 @@ export function validateRuntimeOptionPatch( const allowedKeys = new Set([ "runtimeMode", "model", + "thinking", "cwd", "permissionProfile", "timeoutSeconds", @@ -171,6 +181,13 @@ export function validateRuntimeOptionPatch( next.model = validateRuntimeModelInput(rawPatch.model); } } + if (Object.hasOwn(rawPatch, "thinking")) { + if (rawPatch.thinking === undefined) { + next.thinking = undefined; + } else { + next.thinking = validateRuntimeThinkingInput(rawPatch.thinking); + } + } if (Object.hasOwn(rawPatch, "cwd")) { if (rawPatch.cwd === undefined) { next.cwd = undefined; @@ -220,6 +237,7 @@ export function normalizeRuntimeOptions( ): AcpSessionRuntimeOptions { const runtimeMode = normalizeText(options?.runtimeMode); const model = normalizeText(options?.model); + const thinking = normalizeText(options?.thinking); const cwd = normalizeText(options?.cwd); const permissionProfile = normalizeText(options?.permissionProfile); let timeoutSeconds: number | undefined; @@ -237,6 +255,7 @@ export function normalizeRuntimeOptions( return { ...(runtimeMode ? { runtimeMode } : {}), ...(model ? { model } : {}), + ...(thinking ? { thinking } : {}), ...(cwd ? { cwd } : {}), ...(permissionProfile ? { permissionProfile } : {}), ...(typeof timeoutSeconds === "number" ? { timeoutSeconds } : {}), @@ -287,6 +306,7 @@ export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): return JSON.stringify({ runtimeMode: normalized.runtimeMode ?? null, model: normalized.model ?? null, + thinking: normalized.thinking ?? null, permissionProfile: normalized.permissionProfile ?? null, timeoutSeconds: normalized.timeoutSeconds ?? null, backendExtras: extras, @@ -301,6 +321,9 @@ export function buildRuntimeConfigOptionPairs( if (normalized.model) { pairs.set("model", normalized.model); } + if (normalized.thinking) { + pairs.set("thinking", normalized.thinking); + } if (normalized.permissionProfile) { pairs.set("approval_policy", normalized.permissionProfile); } @@ -324,6 +347,13 @@ export function inferRuntimeOptionPatchFromConfigOption( if (normalizedKey === "model") { return { model: validateRuntimeModelInput(validated.value) }; } + if ( + normalizedKey === "thinking" || + normalizedKey === "thought_level" || + normalizedKey === "reasoning_effort" + ) { + return { thinking: validateRuntimeThinkingInput(validated.value) }; + } if ( normalizedKey === "approval_policy" || normalizedKey === "permission_profile" || diff --git a/src/acp/runtime/types.ts b/src/acp/runtime/types.ts index 419cc4af9c4..b0831df2b95 100644 --- a/src/acp/runtime/types.ts +++ b/src/acp/runtime/types.ts @@ -36,6 +36,10 @@ export type AcpRuntimeEnsureInput = { agent: string; mode: AcpRuntimeSessionMode; resumeSessionId?: string; + /** Optional runtime model override that must be available during session creation. */ + model?: string; + /** Optional runtime thinking/reasoning override that must be available during session creation. */ + thinking?: string; cwd?: string; env?: Record; }; diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index fe2e42d78bc..86c7f636e2c 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -716,12 +716,13 @@ describe("spawnAcpDirect", () => { expect(transcriptCalls[1]?.threadId).toBe("child-thread"); }); - it("passes model override into ACP session initialization", async () => { + it("passes model and thinking overrides into ACP session initialization", async () => { const result = await spawnAcpDirect( { task: "Investigate flaky tests", agentId: "codex", model: "openai-codex/gpt-5.4", + thinking: "high", }, { agentSessionKey: "agent:main:main", @@ -735,6 +736,7 @@ describe("spawnAcpDirect", () => { agent: "codex", runtimeOptions: { model: "openai-codex/gpt-5.4", + thinking: "high", }, }), ); diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 71f5e510ac4..de9bcb35a9b 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -98,6 +98,7 @@ export type SpawnAcpParams = { agentId?: string; resumeSessionId?: string; model?: string; + thinking?: string; cwd?: string; mode?: SpawnAcpMode; thread?: boolean; @@ -826,6 +827,7 @@ async function initializeAcpSpawnRuntime(params: { runtimeMode: AcpRuntimeSessionMode; resumeSessionId?: string; model?: string; + thinking?: string; cwd?: string; }): Promise { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId }); @@ -850,7 +852,13 @@ async function initializeAcpSpawnRuntime(params: { agent: params.targetAgentId, mode: params.runtimeMode, resumeSessionId: params.resumeSessionId, - runtimeOptions: params.model ? { model: params.model } : undefined, + runtimeOptions: + params.model || params.thinking + ? { + ...(params.model ? { model: params.model } : {}), + ...(params.thinking ? { thinking: params.thinking } : {}), + } + : undefined, cwd: params.cwd, backendId: params.cfg.acp?.backend, }); @@ -1191,6 +1199,7 @@ export async function spawnAcpDirect( runtimeMode, resumeSessionId: params.resumeSessionId, model: params.model, + thinking: params.thinking, cwd: runtimeCwd, }); initializedRuntime = initializedSession.runtimeCloseHandle; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 04fe8d605f7..1372fbd2231 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -259,6 +259,7 @@ export function createSessionsSpawnTool( agentId: requestedAgentId, resumeSessionId, model: modelOverride, + thinking: thinkingOverrideRaw, cwd, mode: mode === "run" || mode === "session" ? mode : undefined, thread, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 6c9a24c571f..9822753b6a5 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -58,6 +58,8 @@ export type AcpSessionRuntimeOptions = { runtimeMode?: string; /** ACP runtime config option: model id. */ model?: string; + /** ACP runtime config option: thinking/reasoning effort. */ + thinking?: string; /** Working directory override for ACP session turns. */ cwd?: string; /** ACP runtime config option: permission profile id. */