fix(acp): pass configured agent model to ACP binding sessions (#120046)

This commit is contained in:
Peter Steinberger
2026-08-06 15:57:44 -07:00
committed by GitHub
parent 7729100775
commit d0fe7bcb3c
5 changed files with 76 additions and 11 deletions
+38 -2
View File
@@ -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");
});
});
+14 -2
View File
@@ -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,
});
+15 -7
View File
@@ -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");
});
+3
View File
@@ -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),
@@ -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,