diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 6f0fb6204eaf..7b40e2a9957a 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -1020,6 +1020,269 @@ describe("AcpxRuntime fresh reset wrapper", () => { ).not.toContain("gpt-5.6-sol/medium"); }); + it.each<{ model: string | undefined; thinking?: string; override: Record }>([ + { model: "openai/gpt-5.4", override: { model: "gpt-5.4" } }, + { + model: "openai/gpt-5.4", + thinking: "high", + override: { model: "gpt-5.4", reasoningEffort: "high" }, + }, + { model: "gpt-5.4/high", override: { model: "gpt-5.4", reasoningEffort: "high" } }, + { model: "gpt-5.4", override: { model: "gpt-5.4" } }, + { model: undefined, thinking: "low", override: { reasoningEffort: "low" } }, + { model: "", override: {} }, + ])( + "classifies supported Codex ACP model request ($model, $thinking) as an override", + ({ model, thinking, override }) => { + expect(testing.classifyCodexAcpModelRequest(model, thinking)).toEqual({ + kind: "override", + override, + }); + }, + ); + + it.each<{ model: string; thinking?: string; expected: Record }>([ + { model: "google/gemini-3.1-flash-lite", expected: { kind: "unsupported" } }, + { + model: "google/gemini-3.1-flash-lite", + thinking: "low", + expected: { kind: "unsupported", thinkingOverride: { reasoningEffort: "low" } }, + }, + { model: "gpt-5.4/ultra", expected: { kind: "unsupported" } }, + { model: "/high", expected: { kind: "unsupported" } }, + ])( + "classifies unsupported Codex ACP model request ($model, $thinking) without thinking-slot routing", + ({ model, thinking, expected }) => { + expect(testing.classifyCodexAcpModelRequest(model, thinking)).toEqual(expected); + }, + ); + + it.each(["openai/foo/bar", "openai/", "openai//high"])( + "fails closed on malformed openai-qualified Codex ACP model request %s", + (model) => { + expect(() => testing.classifyCodexAcpModelRequest(model)).toThrow(AcpRuntimeError); + }, + ); + + it("fails closed on an unsupported Codex ACP thinking value", () => { + expect(() => testing.classifyCodexAcpModelRequest(undefined, "superhigh")).toThrow( + AcpRuntimeError, + ); + }); + + it("starts Codex ACP without injecting a leaked non-openai default model", 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: "google/gemini-3.1-flash-lite", + }); + + const ensureInput = readFirstEnsureSessionInput(ensure); + expect(ensureInput).toEqual({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + }); + expect(ensureInput).not.toHaveProperty("model"); + expect(ensureInput).not.toHaveProperty("sessionOptions"); + }); + + it("reports a dropped leaked non-openai default on the returned handle", 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"], + }, + }); + vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "codex", + }); + + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + model: "google/gemini-3.1-flash-lite", + }); + + expect(handle.appliedModel).toEqual({ kind: "dropped" }); + }); + + it("reports a supported codex model as applied on the returned handle", 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"], + }, + }); + vi.spyOn(delegate, "ensureSession").mockResolvedValue({ + sessionKey: "agent:codex:acp:test", + backend: "acpx", + runtimeSessionName: "codex", + }); + + const handle = await runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + model: "openai/gpt-5.5", + }); + + expect(handle.appliedModel).toEqual({ kind: "applied", model: "openai/gpt-5.5" }); + }); + + it("applies explicit Codex ACP thinking while dropping a leaked non-openai default model", 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: "google/gemini-3.1-flash-lite", + thinking: "low", + }); + + const ensureInput = readFirstEnsureSessionInput(ensure); + expect(ensureInput).not.toHaveProperty("model"); + expect(ensureInput).not.toHaveProperty("sessionOptions"); + expect(ensureInput).toMatchObject({ thinking: "low" }); + }); + + it("drops a leaked malformed Codex ACP default at spawn instead of failing the session", 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: "gpt-5.4/ultra", + }); + + const ensureInput = readFirstEnsureSessionInput(ensure); + expect(ensureInput).not.toHaveProperty("model"); + expect(ensureInput).not.toHaveProperty("sessionOptions"); + }); + + it.each(["google/gemini-3.1-flash-lite", "gpt-5.4/ultra"])( + "fails closed on an explicit unsupported Codex ACP spawn model %s without calling the delegate", + async (model) => { + 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 expect( + runtime.ensureSession({ + sessionKey: "agent:codex:acp:test", + agent: "codex", + mode: "persistent", + model, + modelExplicit: true, + }), + ).rejects.toMatchObject({ code: "ACP_INVALID_RUNTIME_OPTION" }); + expect(ensure).not.toHaveBeenCalled(); + }, + ); + + it("passes an explicit supported Codex ACP spawn model through without leaking the provenance flag", 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/gpt-5.5", + modelExplicit: true, + }); + + const ensureInput = readFirstEnsureSessionInput(ensure); + expect(ensureInput).not.toHaveProperty("modelExplicit"); + expect(ensureInput).toMatchObject({ + model: "gpt-5.5", + sessionOptions: { model: "gpt-5.5" }, + }); + }); + it("normalizes Codex ACP model config controls to adapter ids", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ @@ -1051,6 +1314,32 @@ describe("AcpxRuntime fresh reset wrapper", () => { expect(setConfigOption).toHaveBeenCalledOnce(); }); + it.each(["google/gemini-3.1-flash-lite", "gpt-5.4/ultra", "openai/foo/bar"])( + "fails closed on Codex ACP model config control %s without re-injecting it", + async (value) => { + 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 expect(runtime.setConfigOption({ handle, key: "model", value })).rejects.toMatchObject({ + code: "ACP_INVALID_RUNTIME_OPTION", + }); + expect(setConfigOption).not.toHaveBeenCalled(); + }, + ); + it("normalizes Codex ACP slash reasoning suffixes to config controls", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 45fa648078c3..8461188e9bc6 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -60,6 +60,7 @@ type AcpxRuntimeTestOptions = Record & { }; type OpenClawRuntimeTurnInput = Parameters>[0]; type OpenClawRuntimeEnsureInput = Parameters[0]; +type OpenClawRuntimeHandle = Awaited>; type AcpxDelegateEnsureInput = Parameters[0]; type AcpxMcpServer = NonNullable[number]; @@ -340,7 +341,6 @@ const OPENCLAW_BRIDGE_SUBCOMMAND = "acp"; const CODEX_ACP_AGENT_ID = "codex"; const CODEX_ACP_OPENCLAW_PREFIX = "openai/"; const CLAUDE_ACP_OPENCLAW_PREFIX = "anthropic/"; -const CODEX_ACP_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh"]); const CODEX_ACP_THINKING_ALIASES = new Map([ ["off", undefined], ["minimal", "low"], @@ -360,6 +360,10 @@ type CodexAcpModelOverride = { reasoningEffort?: string; }; +type CodexAcpModelClassification = + | { kind: "override"; override: CodexAcpModelOverride } + | { kind: "unsupported"; thinkingOverride?: CodexAcpModelOverride }; + function normalizeAgentName(value: string | undefined): string | undefined { const normalized = value?.trim().toLowerCase(); return normalized ? normalized : undefined; @@ -531,46 +535,74 @@ function normalizeCodexAcpReasoningEffort(rawThinking: string | undefined): stri return CODEX_ACP_THINKING_ALIASES.get(normalized); } -function normalizeCodexAcpModelOverride( +function isCodexAcpReasoningEffortAlias(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return Boolean(normalized && CODEX_ACP_THINKING_ALIASES.has(normalized)); +} + +function classifyCodexAcpModelRequest( rawModel: string | undefined, rawThinking?: string, -): CodexAcpModelOverride | undefined { +): CodexAcpModelClassification { const raw = rawModel?.trim(); const thinkingReasoningEffort = normalizeCodexAcpReasoningEffort(rawThinking); - + const thinkingOnlyOverride = thinkingReasoningEffort + ? { reasoningEffort: thinkingReasoningEffort } + : undefined; if (!raw) { - return thinkingReasoningEffort ? { reasoningEffort: thinkingReasoningEffort } : undefined; + return { kind: "override", override: thinkingOnlyOverride ?? {} }; } let value = raw; + let hadOpenAiQualifier = false; if (value.toLowerCase().startsWith(CODEX_ACP_OPENCLAW_PREFIX)) { value = value.slice(CODEX_ACP_OPENCLAW_PREFIX.length); + hadOpenAiQualifier = true; } - const parts = value.split("/"); - if (parts.length > 2) { + + let model = value.trim(); + let modelReasoningEffort: string | undefined; + const slashIndex = value.lastIndexOf("/"); + if (slashIndex >= 0 && isCodexAcpReasoningEffortAlias(value.slice(slashIndex + 1))) { + modelReasoningEffort = normalizeCodexAcpReasoningEffort(value.slice(slashIndex + 1)); + model = value.slice(0, slashIndex).trim(); + } + + if (hadOpenAiQualifier && (!model || model.includes("/"))) { failUnsupportedCodexAcpModel( raw, `Codex ACP model "${raw}" is not supported. Use openai/ or /.`, ); } - const model = (parts[0] ?? "").trim(); - const modelReasoningEffort = normalizeCodexAcpReasoningEffort(parts[1]); - if (!model) { - failUnsupportedCodexAcpModel( - raw, - `Codex ACP model "${raw}" is not supported. Use openai/ or /.`, - ); + if (!model || model.includes("/")) { + return thinkingOnlyOverride + ? { kind: "unsupported", thinkingOverride: thinkingOnlyOverride } + : { kind: "unsupported" }; } + const reasoningEffort = thinkingReasoningEffort ?? modelReasoningEffort; - if (reasoningEffort && !CODEX_ACP_REASONING_EFFORTS.has(reasoningEffort)) { - failUnsupportedCodexAcpThinking(reasoningEffort); - } return { - model, - ...(reasoningEffort ? { reasoningEffort } : {}), + kind: "override", + override: { + model, + ...(reasoningEffort ? { reasoningEffort } : {}), + }, }; } +function withCodexSessionModel( + input: T, + override: CodexAcpModelOverride | undefined, +): T { + const next = { ...input }; + if (override?.model) { + next.model = override.model; + } else { + delete next.model; + } + return next; +} + function normalizeClaudeAcpModelOverride(rawModel: string | undefined): string | undefined { const raw = rawModel?.trim(); if (!raw) { @@ -586,8 +618,9 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions; const model = input.model?.trim() || existingOptions?.model; const sessionOptions = model ? { ...existingOptions, model } : existingOptions; + const { modelExplicit: _modelExplicit, ...rest } = input; return { - ...input, + ...rest, ...(sessionOptions ? { sessionOptions } : {}), } as AcpxDelegateEnsureInput; } @@ -1066,21 +1099,44 @@ export class AcpxRuntime implements AcpRuntime { async ensureSession( input: Parameters[0], - ): Promise { + ): Promise { assertSupportedRuntimeSessionMode(input.mode); const command = resolveAgentCommand({ agentName: input.agent, agentRegistry: this.agentRegistry, }); const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey }); + const isCodexAcp = + normalizeAgentName(input.agent) === CODEX_ACP_AGENT_ID && isCodexAcpCommand(command); const claudeModelOverride = isClaudeAcpCommand(command) ? normalizeClaudeAcpModelOverride(input.model) : undefined; + const codexClassification = isCodexAcp + ? classifyCodexAcpModelRequest(input.model, input.thinking) + : undefined; + if (codexClassification?.kind === "unsupported" && input.modelExplicit) { + failUnsupportedCodexAcpModel(input.model ?? ""); + } + const classifiedCodexOverride = + codexClassification?.kind === "override" + ? codexClassification.override + : codexClassification?.thinkingOverride; const codexModelOverride = - normalizeAgentName(input.agent) === CODEX_ACP_AGENT_ID && isCodexAcpCommand(command) - ? normalizeCodexAcpModelOverride(input.model, input.thinking) + classifiedCodexOverride && Object.keys(classifiedCodexOverride).length > 0 + ? classifiedCodexOverride : undefined; - const ensureInput = claudeModelOverride ? { ...input, model: claudeModelOverride } : input; + const requestedModel = input.model?.trim(); + const appliedModel: OpenClawRuntimeHandle["appliedModel"] = + isCodexAcp && requestedModel + ? codexModelOverride?.model + ? { kind: "applied", model: requestedModel } + : { kind: "dropped" } + : undefined; + const ensureInput = isCodexAcp + ? withCodexSessionModel(input, codexModelOverride) + : claudeModelOverride + ? { ...input, model: claudeModelOverride } + : input; const stableLaunchCommand = codexModelOverride && command ? appendCodexAcpConfigOverrides(command, codexModelOverride) @@ -1093,37 +1149,32 @@ export class AcpxRuntime implements AcpRuntime { resumeSessionId: input.resumeSessionId, })); - if (!codexModelOverride) { - return await this.runWithLaunchLease({ - sessionKey: ensureInput.sessionKey, - command: stableLaunchCommand, - enabled: shouldStartWithLease, - run: () => - this.withCodexWrapperDiagnostics({ - command: stableLaunchCommand, - fallbackCode: "ACP_SESSION_INIT_FAILED", - run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput), - }), - }); - } - - const normalizedInput = { - ...ensureInput, - ...(codexModelOverride.model ? { model: codexModelOverride.model } : {}), - }; - return await this.runWithLaunchLease({ - sessionKey: input.sessionKey, - command: stableLaunchCommand, - enabled: shouldStartWithLease, - run: () => - this.codexAcpModelOverrideScope.run(codexModelOverride, () => - this.withCodexWrapperDiagnostics({ - command: stableLaunchCommand, - fallbackCode: "ACP_SESSION_INIT_FAILED", - run: () => delegate.ensureSession(withAcpxSessionOptions(normalizedInput)), - }), - ), - }); + const handle = !codexModelOverride + ? await this.runWithLaunchLease({ + sessionKey: ensureInput.sessionKey, + command: stableLaunchCommand, + enabled: shouldStartWithLease, + run: () => + this.withCodexWrapperDiagnostics({ + command: stableLaunchCommand, + fallbackCode: "ACP_SESSION_INIT_FAILED", + run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput), + }), + }) + : await this.runWithLaunchLease({ + sessionKey: input.sessionKey, + command: stableLaunchCommand, + enabled: shouldStartWithLease, + run: () => + this.codexAcpModelOverrideScope.run(codexModelOverride, () => + this.withCodexWrapperDiagnostics({ + command: stableLaunchCommand, + fallbackCode: "ACP_SESSION_INIT_FAILED", + run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)), + }), + ), + }); + return appliedModel ? { ...handle, appliedModel } : handle; } async *runTurn(input: Parameters[0]): AsyncIterable { @@ -1305,36 +1356,37 @@ export class AcpxRuntime implements AcpRuntime { return; } if (isCodexAcp) { - if ( - key === "model" || - key === "thinking" || - key === "thought_level" || - key === "reasoning_effort" - ) { - const override = - key === "model" - ? normalizeCodexAcpModelOverride(input.value) - : normalizeCodexAcpModelOverride(undefined, input.value); - if (!override && 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, - }); - } + if (key === "model") { + const classification = classifyCodexAcpModelRequest(input.value); + if (classification.kind === "unsupported") { + failUnsupportedCodexAcpModel(input.value); + } + const { override } = classification; + 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; + } + if (key === "thinking" || key === "thought_level" || key === "reasoning_effort") { + const classification = classifyCodexAcpModelRequest(undefined, input.value); + const reasoningEffort = + classification.kind === "override" ? classification.override.reasoningEffort : undefined; + if (!reasoningEffort) { return; } + await delegate.setConfigOption({ + ...input, + key: "reasoning_effort", + value: reasoningEffort, + }); + return; } } if (isClaudeAcpCommand(command) && key === "model") { @@ -1400,10 +1452,10 @@ export { export const testing = { appendCodexAcpConfigOverrides, assertSupportedRuntimeSessionMode, + classifyCodexAcpModelRequest, isClaudeAcpCommand, isCodexAcpCommand, normalizeClaudeAcpModelOverride, - normalizeCodexAcpModelOverride, }; export type { AcpAgentRegistry, AcpRuntimeOptions, AcpSessionRecord, AcpSessionStore }; diff --git a/packages/acp-core/src/runtime/types.ts b/packages/acp-core/src/runtime/types.ts index f620fda424c4..c97a4b671029 100644 --- a/packages/acp-core/src/runtime/types.ts +++ b/packages/acp-core/src/runtime/types.ts @@ -32,6 +32,13 @@ export type AcpRuntimeHandle = { backendSessionId?: string; /** Upstream harness session identifier, if exposed by adapter/runtime. */ agentSessionId?: string; + /** + * Effective model the backend applied during session creation, when it can differ from the + * requested model. A backend that drops an unsupported inherited default reports `dropped` so + * the manager omits that model from persisted runtime controls instead of replaying a rejected + * model before the first turn. Absent when the backend did not deviate from the request. + */ + appliedModel?: { kind: "applied"; model: string } | { kind: "dropped" }; }; export type AcpRuntimeEnsureInput = { @@ -42,6 +49,12 @@ export type AcpRuntimeEnsureInput = { resumeSessionId?: string; /** Optional runtime model override that must be available during session creation. */ model?: string; + /** + * Whether `model` was an explicit caller selection rather than an inherited default. A backend + * that cannot honor an explicit unsupported model must fail closed; an unsupported inherited + * default may be dropped so the backend starts on its own default. + */ + modelExplicit?: boolean; /** Optional runtime thinking/reasoning override that must be available during session creation. */ thinking?: string; cwd?: string; diff --git a/src/acp/control-plane/manager.initialize-session.ts b/src/acp/control-plane/manager.initialize-session.ts index 4b08fa1b36e2..6ef198eaa3ba 100644 --- a/src/acp/control-plane/manager.initialize-session.ts +++ b/src/acp/control-plane/manager.initialize-session.ts @@ -59,6 +59,7 @@ export async function runManagerInitializeSession(params: { mode: input.mode, resumeSessionId: input.resumeSessionId, ...(requestedModel ? { model: requestedModel } : {}), + ...(requestedModel && input.modelExplicit ? { modelExplicit: true } : {}), ...(requestedThinking ? { thinking: requestedThinking } : {}), cwd: requestedCwd, }), @@ -66,8 +67,13 @@ export async function runManagerInitializeSession(params: { fallbackMessage: "Could not initialize ACP session runtime.", }); const effectiveCwd = normalizeText(handle.cwd) ?? requestedCwd; + const effectiveModel = resolveEffectiveSessionModel({ + requestedModel, + appliedModel: handle.appliedModel, + }); const effectiveRuntimeOptions = normalizeRuntimeOptions({ ...initialRuntimeOptions, + model: effectiveModel, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), }); @@ -130,6 +136,17 @@ export async function runManagerInitializeSession(params: { }; } +function resolveEffectiveSessionModel(params: { + requestedModel: string | undefined; + appliedModel: AcpRuntimeHandle["appliedModel"]; +}): string | undefined { + const { appliedModel } = params; + if (!appliedModel) { + return params.requestedModel; + } + return appliedModel.kind === "applied" ? appliedModel.model : undefined; +} + async function persistInitializedSessionMeta(params: { cfg: OpenClawConfig; sessionKey: string; diff --git a/src/acp/control-plane/manager.runtime-config-validation.test.ts b/src/acp/control-plane/manager.runtime-config-validation.test.ts new file mode 100644 index 000000000000..892903e8b780 --- /dev/null +++ b/src/acp/control-plane/manager.runtime-config-validation.test.ts @@ -0,0 +1,173 @@ +/** Tests ACP runtime config validation and backend-applied model persistence. */ +import { describe, expect, it } from "vitest"; +import { + AcpSessionManager, + baseCfg, + createRuntime, + expectMockCallFields, + expectNoMockCallFields, + expectRejectedRecord, + hoisted, + installAcpSessionManagerTestLifecycle, + readySessionMeta, + type SessionAcpMeta, +} from "./manager.test-helpers.js"; + +describe("AcpSessionManager runtime config validation", () => { + installAcpSessionManagerTestLifecycle(); + + it("rejects invalid runtime option values before backend controls run", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey: "agent:codex:acp:session-1", + storeSessionKey: "agent:codex:acp:session-1", + acp: readySessionMeta(), + }); + + const manager = new AcpSessionManager(); + await expectRejectedRecord( + manager.setSessionConfigOption({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + key: "timeout", + value: "not-a-number", + }), + { code: "ACP_INVALID_RUNTIME_OPTION" }, + ); + expect(runtimeState.setConfigOption).not.toHaveBeenCalled(); + + await expectRejectedRecord( + manager.updateSessionRuntimeOptions({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-1", + patch: { cwd: "relative/path" }, + }), + { code: "ACP_INVALID_RUNTIME_OPTION" }, + ); + }); + + it("never replays an inherited non-openai default that the backend dropped at session init", async () => { + const sessionKey = "agent:codex:acp:session-dropped-default"; + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockImplementation(async (input) => ({ + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `${input.sessionKey}:runtime`, + appliedModel: { kind: "dropped" as const }, + })); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + + let persistedMeta: SessionAcpMeta | undefined; + hoisted.upsertAcpSessionMetaMock.mockImplementation( + async (payload: { + mutate: (current: SessionAcpMeta | undefined) => SessionAcpMeta | null | undefined; + }) => { + persistedMeta = payload.mutate(undefined) ?? undefined; + return persistedMeta + ? { sessionKey, storeSessionKey: sessionKey, acp: persistedMeta } + : null; + }, + ); + + const manager = new AcpSessionManager(); + await manager.initializeSession({ + cfg: baseCfg, + sessionKey, + agent: "codex", + mode: "persistent", + runtimeOptions: { + model: "google/gemini-3.1-flash-lite", + thinking: "low", + }, + }); + + expect(persistedMeta?.runtimeOptions).toEqual({ thinking: "low" }); + + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey, + storeSessionKey: sessionKey, + acp: persistedMeta as SessionAcpMeta, + }); + + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "do work", + mode: "prompt", + requestId: "run-dropped-default", + provenance: "system", + }); + + expectNoMockCallFields(runtimeState.setConfigOption, { key: "model" }); + expectMockCallFields(runtimeState.setConfigOption, { key: "thinking", value: "low" }); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(1); + }); + + it("persists and replays a supported codex model the backend applied at session init", async () => { + const sessionKey = "agent:codex:acp:session-applied-model"; + const runtimeState = createRuntime(); + runtimeState.ensureSession.mockImplementation(async (input) => ({ + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `${input.sessionKey}:runtime`, + appliedModel: { kind: "applied" as const, model: input.model ?? "" }, + })); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + + let persistedMeta: SessionAcpMeta | undefined; + hoisted.upsertAcpSessionMetaMock.mockImplementation( + async (payload: { + mutate: (current: SessionAcpMeta | undefined) => SessionAcpMeta | null | undefined; + }) => { + persistedMeta = payload.mutate(undefined) ?? undefined; + return persistedMeta + ? { sessionKey, storeSessionKey: sessionKey, acp: persistedMeta } + : null; + }, + ); + + const manager = new AcpSessionManager(); + await manager.initializeSession({ + cfg: baseCfg, + sessionKey, + agent: "codex", + mode: "persistent", + runtimeOptions: { + model: "openai/gpt-5.5", + }, + }); + + expect(persistedMeta?.runtimeOptions).toEqual({ model: "openai/gpt-5.5" }); + + hoisted.readAcpSessionEntryMock.mockReturnValue({ + sessionKey, + storeSessionKey: sessionKey, + acp: persistedMeta as SessionAcpMeta, + }); + + await manager.runTurn({ + cfg: baseCfg, + sessionKey, + text: "do work", + mode: "prompt", + requestId: "run-applied-model", + provenance: "system", + }); + + expectMockCallFields(runtimeState.setConfigOption, { + key: "model", + value: "openai/gpt-5.5", + }); + expect(runtimeState.runTurn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/acp/control-plane/manager.runtime-config.test.ts b/src/acp/control-plane/manager.runtime-config.test.ts index 3985b887295b..74c800edb788 100644 --- a/src/acp/control-plane/manager.runtime-config.test.ts +++ b/src/acp/control-plane/manager.runtime-config.test.ts @@ -995,38 +995,4 @@ describe("AcpSessionManager runtime config", () => { }); expect(nextOptions).toEqual({ permissionProfile: "strict" }); }); - - it("rejects invalid runtime option values before backend controls run", async () => { - const runtimeState = createRuntime(); - hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ - id: "acpx", - runtime: runtimeState.runtime, - }); - hoisted.readAcpSessionEntryMock.mockReturnValue({ - sessionKey: "agent:codex:acp:session-1", - storeSessionKey: "agent:codex:acp:session-1", - acp: readySessionMeta(), - }); - - const manager = new AcpSessionManager(); - await expectRejectedRecord( - manager.setSessionConfigOption({ - cfg: baseCfg, - sessionKey: "agent:codex:acp:session-1", - key: "timeout", - value: "not-a-number", - }), - { code: "ACP_INVALID_RUNTIME_OPTION" }, - ); - expect(runtimeState.setConfigOption).not.toHaveBeenCalled(); - - await expectRejectedRecord( - manager.updateSessionRuntimeOptions({ - cfg: baseCfg, - sessionKey: "agent:codex:acp:session-1", - patch: { cwd: "relative/path" }, - }), - { code: "ACP_INVALID_RUNTIME_OPTION" }, - ); - }); }); diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index cd40daf851cd..c95f01f5d16c 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -49,6 +49,7 @@ export type AcpInitializeSessionInput = { mode: AcpRuntimeSessionMode; resumeSessionId?: string; runtimeOptions?: Partial; + modelExplicit?: boolean; cwd?: string; backendId?: string; }; diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 78b432c223a5..0d1172e3d334 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -1017,8 +1017,11 @@ function resolveAcpSpawnRuntimeOptions(params: { model?: string; thinking?: string; runTimeoutSeconds?: number; -}): { ok: true; runtimeOptions?: AcpSpawnRuntimeOptions } | { ok: false; error: string } { +}): + | { ok: true; runtimeOptions?: AcpSpawnRuntimeOptions; modelExplicit: boolean } + | { ok: false; error: string } { const policyAgentId = params.configAgentId ?? params.targetAgentId; + const modelExplicit = normalizeOptionalString(params.model) !== undefined; const model = resolveConfiguredSubagentSpawnModelSelection({ cfg: params.cfg, agentId: policyAgentId, @@ -1059,7 +1062,7 @@ function resolveAcpSpawnRuntimeOptions(params: { ...(timeoutSeconds ? { timeoutSeconds } : {}), } : undefined; - return { ok: true, runtimeOptions }; + return { ok: true, runtimeOptions, modelExplicit }; } async function initializeAcpSpawnRuntime(params: { @@ -1069,6 +1072,7 @@ async function initializeAcpSpawnRuntime(params: { runtimeMode: AcpRuntimeSessionMode; resumeSessionId?: string; runtimeOptions?: AcpSpawnRuntimeOptions; + modelExplicit?: boolean; cwd?: string; }): Promise { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId }); @@ -1096,6 +1100,7 @@ async function initializeAcpSpawnRuntime(params: { mode: params.runtimeMode, resumeSessionId: params.resumeSessionId, runtimeOptions: params.runtimeOptions, + modelExplicit: params.modelExplicit, cwd: params.cwd, backendId: params.cfg.acp?.backend, }); @@ -1495,6 +1500,7 @@ export async function spawnAcpDirect( runtimeMode, resumeSessionId: params.resumeSessionId, runtimeOptions: runtimeOptionsResult.runtimeOptions, + modelExplicit: runtimeOptionsResult.modelExplicit, cwd: runtimeCwd, }); initializedRuntime = initializedSession.runtimeCloseHandle;