diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df90ff3236..029b12d29b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao. - CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl. - Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc. - Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev. diff --git a/src/acp/control-plane/manager.core.ts b/src/acp/control-plane/manager.core.ts index f0692a78c2a..83346f145a8 100644 --- a/src/acp/control-plane/manager.core.ts +++ b/src/acp/control-plane/manager.core.ts @@ -311,7 +311,10 @@ export class AcpSessionManager { return await this.withSessionActor(sessionKey, async () => { const backend = this.deps.requireRuntimeBackend(input.backendId || input.cfg.acp?.backend); const runtime = backend.runtime; - const initialRuntimeOptions = validateRuntimeOptionPatch({ cwd: input.cwd }); + const initialRuntimeOptions = validateRuntimeOptionPatch({ + ...input.runtimeOptions, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + }); const requestedCwd = initialRuntimeOptions.cwd; this.enforceConcurrentSessionLimit({ cfg: input.cfg, diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 02be0aa2da5..f78c6e6cd9c 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -1298,6 +1298,77 @@ describe("AcpSessionManager", () => { expect(runtimeState.ensureSession).toHaveBeenCalledTimes(1); }); + it("persists runtime options provided during initializeSession", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.upsertAcpSessionMetaMock.mockResolvedValue({ + sessionKey: "agent:codex:acp:session-a", + storeSessionKey: "agent:codex:acp:session-a", + acp: readySessionMeta({ + runtimeOptions: { + model: "openai-codex/gpt-5.4", + }, + }), + }); + + const manager = new AcpSessionManager(); + await manager.initializeSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-a", + agent: "codex", + mode: "persistent", + runtimeOptions: { + model: "openai-codex/gpt-5.4", + }, + }); + + expect(extractRuntimeOptionsFromUpserts()).toContainEqual({ + model: "openai-codex/gpt-5.4", + }); + }); + + it("preserves runtimeOptions cwd when initializeSession cwd is omitted", async () => { + const runtimeState = createRuntime(); + hoisted.requireAcpRuntimeBackendMock.mockReturnValue({ + id: "acpx", + runtime: runtimeState.runtime, + }); + hoisted.upsertAcpSessionMetaMock.mockResolvedValue({ + sessionKey: "agent:codex:acp:session-cwd-runtime-options", + storeSessionKey: "agent:codex:acp:session-cwd-runtime-options", + acp: readySessionMeta({ + runtimeOptions: { + cwd: "/workspace/from-runtime-options", + }, + cwd: "/workspace/from-runtime-options", + }), + }); + + const manager = new AcpSessionManager(); + await manager.initializeSession({ + cfg: baseCfg, + sessionKey: "agent:codex:acp:session-cwd-runtime-options", + agent: "codex", + mode: "persistent", + runtimeOptions: { + cwd: "/workspace/from-runtime-options", + }, + }); + + expect(runtimeState.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:codex:acp:session-cwd-runtime-options", + cwd: "/workspace/from-runtime-options", + }), + ); + expect(extractRuntimeOptionsFromUpserts()).toContainEqual({ + cwd: "/workspace/from-runtime-options", + }); + }); + it("drops cached runtime handles after tolerated close failures", async () => { const closeFailures = [ { diff --git a/src/acp/control-plane/manager.types.ts b/src/acp/control-plane/manager.types.ts index f1a5550a2db..f8357d24854 100644 --- a/src/acp/control-plane/manager.types.ts +++ b/src/acp/control-plane/manager.types.ts @@ -44,6 +44,7 @@ export type AcpInitializeSessionInput = { agent: string; mode: AcpRuntimeSessionMode; resumeSessionId?: string; + runtimeOptions?: Partial; cwd?: string; backendId?: string; }; diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 0694b3a7f4a..86def2e3375 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -718,6 +718,30 @@ describe("spawnAcpDirect", () => { expect(transcriptCalls[1]?.threadId).toBe("child-thread"); }); + it("passes model override into ACP session initialization", async () => { + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + model: "openai-codex/gpt-5.4", + }, + { + agentSessionKey: "agent:main:main", + }, + ); + + expectAcceptedSpawn(result); + expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: expect.stringMatching(/^agent:codex:acp:/), + agent: "codex", + runtimeOptions: { + model: "openai-codex/gpt-5.4", + }, + }), + ); + }); + it("inherits subagent envelope fields onto ACP children", async () => { replaceSpawnConfig({ ...hoisted.state.cfg, diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 150baacd425..7204362066d 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -104,6 +104,7 @@ export type SpawnAcpParams = { label?: string; agentId?: string; resumeSessionId?: string; + model?: string; cwd?: string; mode?: SpawnAcpMode; thread?: boolean; @@ -890,6 +891,7 @@ async function initializeAcpSpawnRuntime(params: { targetAgentId: string; runtimeMode: AcpRuntimeSessionMode; resumeSessionId?: string; + model?: string; cwd?: string; }): Promise { const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.targetAgentId }); @@ -914,6 +916,7 @@ async function initializeAcpSpawnRuntime(params: { agent: params.targetAgentId, mode: params.runtimeMode, resumeSessionId: params.resumeSessionId, + runtimeOptions: params.model ? { model: params.model } : undefined, cwd: params.cwd, backendId: params.cfg.acp?.backend, }); @@ -1249,6 +1252,7 @@ export async function spawnAcpDirect( targetAgentId, runtimeMode, resumeSessionId: params.resumeSessionId, + model: params.model, cwd: runtimeCwd, }); initializedRuntime = initializedSession.runtimeCloseHandle; diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 900d1c4bca5..d3c59bc68b1 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -247,6 +247,28 @@ describe("sessions_spawn tool", () => { expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled(); }); + it("forwards model override to ACP runtime spawns", async () => { + const tool = createSessionsSpawnTool({ + agentSessionKey: "agent:main:main", + }); + + await tool.execute("call-2-model", { + runtime: "acp", + task: "investigate the failing CI run", + agentId: "codex", + model: "github-copilot/claude-sonnet-4.6", + }); + + expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith( + expect.objectContaining({ + task: "investigate the failing CI run", + agentId: "codex", + model: "github-copilot/claude-sonnet-4.6", + }), + expect.any(Object), + ); + }); + it("adds requested role to forwarded ACP failures", async () => { hoisted.spawnAcpDirectMock.mockResolvedValueOnce({ status: "forbidden", diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index a9e64fc20e2..ccfa198b9ab 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -245,6 +245,7 @@ export function createSessionsSpawnTool( label: label || undefined, agentId: requestedAgentId, resumeSessionId, + model: modelOverride, cwd, mode: mode === "run" || mode === "session" ? mode : undefined, thread,