Files
openclaw/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts
2026-03-11 01:44:38 +00:00

259 lines
8.6 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js";
import { createPerSenderSessionConfig } from "./test-helpers/session-config.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let storeTemplatePath = "";
let configOverride: Record<string, unknown> = {
session: createPerSenderSessionConfig(),
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
};
});
function writeStore(agentId: string, store: Record<string, unknown>) {
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<string, unknown>) {
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 };
}
describe("sessions_spawn depth + child limits", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
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: "running" };
}
return {};
});
});
it("rejects spawning when caller depth reaches maxSpawnDepth", async () => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:subagent:parent" });
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<string, unknown> },
);
const agentCall = calls.find((entry) => entry.method === "agent");
expect(agentCall?.params?.spawnedBy).toBe("agent:main:subagent:parent");
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 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: "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);
});
});