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 deleted file mode 100644 index 61c52a6df78..00000000000 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPerSenderSessionConfig } from "./test-helpers/session-config.js"; - -const callGatewayMock = vi.fn(); - -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - -vi.mock("../tasks/task-executor.js", () => ({ - completeTaskRunByRunId: vi.fn(), - createQueuedTaskRun: vi.fn(), - createRunningTaskRun: vi.fn(), - failTaskRunByRunId: vi.fn(), - recordTaskRunProgressByRunId: vi.fn(), - setDetachedTaskDeliveryStatusByRunId: vi.fn(), - startTaskRunByRunId: vi.fn(), -})); - -let storeTemplatePath = ""; -let configOverride: Record = { - session: createPerSenderSessionConfig(), -}; -let addSubagentRunForTests: typeof import("./subagent-registry.js").addSubagentRunForTests; -let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; -let subagentRegistryTesting: typeof import("./subagent-registry.js").__testing; -let setSubagentSpawnDepsForTest: typeof import("./subagent-spawn.js").__testing.setDepsForTest; -let createSessionsSpawnTool: typeof import("./tools/sessions-spawn-tool.js").createSessionsSpawnTool; - -vi.mock("../config/config.js", () => ({ - loadConfig: () => configOverride, -})); - -function writeStore(agentId: string, store: Record) { - const storePath = storeTemplatePath.replaceAll("{agentId}", agentId); - fs.mkdirSync(path.dirname(storePath), { recursive: true }); - fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8"); -} - -function setSubagentLimits(subagents: Record) { - configOverride = { - session: createPerSenderSessionConfig({ store: storeTemplatePath }), - agents: { - defaults: { - subagents, - }, - }, - }; -} - -function seedDepthTwoAncestryStore(params?: { sessionIds?: boolean }) { - const depth1 = "agent:main:subagent:depth-1"; - const callerKey = "agent:main:subagent:depth-2"; - writeStore("main", { - [depth1]: { - sessionId: params?.sessionIds ? "depth-1-session" : "depth-1", - updatedAt: Date.now(), - spawnedBy: "agent:main:main", - }, - [callerKey]: { - sessionId: params?.sessionIds ? "depth-2-session" : "depth-2", - updatedAt: Date.now(), - spawnedBy: depth1, - }, - }); - return { depth1, callerKey }; -} - -beforeAll(async () => { - ({ - __testing: subagentRegistryTesting, - addSubagentRunForTests, - resetSubagentRegistryForTests, - } = await import("./subagent-registry.js")); - ({ - __testing: { setDepsForTest: setSubagentSpawnDepsForTest }, - } = await import("./subagent-spawn.js")); - ({ createSessionsSpawnTool } = await import("./tools/sessions-spawn-tool.js")); -}); - -describe("sessions_spawn depth + child limits", () => { - beforeEach(() => { - setSubagentSpawnDepsForTest({ - callGateway: (opts) => callGatewayMock(opts), - getGlobalHookRunner: () => null, - }); - subagentRegistryTesting.setDepsForTest({ - captureSubagentCompletionReply: () => Promise.resolve(undefined), - cleanupBrowserSessionsForLifecycleEnd: () => Promise.resolve(), - ensureRuntimePluginsLoaded: () => {}, - onAgentEvent: () => () => {}, - persistSubagentRunsToDisk: () => {}, - resolveAgentTimeoutMs: () => 1, - runSubagentAnnounceFlow: () => Promise.resolve(true), - }); - resetSubagentRegistryForTests({ persist: false }); - callGatewayMock.mockClear(); - storeTemplatePath = path.join( - os.tmpdir(), - `openclaw-subagent-depth-${Date.now()}-${Math.random().toString(16).slice(2)}-{agentId}.json`, - ); - configOverride = { - session: createPerSenderSessionConfig({ store: storeTemplatePath }), - }; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const req = opts as { method?: string }; - if (req.method === "agent") { - return { runId: "run-depth" }; - } - if (req.method === "agent.wait") { - return { status: "pending" }; - } - return {}; - }); - }); - - afterEach(() => { - resetSubagentRegistryForTests({ persist: false }); - subagentRegistryTesting.setDepsForTest(); - }); - - afterAll(() => { - setSubagentSpawnDepsForTest(); - }); - - it("rejects spawning when caller depth reaches maxSpawnDepth", async () => { - const tool = createSessionsSpawnTool({ - agentSessionKey: "agent:main:subagent:parent", - workspaceDir: "/parent/workspace", - }); - const result = await tool.execute("call-depth-reject", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "forbidden", - error: "sessions_spawn is not allowed at this depth (current depth: 1, max: 1)", - }); - }); - - it("allows depth-1 callers when maxSpawnDepth is 2", async () => { - setSubagentLimits({ maxSpawnDepth: 2 }); - - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); - const result = await tool.execute("call-depth-allow", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "accepted", - childSessionKey: expect.stringMatching(/^agent:main:subagent:/), - runId: "run-depth", - }); - - const calls = callGatewayMock.mock.calls.map( - (call) => call[0] as { method?: string; params?: Record }, - ); - const spawnedByPatch = calls.find( - (entry) => - entry.method === "sessions.patch" && - entry.params?.spawnedBy === "agent:main:subagent:parent", - ); - expect(spawnedByPatch?.params?.key).toMatch(/^agent:main:subagent:/); - expect(typeof spawnedByPatch?.params?.spawnedWorkspaceDir).toBe("string"); - - const spawnDepthPatch = calls.find( - (entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2, - ); - expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/); - expect(spawnDepthPatch?.params?.subagentRole).toBe("leaf"); - expect(spawnDepthPatch?.params?.subagentControlScope).toBe("none"); - }); - - it("rejects depth-2 callers when maxSpawnDepth is 2 (using stored spawnDepth on flat keys)", async () => { - setSubagentLimits({ maxSpawnDepth: 2 }); - - const callerKey = "agent:main:subagent:flat-depth-2"; - writeStore("main", { - [callerKey]: { - sessionId: "flat-depth-2", - updatedAt: Date.now(), - spawnDepth: 2, - }, - }); - - const tool = createSessionsSpawnTool({ agentSessionKey: callerKey }); - const result = await tool.execute("call-depth-2-reject", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "forbidden", - error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", - }); - }); - - it("rejects depth-2 callers when spawnDepth is missing but spawnedBy ancestry implies depth 2", async () => { - setSubagentLimits({ maxSpawnDepth: 2 }); - const { callerKey } = seedDepthTwoAncestryStore(); - - const tool = createSessionsSpawnTool({ agentSessionKey: callerKey }); - const result = await tool.execute("call-depth-ancestry-reject", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "forbidden", - error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", - }); - }); - - it("rejects depth-2 callers when the requester key is a sessionId", async () => { - setSubagentLimits({ maxSpawnDepth: 2 }); - seedDepthTwoAncestryStore({ sessionIds: true }); - - const tool = createSessionsSpawnTool({ agentSessionKey: "depth-2-session" }); - const result = await tool.execute("call-depth-sessionid-reject", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "forbidden", - error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", - }); - }); - - it("rejects when active children for requester session reached maxChildrenPerAgent", async () => { - configOverride = { - session: createPerSenderSessionConfig({ store: storeTemplatePath }), - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - maxChildrenPerAgent: 1, - }, - }, - }, - }; - - addSubagentRunForTests({ - runId: "existing-run", - childSessionKey: "agent:main:subagent:existing", - requesterSessionKey: "agent:main:subagent:parent", - requesterDisplayKey: "agent:main:subagent:parent", - task: "existing", - cleanup: "keep", - createdAt: Date.now(), - startedAt: Date.now(), - }); - - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); - const result = await tool.execute("call-max-children", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "forbidden", - error: "sessions_spawn has reached max active children for this session (1/1)", - }); - }); - - it("does not double-count restarted child sessions toward maxChildrenPerAgent", async () => { - configOverride = { - session: createPerSenderSessionConfig({ store: storeTemplatePath }), - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - maxChildrenPerAgent: 2, - }, - }, - }, - }; - - const childSessionKey = "agent:main:subagent:restarted-child"; - addSubagentRunForTests({ - runId: "existing-old-run", - childSessionKey, - requesterSessionKey: "agent:main:subagent:parent", - requesterDisplayKey: "agent:main:subagent:parent", - task: "old orchestration run", - cleanup: "keep", - createdAt: Date.now() - 30_000, - startedAt: Date.now() - 30_000, - endedAt: Date.now() - 20_000, - cleanupCompletedAt: undefined, - }); - addSubagentRunForTests({ - runId: "existing-current-run", - childSessionKey, - requesterSessionKey: "agent:main:subagent:parent", - requesterDisplayKey: "agent:main:subagent:parent", - task: "current orchestration run", - cleanup: "keep", - createdAt: Date.now() - 10_000, - startedAt: Date.now() - 10_000, - }); - addSubagentRunForTests({ - runId: "existing-descendant-run", - childSessionKey: `${childSessionKey}:subagent:leaf`, - requesterSessionKey: childSessionKey, - requesterDisplayKey: childSessionKey, - task: "descendant still running", - cleanup: "keep", - createdAt: Date.now() - 5_000, - startedAt: Date.now() - 5_000, - }); - - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); - const result = await tool.execute("call-max-children-dedupe", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "accepted", - runId: "run-depth", - }); - }); - - it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => { - configOverride = { - session: createPerSenderSessionConfig({ store: storeTemplatePath }), - agents: { - defaults: { - subagents: { - maxSpawnDepth: 2, - maxChildrenPerAgent: 5, - maxConcurrent: 1, - }, - }, - }, - }; - - const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" }); - const result = await tool.execute("call-max-concurrent-independent", { task: "hello" }); - - expect(result.details).toMatchObject({ - status: "accepted", - 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: "pending" }; - } - 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((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/subagent-spawn.depth-limits.test.ts b/src/agents/subagent-spawn.depth-limits.test.ts new file mode 100644 index 00000000000..cefc3011991 --- /dev/null +++ b/src/agents/subagent-spawn.depth-limits.test.ts @@ -0,0 +1,180 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createSubagentSpawnTestConfig, + loadSubagentSpawnModuleForTest, + setupAcceptedSubagentGatewayMock, +} from "./subagent-spawn.test-helpers.js"; + +const hoisted = vi.hoisted(() => ({ + activeChildrenBySession: new Map(), + callGatewayMock: vi.fn(), + configOverride: {} as Record, + depthBySession: new Map(), + registerSubagentRunMock: vi.fn(), +})); + +let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect; + +function createDepthLimitConfig(subagents?: Record) { + return createSubagentSpawnTestConfig("/tmp/workspace-main", { + agents: { + defaults: { + workspace: "/tmp/workspace-main", + subagents: { + maxSpawnDepth: 1, + ...subagents, + }, + }, + }, + }); +} + +async function spawnFrom(sessionKey: string, params?: Record) { + return await spawnSubagentDirect( + { + task: "hello", + ...params, + }, + { + agentSessionKey: sessionKey, + workspaceDir: "/tmp/workspace-main", + }, + ); +} + +describe("subagent spawn depth + child limits", () => { + beforeAll(async () => { + ({ spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ + callGatewayMock: hoisted.callGatewayMock, + loadConfig: () => hoisted.configOverride, + registerSubagentRunMock: hoisted.registerSubagentRunMock, + getSubagentDepthFromSessionStore: (sessionKey) => hoisted.depthBySession.get(sessionKey) ?? 0, + countActiveRunsForSession: (sessionKey) => + hoisted.activeChildrenBySession.get(sessionKey) ?? 0, + resetModules: false, + })); + }); + + beforeEach(() => { + hoisted.activeChildrenBySession.clear(); + hoisted.depthBySession.clear(); + hoisted.callGatewayMock.mockClear(); + hoisted.registerSubagentRunMock.mockClear(); + hoisted.configOverride = createDepthLimitConfig(); + setupAcceptedSubagentGatewayMock(hoisted.callGatewayMock); + }); + + it("rejects spawning when caller depth reaches maxSpawnDepth", async () => { + hoisted.depthBySession.set("agent:main:subagent:parent", 1); + + const result = await spawnFrom("agent:main:subagent:parent"); + + expect(result).toMatchObject({ + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 1, max: 1)", + }); + }); + + it("allows depth-1 callers when maxSpawnDepth is 2 and patches child capabilities", async () => { + hoisted.configOverride = createDepthLimitConfig({ maxSpawnDepth: 2 }); + hoisted.depthBySession.set("agent:main:subagent:parent", 1); + + const result = await spawnFrom("agent:main:subagent:parent"); + + expect(result).toMatchObject({ + status: "accepted", + childSessionKey: expect.stringMatching(/^agent:main:subagent:/), + runId: "run-1", + }); + + const calls = hoisted.callGatewayMock.mock.calls.map( + (call) => call[0] as { method?: string; params?: Record }, + ); + const spawnedByPatch = calls.find( + (entry) => + entry.method === "sessions.patch" && + entry.params?.spawnedBy === "agent:main:subagent:parent", + ); + expect(spawnedByPatch?.params?.key).toMatch(/^agent:main:subagent:/); + expect(typeof spawnedByPatch?.params?.spawnedWorkspaceDir).toBe("string"); + + const spawnDepthPatch = calls.find( + (entry) => entry.method === "sessions.patch" && entry.params?.spawnDepth === 2, + ); + expect(spawnDepthPatch?.params?.key).toMatch(/^agent:main:subagent:/); + expect(spawnDepthPatch?.params?.subagentRole).toBe("leaf"); + expect(spawnDepthPatch?.params?.subagentControlScope).toBe("none"); + }); + + it("rejects callers when stored spawn depth is already at the configured max", async () => { + hoisted.configOverride = createDepthLimitConfig({ maxSpawnDepth: 2 }); + hoisted.depthBySession.set("agent:main:subagent:flat-depth-2", 2); + + const result = await spawnFrom("agent:main:subagent:flat-depth-2"); + + expect(result).toMatchObject({ + status: "forbidden", + error: "sessions_spawn is not allowed at this depth (current depth: 2, max: 2)", + }); + }); + + it("rejects when active children for requester session reached maxChildrenPerAgent", async () => { + hoisted.configOverride = createDepthLimitConfig({ + maxSpawnDepth: 2, + maxChildrenPerAgent: 1, + }); + hoisted.depthBySession.set("agent:main:subagent:parent", 1); + hoisted.activeChildrenBySession.set("agent:main:subagent:parent", 1); + + const result = await spawnFrom("agent:main:subagent:parent"); + + expect(result).toMatchObject({ + status: "forbidden", + error: "sessions_spawn has reached max active children for this session (1/1)", + }); + }); + + it("does not use subagent maxConcurrent as a per-parent spawn gate", async () => { + hoisted.configOverride = createDepthLimitConfig({ + maxSpawnDepth: 2, + maxChildrenPerAgent: 5, + maxConcurrent: 1, + }); + hoisted.depthBySession.set("agent:main:subagent:parent", 1); + hoisted.activeChildrenBySession.set("agent:main:subagent:parent", 1); + + const result = await spawnFrom("agent:main:subagent:parent"); + + expect(result).toMatchObject({ + status: "accepted", + runId: "run-1", + }); + }); + + it("fails spawn when sessions.patch rejects the model", async () => { + hoisted.configOverride = createDepthLimitConfig({ maxSpawnDepth: 2 }); + hoisted.callGatewayMock.mockImplementation( + async (opts: { method?: string; params?: { model?: string } }) => { + if (opts.method === "sessions.patch" && opts.params?.model === "bad-model") { + throw new Error("invalid model: bad-model"); + } + if (opts.method === "agent") { + return { runId: "run-depth" }; + } + return {}; + }, + ); + + const result = await spawnFrom("main", { model: "bad-model" }); + + expect(result).toMatchObject({ + status: "error", + }); + expect(result.error ?? "").toContain("invalid model"); + expect( + hoisted.callGatewayMock.mock.calls.some( + (call) => (call[0] as { method?: string }).method === "agent", + ), + ).toBe(false); + }); +}); diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 160c8c6a434..670fb4bfee2 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -121,6 +121,8 @@ export async function loadSubagentSpawnModuleForTest(params: { resolveAgentConfig?: (cfg: Record, agentId: string) => unknown; resolveAgentWorkspaceDir?: (cfg: Record, agentId: string) => string; resolveSubagentSpawnModelSelection?: () => string | undefined; + getSubagentDepthFromSessionStore?: (sessionKey: string, opts?: unknown) => number; + countActiveRunsForSession?: (sessionKey: string) => number; resolveSandboxRuntimeStatus?: (params: { cfg?: Record; sessionKey?: string; @@ -220,11 +222,11 @@ export async function loadSubagentSpawnModuleForTest(params: { })); vi.doMock("./subagent-depth.js", () => ({ - getSubagentDepthFromSessionStore: () => 0, + getSubagentDepthFromSessionStore: params.getSubagentDepthFromSessionStore ?? (() => 0), })); vi.doMock("./subagent-registry.js", () => ({ - countActiveRunsForSession: () => 0, + countActiveRunsForSession: params.countActiveRunsForSession ?? (() => 0), registerSubagentRun: params.registerSubagentRunMock ?? vi.fn((_record: Record) => undefined), resetSubagentRegistryForTests,