From d0fe7bcb3caf4e8d6f995f7e7ba12439b85e3c0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 6 Aug 2026 15:57:44 -0700 Subject: [PATCH] fix(acp): pass configured agent model to ACP binding sessions (#120046) --- src/acp/persistent-bindings.lifecycle.test.ts | 40 ++++++++++++++++++- src/acp/persistent-bindings.lifecycle.ts | 16 +++++++- src/acp/persistent-bindings.test.ts | 22 ++++++---- src/acp/persistent-bindings.types.ts | 3 ++ .../acp-configured-binding-consumer.ts | 6 +++ 5 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/acp/persistent-bindings.lifecycle.test.ts b/src/acp/persistent-bindings.lifecycle.test.ts index a7e03d82015a..8da82715aaa7 100644 --- a/src/acp/persistent-bindings.lifecycle.test.ts +++ b/src/acp/persistent-bindings.lifecycle.test.ts @@ -59,6 +59,7 @@ function createPersistentSpec( function mockReadySession(params: { spec: ConfiguredAcpBindingSpec; cwd: string; + model?: string; state?: "idle" | "running" | "error"; }) { const sessionKey = buildConfiguredAcpSessionKey(params.spec); @@ -70,7 +71,10 @@ function mockReadySession(params: { agent: params.spec.acpAgentId ?? params.spec.agentId, runtimeSessionName: "existing", mode: params.spec.mode, - runtimeOptions: { cwd: params.cwd }, + runtimeOptions: { + cwd: params.cwd, + ...(params.model ? { model: params.model } : {}), + }, state: params.state ?? "idle", lastActivityAt: Date.now(), }, @@ -102,6 +106,7 @@ describe("ensureConfiguredAcpBindingSession", () => { const sessionKey = mockReadySession({ spec, cwd: "/workspace/openclaw", + model: "manual/selected-model", }); const ensured = await ensureConfiguredAcpBindingSession({ @@ -112,6 +117,32 @@ describe("ensureConfiguredAcpBindingSession", () => { expect(ensured).toEqual({ ok: true, sessionKey }); expect(managerMocks.closeSession).not.toHaveBeenCalled(); expect(managerMocks.initializeSession).not.toHaveBeenCalled(); + expect(managerMocks.updateSessionRuntimeOptions).not.toHaveBeenCalled(); + }); + + it("updates a configured model in place for a structurally matching session", async () => { + const spec = createPersistentSpec({ + model: "anthropic/claude-sonnet-4-6", + }); + const sessionKey = mockReadySession({ + spec, + cwd: "/workspace/openclaw", + model: "anthropic/claude-haiku-4-5", + }); + + const ensured = await ensureConfiguredAcpBindingSession({ + cfg: baseCfg, + spec, + }); + + expect(ensured).toEqual({ ok: true, sessionKey }); + expect(managerMocks.updateSessionRuntimeOptions).toHaveBeenCalledWith({ + cfg: baseCfg, + sessionKey, + patch: { model: "anthropic/claude-sonnet-4-6" }, + }); + expect(managerMocks.closeSession).not.toHaveBeenCalled(); + expect(managerMocks.initializeSession).not.toHaveBeenCalled(); }); it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => { @@ -155,10 +186,11 @@ describe("ensureConfiguredAcpBindingSession", () => { expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1); }); - it("initializes ACP session with runtime agent override when provided", async () => { + it("initializes ACP session with runtime agent override and configured model", async () => { const spec = createPersistentSpec({ agentId: "coding", acpAgentId: "codex", + model: "anthropic/claude-sonnet-4-6", }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); @@ -170,5 +202,9 @@ describe("ensureConfiguredAcpBindingSession", () => { expect(ensured.ok).toBe(true); const initializeArgs = expectInitializeArgs(); expect(initializeArgs.agent).toBe("codex"); + expect(initializeArgs.runtimeOptions).toEqual({ + model: "anthropic/claude-sonnet-4-6", + }); + expect(initializeArgs).not.toHaveProperty("modelExplicit"); }); }); diff --git a/src/acp/persistent-bindings.lifecycle.ts b/src/acp/persistent-bindings.lifecycle.ts index ad76e3820742..26e5540a6fd1 100644 --- a/src/acp/persistent-bindings.lifecycle.ts +++ b/src/acp/persistent-bindings.lifecycle.ts @@ -13,7 +13,7 @@ import { } from "./persistent-bindings.types.js"; // Binding lifecycle keeps configured channel conversations attached to matching ACP sessions. -function sessionMatchesConfiguredBinding(params: { +function sessionStructurallyMatchesConfiguredBinding(params: { cfg: OpenClawConfig; spec: ConfiguredAcpBindingSpec; meta: SessionAcpMeta; @@ -67,12 +67,23 @@ export async function ensureConfiguredAcpBindingSession(params: { }); if ( resolution.kind === "ready" && - sessionMatchesConfiguredBinding({ + sessionStructurallyMatchesConfiguredBinding({ cfg: params.cfg, spec: params.spec, meta: resolution.meta, }) ) { + // Model drift is live-configurable; preserve the bound conversation and patch it in place. + if ( + params.spec.model && + normalizeText(resolution.meta.runtimeOptions?.model) !== params.spec.model + ) { + await acpManager.updateSessionRuntimeOptions({ + cfg: params.cfg, + sessionKey, + patch: { model: params.spec.model }, + }); + } return { ok: true, sessionKey, @@ -95,6 +106,7 @@ export async function ensureConfiguredAcpBindingSession(params: { sessionKey, agent: params.spec.acpAgentId ?? params.spec.agentId, mode: params.spec.mode, + runtimeOptions: params.spec.model ? { model: params.spec.model } : undefined, cwd: params.spec.cwd, backendId: params.spec.backend, }); diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts index 901eebe01a3d..027af283da6d 100644 --- a/src/acp/persistent-bindings.test.ts +++ b/src/acp/persistent-bindings.test.ts @@ -391,18 +391,26 @@ beforeEach(() => { describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { - const cfg = createCfgWithBindings([ - createDiscordBinding({ - agentId: "codex", - conversationId: defaultDiscordConversationId, - acp: { cwd: "/repo/openclaw" }, - }), - ]); + const cfg = createCfgWithBindings( + [ + createDiscordBinding({ + agentId: "codex", + conversationId: defaultDiscordConversationId, + acp: { cwd: "/repo/openclaw" }, + }), + ], + { + agents: { + list: [{ id: "codex", model: { primary: "anthropic/claude-sonnet-4-6" } }], + }, + }, + ); const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.channel).toBe("discord"); expect(resolved?.spec.conversationId).toBe(defaultDiscordConversationId); expect(resolved?.spec.agentId).toBe("codex"); + expect(resolved?.spec.model).toBe("anthropic/claude-sonnet-4-6"); expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:"); expect(resolved?.record.metadata?.source).toBe("config"); }); diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index b2d5cca5c791..23a25433d910 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -25,6 +25,7 @@ export type ConfiguredAcpBindingSpec = { /** ACP harness agent id override (falls back to agentId when omitted). */ acpAgentId?: string; mode: AcpRuntimeSessionMode; + model?: string; cwd?: string; backend?: string; label?: string; @@ -101,6 +102,7 @@ export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): Se agentId: spec.agentId, ...(spec.acpAgentId ? { acpAgentId: spec.acpAgentId } : {}), label: spec.label, + ...(spec.model ? { model: spec.model } : {}), ...(spec.backend ? { backend: spec.backend } : {}), ...(spec.cwd ? { cwd: spec.cwd } : {}), }, @@ -158,6 +160,7 @@ export function resolveConfiguredAcpBindingSpecFromRecord( agentId, acpAgentId: normalizeText(record.metadata?.acpAgentId), mode: normalizeMode(record.metadata?.mode), + model: normalizeText(record.metadata?.model), cwd: normalizeText(record.metadata?.cwd), backend: normalizeText(record.metadata?.backend), label: normalizeText(record.metadata?.label), diff --git a/src/channels/plugins/acp-configured-binding-consumer.ts b/src/channels/plugins/acp-configured-binding-consumer.ts index fb2694fbd7b1..98f07b8f1ce8 100644 --- a/src/channels/plugins/acp-configured-binding-consumer.ts +++ b/src/channels/plugins/acp-configured-binding-consumer.ts @@ -15,6 +15,7 @@ import { } from "../../acp/persistent-bindings.types.js"; import { resolveAgentConfig, + resolveAgentExplicitModelPrimary, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope.js"; @@ -74,6 +75,7 @@ function buildConfiguredAcpSpec(params: { agentId: string; acpAgentId?: string; mode: "persistent" | "oneshot"; + model?: string; cwd?: string; backend?: string; label?: string; @@ -86,6 +88,7 @@ function buildConfiguredAcpSpec(params: { agentId: params.agentId, acpAgentId: params.acpAgentId, mode: params.mode, + model: params.model, cwd: params.cwd, backend: params.backend, label: params.label, @@ -109,6 +112,8 @@ function buildAcpTargetFactory(params: { }); const bindingOverrides = normalizeBindingConfig(params.binding.acp); const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode); + // Every ACP binding uses its owner's explicit model, regardless of the owner's runtime type. + const model = resolveAgentExplicitModelPrimary(params.cfg, params.agentId); const cwd = bindingOverrides.cwd ?? runtimeDefaults.cwd ?? @@ -132,6 +137,7 @@ function buildAcpTargetFactory(params: { agentId: params.agentId, acpAgentId, mode, + model, cwd, backend, label,