From 870b1d50dff2302d99e9fad239884587a3de09e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Feb 2026 03:20:53 +0000 Subject: [PATCH] perf(test): consolidate sessions_spawn e2e tests --- ...es-agent-wait-lifecycle-events.e2e.test.ts | 242 ------------ ...efers-per-agent-subagent-model.e2e.test.ts | 192 ---------- ...lves-main-announce-target-from.e2e.test.ts | 215 ----------- ...ents.sessions-spawn.allowlist.e2e.test.ts} | 117 +++++- ...ents.sessions-spawn.lifecycle.e2e.test.ts} | 356 ++++++++++++++---- ...ubagents.sessions-spawn.model.e2e.test.ts} | 136 ++++++- 6 files changed, 541 insertions(+), 717 deletions(-) delete mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.e2e.test.ts delete mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.e2e.test.ts delete mode 100644 src/agents/openclaw-tools.subagents.sessions-spawn-resolves-main-announce-target-from.e2e.test.ts rename src/agents/{openclaw-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.e2e.test.ts => openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts} (60%) rename src/agents/{openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts => openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts} (50%) rename src/agents/{openclaw-tools.subagents.sessions-spawn-applies-model-child-session.e2e.test.ts => openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts} (62%) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.e2e.test.ts deleted file mode 100644 index 14684bfb7da..00000000000 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.e2e.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { sleep } from "../utils.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; -import "./test-helpers/fast-core-tools.js"; -import { resetSubagentRegistryForTests } from "./subagent-registry.js"; - -type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; - -const hoisted = vi.hoisted(() => { - const callGatewayMock = vi.fn(); - const defaultConfigOverride = { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as SessionsSpawnTestConfig; - const state = { configOverride: defaultConfigOverride }; - return { callGatewayMock, defaultConfigOverride, state }; -}); - -const callGatewayMock = hoisted.callGatewayMock; - -function resetConfigOverride() { - hoisted.state.configOverride = hoisted.defaultConfigOverride; -} - -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); -// Some tools import callGateway via "../../gateway/call.js" (from nested folders). Mock that too. -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -// Same module, different specifier (used by tools under src/agents/tools/*). -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -describe("openclaw-tools: subagents", () => { - beforeEach(() => { - resetConfigOverride(); - }); - - it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let deletedKey: string | undefined; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - channel?: string; - timeout?: number; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - expect(params?.channel).toBe("discord"); - expect(params?.timeout).toBe(1); - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 3000, - endedAt: 4000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - const params = request.params as { key?: string } | undefined; - deletedKey = params?.key; - return { ok: true }; - } - return {}; - }); - - const tool = createOpenClawTools({ - agentSessionKey: "discord:group:req", - agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call1b", { - task: "do thing", - runTimeoutSeconds: 1, - cleanup: "delete", - }); - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - - await sleep(0); - await sleep(0); - await sleep(0); - - const childWait = waitCalls.find((call) => call.runId === childRunId); - expect(childWait?.timeoutMs).toBe(1000); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); - - // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((call) => call.method === "agent"); - expect(agentCalls).toHaveLength(2); - - // First call: subagent spawn - const first = agentCalls[0]?.params as { lane?: string } | undefined; - expect(first?.lane).toBe("subagent"); - - // Second call: main agent trigger - const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; - expect(second?.sessionKey).toBe("discord:group:req"); - expect(second?.deliver).toBe(true); - - // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); - expect(sendCalls.length).toBe(0); - - // Session should be deleted - expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); - }); - - it("sessions_spawn reports timed out when agent.wait returns timeout", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - return { - runId: `run-${agentCallCount}`, - status: "accepted", - acceptedAt: 5000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string } | undefined; - return { - runId: params?.runId ?? "run-1", - status: "timeout", - startedAt: 6000, - endedAt: 7000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "still working" }], - }, - ], - }; - } - return {}; - }); - - const tool = createOpenClawTools({ - agentSessionKey: "discord:group:req", - agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call-timeout", { - task: "do thing", - runTimeoutSeconds: 1, - cleanup: "keep", - }); - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - - await sleep(0); - await sleep(0); - await sleep(0); - - const mainAgentCall = calls - .filter((call) => call.method === "agent") - .find((call) => { - const params = call.params as { lane?: string } | undefined; - return params?.lane !== "subagent"; - }); - const mainMessage = (mainAgentCall?.params as { message?: string } | undefined)?.message ?? ""; - - expect(mainMessage).toContain("timed out"); - expect(mainMessage).not.toContain("completed successfully"); - }); -}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.e2e.test.ts deleted file mode 100644 index c93fbc8a356..00000000000 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.e2e.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createOpenClawTools } from "./openclaw-tools.js"; -import "./test-helpers/fast-core-tools.js"; -import { resetSubagentRegistryForTests } from "./subagent-registry.js"; - -type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; - -const hoisted = vi.hoisted(() => { - const callGatewayMock = vi.fn(); - const defaultConfigOverride = { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as SessionsSpawnTestConfig; - const state = { configOverride: defaultConfigOverride }; - return { callGatewayMock, defaultConfigOverride, state }; -}); - -const callGatewayMock = hoisted.callGatewayMock; - -function resetConfigOverride() { - hoisted.state.configOverride = hoisted.defaultConfigOverride; -} - -function setConfigOverride(next: SessionsSpawnTestConfig) { - hoisted.state.configOverride = next; -} - -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); -// Some tools import callGateway via "../../gateway/call.js" (from nested folders). Mock that too. -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -// Same module, different specifier (used by tools under src/agents/tools/*). -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -describe("openclaw-tools: subagents", () => { - beforeEach(() => { - resetConfigOverride(); - }); - - it("sessions_spawn prefers per-agent subagent model over defaults", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - setConfigOverride({ - session: { mainKey: "main", scope: "per-sender" }, - agents: { - defaults: { subagents: { model: "minimax/MiniMax-M2.1" } }, - list: [{ id: "research", subagents: { model: "opencode/claude" } }], - }, - }); - const calls: Array<{ method?: string; params?: unknown }> = []; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { - return { ok: true }; - } - if (request.method === "agent") { - return { runId: "run-agent-model", status: "accepted" }; - } - return {}; - }); - - const tool = createOpenClawTools({ - agentSessionKey: "agent:research:main", - agentChannel: "discord", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call-agent-model", { - task: "do thing", - }); - expect(result.details).toMatchObject({ - status: "accepted", - modelApplied: true, - }); - - const patchCall = calls.find((call) => call.method === "sessions.patch"); - expect(patchCall?.params).toMatchObject({ - model: "opencode/claude", - }); - }); - it("sessions_spawn skips invalid model overrides and continues", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.patch") { - throw new Error("invalid model: bad-model"); - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - return { - runId, - status: "accepted", - acceptedAt: 4000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - return { status: "timeout" }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; - }); - - const tool = createOpenClawTools({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call4", { - task: "do thing", - runTimeoutSeconds: 1, - model: "bad-model", - }); - expect(result.details).toMatchObject({ - status: "accepted", - modelApplied: false, - }); - expect(String((result.details as { warning?: string }).warning ?? "")).toContain( - "invalid model", - ); - expect(calls.some((call) => call.method === "agent")).toBe(true); - }); - it("sessions_spawn supports legacy timeoutSeconds alias", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - let spawnedTimeout: number | undefined; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - if (request.method === "agent") { - const params = request.params as { timeout?: number } | undefined; - spawnedTimeout = params?.timeout; - return { runId: "run-1", status: "accepted", acceptedAt: 1000 }; - } - return {}; - }); - - const tool = createOpenClawTools({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call5", { - task: "do thing", - timeoutSeconds: 2, - }); - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - expect(spawnedTimeout).toBe(2); - }); -}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-resolves-main-announce-target-from.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-resolves-main-announce-target-from.e2e.test.ts deleted file mode 100644 index b70d9bbdf35..00000000000 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-resolves-main-announce-target-from.e2e.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { emitAgentEvent } from "../infra/agent-events.js"; -import { sleep } from "../utils.ts"; -import { createOpenClawTools } from "./openclaw-tools.js"; -import "./test-helpers/fast-core-tools.js"; -import { resetSubagentRegistryForTests } from "./subagent-registry.js"; - -type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; - -const hoisted = vi.hoisted(() => { - const callGatewayMock = vi.fn(); - const defaultConfigOverride = { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as SessionsSpawnTestConfig; - const state = { configOverride: defaultConfigOverride }; - return { callGatewayMock, defaultConfigOverride, state }; -}); - -const callGatewayMock = hoisted.callGatewayMock; - -function resetConfigOverride() { - hoisted.state.configOverride = hoisted.defaultConfigOverride; -} - -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); -// Some tools import callGateway via "../../gateway/call.js" (from nested folders). Mock that too. -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -// Same module, different specifier (used by tools under src/agents/tools/*). -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.configOverride, - resolveGatewayPort: () => 18789, - }; -}); - -describe("openclaw-tools: subagents", () => { - beforeEach(() => { - resetConfigOverride(); - }); - - it("sessions_spawn runs cleanup flow after subagent completion", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let childRunId: string | undefined; - let childSessionKey: string | undefined; - const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; - let patchParams: { key?: string; label?: string } = {}; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "sessions.list") { - return { - sessions: [ - { - key: "main", - lastChannel: "whatsapp", - lastTo: "+123", - }, - ], - }; - } - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { - message?: string; - sessionKey?: string; - lane?: string; - }; - // Only capture the first agent call (subagent spawn, not main agent trigger) - if (params?.lane === "subagent") { - childRunId = runId; - childSessionKey = params?.sessionKey ?? ""; - } - return { - runId, - status: "accepted", - acceptedAt: 2000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - waitCalls.push(params ?? {}); - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.patch") { - const params = request.params as { key?: string; label?: string } | undefined; - patchParams = { key: params?.key, label: params?.label }; - return { ok: true }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "done" }], - }, - ], - }; - } - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; - }); - - const tool = createOpenClawTools({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call2", { - task: "do thing", - runTimeoutSeconds: 1, - label: "my-task", - }); - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-1", - }); - - if (!childRunId) { - throw new Error("missing child runId"); - } - emitAgentEvent({ - runId: childRunId, - stream: "lifecycle", - data: { - phase: "end", - startedAt: 1000, - endedAt: 2000, - }, - }); - - await sleep(0); - await sleep(0); - await sleep(0); - - const childWait = waitCalls.find((call) => call.runId === childRunId); - expect(childWait?.timeoutMs).toBe(1000); - // Cleanup should patch the label - expect(patchParams.key).toBe(childSessionKey); - expect(patchParams.label).toBe("my-task"); - - // Two agent calls: subagent spawn + main agent trigger - const agentCalls = calls.filter((c) => c.method === "agent"); - expect(agentCalls).toHaveLength(2); - - // First call: subagent spawn - const first = agentCalls[0]?.params as { lane?: string } | undefined; - expect(first?.lane).toBe("subagent"); - - // Second call: main agent trigger (not "Sub-agent announce step." anymore) - const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined; - expect(second?.sessionKey).toBe("main"); - expect(second?.message).toContain("subagent task"); - - // No direct send to external channel (main agent handles delivery) - const sendCalls = calls.filter((c) => c.method === "send"); - expect(sendCalls.length).toBe(0); - expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); - }); - - it("sessions_spawn only allows same-agent by default", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - - const tool = createOpenClawTools({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); - } - - const result = await tool.execute("call6", { - task: "do thing", - agentId: "beta", - }); - expect(result.details).toMatchObject({ - status: "forbidden", - }); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts similarity index 60% rename from src/agents/openclaw-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.e2e.test.ts rename to src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts index b912aa326bc..65ca301754a 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.e2e.test.ts @@ -54,11 +54,71 @@ vi.mock("../../config/config.js", async (importOriginal) => { }; }); -describe("openclaw-tools: subagents", () => { +describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => { beforeEach(() => { resetConfigOverride(); }); + it("sessions_spawn only allows same-agent by default", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + + const tool = createOpenClawTools({ + agentSessionKey: "main", + agentChannel: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call6", { + task: "do thing", + agentId: "beta", + }); + expect(result.details).toMatchObject({ + status: "forbidden", + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("sessions_spawn forbids cross-agent spawning when not allowed", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + setConfigOverride({ + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + subagents: { + allowAgents: ["alpha"], + }, + }, + ], + }, + }); + + const tool = createOpenClawTools({ + agentSessionKey: "main", + agentChannel: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call9", { + task: "do thing", + agentId: "beta", + }); + expect(result.details).toMatchObject({ + status: "forbidden", + }); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("sessions_spawn allows cross-agent spawning when configured", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); @@ -112,6 +172,7 @@ describe("openclaw-tools: subagents", () => { }); expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true); }); + it("sessions_spawn allows any agent when allowlist is *", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); @@ -165,4 +226,58 @@ describe("openclaw-tools: subagents", () => { }); expect(childSessionKey?.startsWith("agent:beta:subagent:")).toBe(true); }); + + it("sessions_spawn normalizes allowlisted agent ids", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + setConfigOverride({ + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + subagents: { + allowAgents: ["Research"], + }, + }, + ], + }, + }); + + let childSessionKey: string | undefined; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + if (request.method === "agent") { + const params = request.params as { sessionKey?: string } | undefined; + childSessionKey = params?.sessionKey; + return { runId: "run-1", status: "accepted", acceptedAt: 5200 }; + } + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "main", + agentChannel: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call10", { + task: "do thing", + agentId: "research", + }); + + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true); + }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts similarity index 50% rename from src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts rename to src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index 3988046206e..94681497302 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; +import { sleep } from "../utils.js"; import { createOpenClawTools } from "./openclaw-tools.js"; import "./test-helpers/fast-core-tools.js"; import { resetSubagentRegistryForTests } from "./subagent-registry.js"; @@ -24,10 +25,6 @@ function resetConfigOverride() { hoisted.state.configOverride = hoisted.defaultConfigOverride; } -function setConfigOverride(next: SessionsSpawnTestConfig) { - hoisted.state.configOverride = next; -} - vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); @@ -55,41 +52,81 @@ vi.mock("../../config/config.js", async (importOriginal) => { }; }); -describe("openclaw-tools: subagents", () => { +describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetConfigOverride(); }); - it("sessions_spawn normalizes allowlisted agent ids", async () => { + it("sessions_spawn runs cleanup flow after subagent completion", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setConfigOverride({ - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - list: [ - { - id: "main", - subagents: { - allowAgents: ["Research"], - }, - }, - ], - }, - }); - + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + let childRunId: string | undefined; let childSessionKey: string | undefined; + const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; + let patchParams: { key?: string; label?: string } = {}; + callGatewayMock.mockImplementation(async (opts: unknown) => { const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "sessions.list") { + return { + sessions: [ + { + key: "main", + lastChannel: "whatsapp", + lastTo: "+123", + }, + ], + }; + } if (request.method === "agent") { - const params = request.params as { sessionKey?: string } | undefined; - childSessionKey = params?.sessionKey; - return { runId: "run-1", status: "accepted", acceptedAt: 5200 }; + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { + message?: string; + sessionKey?: string; + lane?: string; + }; + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params?.sessionKey ?? ""; + } + return { + runId, + status: "accepted", + acceptedAt: 2000 + agentCallCount, + }; } if (request.method === "agent.wait") { - return { status: "timeout" }; + const params = request.params as { runId?: string; timeoutMs?: number } | undefined; + waitCalls.push(params ?? {}); + return { + runId: params?.runId ?? "run-1", + status: "ok", + startedAt: 1000, + endedAt: 2000, + }; + } + if (request.method === "sessions.patch") { + const params = request.params as { key?: string; label?: string } | undefined; + patchParams = { key: params?.key, label: params?.label }; + return { ok: true }; + } + if (request.method === "chat.history") { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + if (request.method === "sessions.delete") { + return { ok: true }; } return {}; }); @@ -102,53 +139,56 @@ describe("openclaw-tools: subagents", () => { throw new Error("missing sessions_spawn tool"); } - const result = await tool.execute("call10", { + const result = await tool.execute("call2", { task: "do thing", - agentId: "research", + runTimeoutSeconds: 1, + label: "my-task", }); - expect(result.details).toMatchObject({ status: "accepted", runId: "run-1", }); - expect(childSessionKey?.startsWith("agent:research:subagent:")).toBe(true); - }); - it("sessions_spawn forbids cross-agent spawning when not allowed", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - setConfigOverride({ - session: { - mainKey: "main", - scope: "per-sender", - }, - agents: { - list: [ - { - id: "main", - subagents: { - allowAgents: ["alpha"], - }, - }, - ], - }, - }); - const tool = createOpenClawTools({ - agentSessionKey: "main", - agentChannel: "whatsapp", - }).find((candidate) => candidate.name === "sessions_spawn"); - if (!tool) { - throw new Error("missing sessions_spawn tool"); + if (!childRunId) { + throw new Error("missing child runId"); } + emitAgentEvent({ + runId: childRunId, + stream: "lifecycle", + data: { + phase: "end", + startedAt: 1000, + endedAt: 2000, + }, + }); - const result = await tool.execute("call9", { - task: "do thing", - agentId: "beta", - }); - expect(result.details).toMatchObject({ - status: "forbidden", - }); - expect(callGatewayMock).not.toHaveBeenCalled(); + await sleep(0); + await sleep(0); + await sleep(0); + + const childWait = waitCalls.find((call) => call.runId === childRunId); + expect(childWait?.timeoutMs).toBe(1000); + // Cleanup should patch the label + expect(patchParams.key).toBe(childSessionKey); + expect(patchParams.label).toBe("my-task"); + + // Two agent calls: subagent spawn + main agent trigger + const agentCalls = calls.filter((c) => c.method === "agent"); + expect(agentCalls).toHaveLength(2); + + // First call: subagent spawn + const first = agentCalls[0]?.params as { lane?: string } | undefined; + expect(first?.lane).toBe("subagent"); + + // Second call: main agent trigger (not "Sub-agent announce step." anymore) + const second = agentCalls[1]?.params as { sessionKey?: string; message?: string } | undefined; + expect(second?.sessionKey).toBe("main"); + expect(second?.message).toContain("subagent task"); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); + expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); }); it("sessions_spawn runs cleanup via lifecycle events", async () => { @@ -279,6 +319,190 @@ describe("openclaw-tools: subagents", () => { expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); + it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + let deletedKey: string | undefined; + let childRunId: string | undefined; + let childSessionKey: string | undefined; + const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = []; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + const params = request.params as { + message?: string; + sessionKey?: string; + channel?: string; + timeout?: number; + lane?: string; + }; + // Only capture the first agent call (subagent spawn, not main agent trigger) + if (params?.lane === "subagent") { + childRunId = runId; + childSessionKey = params?.sessionKey ?? ""; + expect(params?.channel).toBe("discord"); + expect(params?.timeout).toBe(1); + } + return { + runId, + status: "accepted", + acceptedAt: 2000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + const params = request.params as { runId?: string; timeoutMs?: number } | undefined; + waitCalls.push(params ?? {}); + return { + runId: params?.runId ?? "run-1", + status: "ok", + startedAt: 3000, + endedAt: 4000, + }; + } + if (request.method === "chat.history") { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + }, + ], + }; + } + if (request.method === "sessions.delete") { + const params = request.params as { key?: string } | undefined; + deletedKey = params?.key; + return { ok: true }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "discord:group:req", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call1b", { + task: "do thing", + runTimeoutSeconds: 1, + cleanup: "delete", + }); + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + + await sleep(0); + await sleep(0); + await sleep(0); + + const childWait = waitCalls.find((call) => call.runId === childRunId); + expect(childWait?.timeoutMs).toBe(1000); + expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true); + + // Two agent calls: subagent spawn + main agent trigger + const agentCalls = calls.filter((call) => call.method === "agent"); + expect(agentCalls).toHaveLength(2); + + // First call: subagent spawn + const first = agentCalls[0]?.params as { lane?: string } | undefined; + expect(first?.lane).toBe("subagent"); + + // Second call: main agent trigger + const second = agentCalls[1]?.params as { sessionKey?: string; deliver?: boolean } | undefined; + expect(second?.sessionKey).toBe("discord:group:req"); + expect(second?.deliver).toBe(true); + + // No direct send to external channel (main agent handles delivery) + const sendCalls = calls.filter((c) => c.method === "send"); + expect(sendCalls.length).toBe(0); + + // Session should be deleted + expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); + }); + + it("sessions_spawn reports timed out when agent.wait returns timeout", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "agent") { + agentCallCount += 1; + return { + runId: `run-${agentCallCount}`, + status: "accepted", + acceptedAt: 5000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + const params = request.params as { runId?: string } | undefined; + return { + runId: params?.runId ?? "run-1", + status: "timeout", + startedAt: 6000, + endedAt: 7000, + }; + } + if (request.method === "chat.history") { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "still working" }], + }, + ], + }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "discord:group:req", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call-timeout", { + task: "do thing", + runTimeoutSeconds: 1, + cleanup: "keep", + }); + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + + await sleep(0); + await sleep(0); + await sleep(0); + + const mainAgentCall = calls + .filter((call) => call.method === "agent") + .find((call) => { + const params = call.params as { lane?: string } | undefined; + return params?.lane !== "subagent"; + }); + const mainMessage = (mainAgentCall?.params as { message?: string } | undefined)?.message ?? ""; + + expect(mainMessage).toContain("timed out"); + expect(mainMessage).not.toContain("completed successfully"); + }); + it("sessions_spawn announces with requester accountId", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); @@ -326,7 +550,7 @@ describe("openclaw-tools: subagents", () => { throw new Error("missing sessions_spawn tool"); } - const result = await tool.execute("call2", { + const result = await tool.execute("call-announce-account", { task: "do thing", runTimeoutSeconds: 1, cleanup: "keep", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-model-child-session.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts similarity index 62% rename from src/agents/openclaw-tools.subagents.sessions-spawn-applies-model-child-session.e2e.test.ts rename to src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index a2ba1fdabd8..cdb1e0c6950 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-applies-model-child-session.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -54,7 +54,7 @@ vi.mock("../../config/config.js", async (importOriginal) => { }; }); -describe("openclaw-tools: subagents", () => { +describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { beforeEach(() => { resetConfigOverride(); }); @@ -185,6 +185,7 @@ describe("openclaw-tools: subagents", () => { expect(String(result.details?.error)).toMatch(/Invalid thinking level/i); expect(calls).toHaveLength(0); }); + it("sessions_spawn applies default subagent model from defaults config", async () => { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); @@ -227,4 +228,137 @@ describe("openclaw-tools: subagents", () => { model: "minimax/MiniMax-M2.1", }); }); + + it("sessions_spawn prefers per-agent subagent model over defaults", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + setConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + agents: { + defaults: { subagents: { model: "minimax/MiniMax-M2.1" } }, + list: [{ id: "research", subagents: { model: "opencode/claude" } }], + }, + }); + const calls: Array<{ method?: string; params?: unknown }> = []; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "sessions.patch") { + return { ok: true }; + } + if (request.method === "agent") { + return { runId: "run-agent-model", status: "accepted" }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:research:main", + agentChannel: "discord", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call-agent-model", { + task: "do thing", + }); + expect(result.details).toMatchObject({ + status: "accepted", + modelApplied: true, + }); + + const patchCall = calls.find((call) => call.method === "sessions.patch"); + expect(patchCall?.params).toMatchObject({ + model: "opencode/claude", + }); + }); + + it("sessions_spawn skips invalid model overrides and continues", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + const calls: Array<{ method?: string; params?: unknown }> = []; + let agentCallCount = 0; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + calls.push(request); + if (request.method === "sessions.patch") { + throw new Error("invalid model: bad-model"); + } + if (request.method === "agent") { + agentCallCount += 1; + const runId = `run-${agentCallCount}`; + return { + runId, + status: "accepted", + acceptedAt: 4000 + agentCallCount, + }; + } + if (request.method === "agent.wait") { + return { status: "timeout" }; + } + if (request.method === "sessions.delete") { + return { ok: true }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "main", + agentChannel: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call4", { + task: "do thing", + runTimeoutSeconds: 1, + model: "bad-model", + }); + expect(result.details).toMatchObject({ + status: "accepted", + modelApplied: false, + }); + expect(String((result.details as { warning?: string }).warning ?? "")).toContain( + "invalid model", + ); + expect(calls.some((call) => call.method === "agent")).toBe(true); + }); + + it("sessions_spawn supports legacy timeoutSeconds alias", async () => { + resetSubagentRegistryForTests(); + callGatewayMock.mockReset(); + let spawnedTimeout: number | undefined; + + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string; params?: unknown }; + if (request.method === "agent") { + const params = request.params as { timeout?: number } | undefined; + spawnedTimeout = params?.timeout; + return { runId: "run-1", status: "accepted", acceptedAt: 1000 }; + } + return {}; + }); + + const tool = createOpenClawTools({ + agentSessionKey: "main", + agentChannel: "whatsapp", + }).find((candidate) => candidate.name === "sessions_spawn"); + if (!tool) { + throw new Error("missing sessions_spawn tool"); + } + + const result = await tool.execute("call5", { + task: "do thing", + timeoutSeconds: 2, + }); + expect(result.details).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + expect(spawnedTimeout).toBe(2); + }); });