From 2475353fdc90cbf811dbe58936fcd6c6a168da63 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 17 Feb 2026 20:46:12 -0500 Subject: [PATCH] subagents: harden model patch regressions and tests --- ...agents.sessions-spawn-depth-limits.test.ts | 33 ++++++ ...subagents.sessions-spawn.model.e2e.test.ts | 3 +- src/agents/subagent-spawn.ts | 1 - src/auto-reply/reply/commands-subagents.ts | 2 +- src/commands/sessions.e2e.test.ts | 50 -------- .../sessions.model-resolution.test.ts | 108 ++++++++++++++++++ 6 files changed, 144 insertions(+), 53 deletions(-) create mode 100644 src/commands/sessions.model-resolution.test.ts diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts index c541e031617d..0cb5b62c835b 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -238,4 +238,37 @@ describe("sessions_spawn depth + child limits", () => { runId: "run-depth", }); }); + + it("fails spawn when sessions.patch rejects the model", async () => { + setSubagentLimits({ maxSpawnDepth: 2 }); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const req = opts as { method?: string; params?: { model?: string } }; + if (req.method === "sessions.patch" && req.params?.model === "bad-model") { + throw new Error("invalid model: bad-model"); + } + if (req.method === "agent") { + return { runId: "run-depth" }; + } + if (req.method === "agent.wait") { + return { status: "running" }; + } + return {}; + }); + + const tool = createSessionsSpawnTool({ agentSessionKey: "main" }); + const result = await tool.execute("call-model-reject", { + task: "hello", + model: "bad-model", + }); + + expect(result.details).toMatchObject({ + status: "error", + }); + expect(String((result.details as { error?: string }).error ?? "")).toContain("invalid model"); + expect( + callGatewayMock.mock.calls.some( + (call) => (call[0] as { method?: string }).method === "agent", + ), + ).toBe(false); + }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index 3c7e8fad6a97..61eb50c63360 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -8,6 +8,7 @@ import { setSessionsSpawnConfigOverride, } from "./openclaw-tools.subagents.sessions-spawn.test-harness.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; +import { SUBAGENT_SPAWN_ACCEPTED_NOTE } from "./subagent-spawn.js"; const callGatewayMock = getCallGatewayMock(); type GatewayCall = { method?: string; params?: unknown }; @@ -83,7 +84,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { }); expect(result.details).toMatchObject({ status: "accepted", - note: "auto-announces on completion, do not poll/sleep. The response will be sent back as an agent message.", + note: SUBAGENT_SPAWN_ACCEPTED_NOTE, modelApplied: true, }); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 48457a17a3d6..24615ca35878 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -49,7 +49,6 @@ export type SpawnSubagentResult = { runId?: string; note?: string; modelApplied?: boolean; - warning?: string; error?: string; }; diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index fdc043c4eb41..25cc41bdffc3 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -707,7 +707,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo return { shouldContinue: false, reply: { - text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).${result.warning ? ` Warning: ${result.warning}` : ""}`, + text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`, }, }; } diff --git a/src/commands/sessions.e2e.test.ts b/src/commands/sessions.e2e.test.ts index 2c1c6edda851..61f89889022f 100644 --- a/src/commands/sessions.e2e.test.ts +++ b/src/commands/sessions.e2e.test.ts @@ -145,54 +145,4 @@ describe("sessionsCommand", () => { expect(group?.totalTokens).toBeNull(); expect(group?.totalTokensFresh).toBe(false); }); - - it("prefers runtime model fields for subagent sessions in JSON output", async () => { - const store = writeStore({ - "agent:research:subagent:demo": { - sessionId: "subagent-1", - updatedAt: Date.now() - 2 * 60_000, - modelProvider: "openai-codex", - model: "gpt-5.3-codex", - modelOverride: "pi:opus", - }, - }); - - const { runtime, logs } = makeRuntime(); - await sessionsCommand({ store, json: true }, runtime); - - fs.rmSync(store); - - const payload = JSON.parse(logs[0] ?? "{}") as { - sessions?: Array<{ - key: string; - model?: string | null; - }>; - }; - const subagent = payload.sessions?.find((row) => row.key === "agent:research:subagent:demo"); - expect(subagent?.model).toBe("gpt-5.3-codex"); - }); - - it("falls back to modelOverride when runtime model is missing", async () => { - const store = writeStore({ - "agent:research:subagent:demo": { - sessionId: "subagent-2", - updatedAt: Date.now() - 2 * 60_000, - modelOverride: "openai-codex/gpt-5.3-codex", - }, - }); - - const { runtime, logs } = makeRuntime(); - await sessionsCommand({ store, json: true }, runtime); - - fs.rmSync(store); - - const payload = JSON.parse(logs[0] ?? "{}") as { - sessions?: Array<{ - key: string; - model?: string | null; - }>; - }; - const subagent = payload.sessions?.find((row) => row.key === "agent:research:subagent:demo"); - expect(subagent?.model).toBe("gpt-5.3-codex"); - }); }); diff --git a/src/commands/sessions.model-resolution.test.ts b/src/commands/sessions.model-resolution.test.ts new file mode 100644 index 000000000000..41bb35468817 --- /dev/null +++ b/src/commands/sessions.model-resolution.test.ts @@ -0,0 +1,108 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + model: { primary: "pi:opus" }, + models: { "pi:opus": {} }, + contextTokens: 32000, + }, + }, + }), + }; +}); + +import { sessionsCommand } from "./sessions.js"; + +const makeRuntime = () => { + const logs: string[] = []; + return { + runtime: { + log: (msg: unknown) => logs.push(String(msg)), + error: (msg: unknown) => { + throw new Error(String(msg)); + }, + exit: (code: number) => { + throw new Error(`exit ${code}`); + }, + }, + logs, + } as const; +}; + +const writeStore = (data: unknown) => { + const file = path.join( + os.tmpdir(), + `sessions-model-${Date.now()}-${Math.random().toString(16).slice(2)}.json`, + ); + fs.writeFileSync(file, JSON.stringify(data, null, 2)); + return file; +}; + +describe("sessionsCommand model resolution", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-12-06T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("prefers runtime model fields for subagent sessions in JSON output", async () => { + const store = writeStore({ + "agent:research:subagent:demo": { + sessionId: "subagent-1", + updatedAt: Date.now() - 2 * 60_000, + modelProvider: "openai-codex", + model: "gpt-5.3-codex", + modelOverride: "pi:opus", + }, + }); + + const { runtime, logs } = makeRuntime(); + await sessionsCommand({ store, json: true }, runtime); + + fs.rmSync(store); + + const payload = JSON.parse(logs[0] ?? "{}") as { + sessions?: Array<{ + key: string; + model?: string | null; + }>; + }; + const subagent = payload.sessions?.find((row) => row.key === "agent:research:subagent:demo"); + expect(subagent?.model).toBe("gpt-5.3-codex"); + }); + + it("falls back to modelOverride when runtime model is missing", async () => { + const store = writeStore({ + "agent:research:subagent:demo": { + sessionId: "subagent-2", + updatedAt: Date.now() - 2 * 60_000, + modelOverride: "openai-codex/gpt-5.3-codex", + }, + }); + + const { runtime, logs } = makeRuntime(); + await sessionsCommand({ store, json: true }, runtime); + + fs.rmSync(store); + + const payload = JSON.parse(logs[0] ?? "{}") as { + sessions?: Array<{ + key: string; + model?: string | null; + }>; + }; + const subagent = payload.sessions?.find((row) => row.key === "agent:research:subagent:demo"); + expect(subagent?.model).toBe("gpt-5.3-codex"); + }); +});