Files
openclaw/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts
2026-02-24 04:12:25 +00:00

373 lines
11 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnConfigOverride,
setupSessionsSpawnGatewayMock,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
const fastModeEnv = vi.hoisted(() => {
const previous = process.env.OPENCLAW_TEST_FAST;
process.env.OPENCLAW_TEST_FAST = "1";
return { previous };
});
vi.mock("./pi-embedded.js", () => ({
isEmbeddedPiRunActive: () => false,
isEmbeddedPiRunStreaming: () => false,
queueEmbeddedPiMessage: () => false,
waitForEmbeddedPiRunEnd: async () => true,
}));
vi.mock("./tools/agent-step.js", () => ({
readLatestAssistantReply: async () => "done",
}));
const callGatewayMock = getCallGatewayMock();
const RUN_TIMEOUT_SECONDS = 1;
function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
return {
onAgentSubagentSpawn: (params: unknown) => {
const rec = params as { channel?: string; timeout?: number } | undefined;
expect(rec?.channel).toBe("discord");
expect(rec?.timeout).toBe(1);
},
onSessionsDelete: (params: unknown) => {
const rec = params as { key?: string } | undefined;
onDelete(rec?.key);
},
};
}
const waitFor = async (predicate: () => boolean, timeoutMs = 1_500) => {
await vi.waitFor(
() => {
expect(predicate()).toBe(true);
},
{ timeout: timeoutMs, interval: 8 },
);
};
async function getDiscordGroupSpawnTool() {
return await getSessionsSpawnTool({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
});
}
async function executeSpawnAndExpectAccepted(params: {
tool: Awaited<ReturnType<typeof getSessionsSpawnTool>>;
callId: string;
cleanup?: "delete" | "keep";
label?: string;
}) {
const result = await params.tool.execute(params.callId, {
task: "do thing",
runTimeoutSeconds: RUN_TIMEOUT_SECONDS,
...(params.cleanup ? { cleanup: params.cleanup } : {}),
...(params.label ? { label: params.label } : {}),
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
return result;
}
async function emitLifecycleEndAndFlush(params: {
runId: string;
startedAt: number;
endedAt: number;
}) {
vi.useFakeTimers();
try {
emitAgentEvent({
runId: params.runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: params.startedAt,
endedAt: params.endedAt,
},
});
await vi.runAllTimersAsync();
} finally {
vi.useRealTimers();
}
}
describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
beforeEach(() => {
resetSessionsSpawnConfigOverride();
setSessionsSpawnConfigOverride({
session: {
mainKey: "main",
scope: "per-sender",
},
messages: {
queue: {
debounceMs: 0,
},
},
});
resetSubagentRegistryForTests();
callGatewayMock.mockClear();
});
afterAll(() => {
if (fastModeEnv.previous === undefined) {
delete process.env.OPENCLAW_TEST_FAST;
return;
}
process.env.OPENCLAW_TEST_FAST = fastModeEnv.previous;
});
it("sessions_spawn runs cleanup flow after subagent completion", async () => {
const patchCalls: Array<{ key?: string; label?: string }> = [];
const ctx = setupSessionsSpawnGatewayMock({
includeSessionsList: true,
includeChatHistory: true,
onSessionsPatch: (params) => {
const rec = params as { key?: string; label?: string } | undefined;
patchCalls.push({ key: rec?.key, label: rec?.label });
},
});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
});
await executeSpawnAndExpectAccepted({
tool,
callId: "call2",
label: "my-task",
});
const child = ctx.getChild();
if (!child.runId) {
throw new Error("missing child runId");
}
await waitFor(
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
patchCalls.some((call) => call.label === "my-task") &&
ctx.calls.filter((call) => call.method === "agent").length >= 2,
);
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
expect(childWait?.timeoutMs).toBe(1000);
// Cleanup should patch the label
const labelPatch = patchCalls.find((call) => call.label === "my-task");
expect(labelPatch?.key).toBe(child.sessionKey);
expect(labelPatch?.label).toBe("my-task");
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = ctx.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("agent:main:main");
expect(second?.message).toContain("subagent task");
// No direct send to external channel (main agent handles delivery)
const sendCalls = ctx.calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn runs cleanup via lifecycle events", async () => {
let deletedKey: string | undefined;
const ctx = setupSessionsSpawnGatewayMock({
...buildDiscordCleanupHooks((key) => {
deletedKey = key;
}),
});
const tool = await getDiscordGroupSpawnTool();
await executeSpawnAndExpectAccepted({
tool,
callId: "call1",
cleanup: "delete",
});
const child = ctx.getChild();
if (!child.runId) {
throw new Error("missing child runId");
}
await emitLifecycleEndAndFlush({
runId: child.runId,
startedAt: 1234,
endedAt: 2345,
});
await waitFor(
() => ctx.calls.filter((call) => call.method === "agent").length >= 2 && Boolean(deletedKey),
);
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
expect(childWait?.timeoutMs).toBe(1000);
const agentCalls = ctx.calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const first = agentCalls[0]?.params as
| {
lane?: string;
deliver?: boolean;
sessionKey?: string;
channel?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
const second = agentCalls[1]?.params as
| {
sessionKey?: string;
message?: string;
deliver?: boolean;
}
| undefined;
expect(second?.sessionKey).toBe("agent:main:discord:group:req");
expect(second?.deliver).toBe(false);
expect(second?.message).toContain("subagent task");
const sendCalls = ctx.calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn deletes session when cleanup=delete via agent.wait", async () => {
let deletedKey: string | undefined;
const ctx = setupSessionsSpawnGatewayMock({
includeChatHistory: true,
...buildDiscordCleanupHooks((key) => {
deletedKey = key;
}),
agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 },
});
const tool = await getDiscordGroupSpawnTool();
await executeSpawnAndExpectAccepted({
tool,
callId: "call1b",
cleanup: "delete",
});
const child = ctx.getChild();
if (!child.runId) {
throw new Error("missing child runId");
}
await waitFor(
() =>
ctx.waitCalls.some((call) => call.runId === child.runId) &&
ctx.calls.filter((call) => call.method === "agent").length >= 2 &&
Boolean(deletedKey),
);
const childWait = ctx.waitCalls.find((call) => call.runId === child.runId);
expect(childWait?.timeoutMs).toBe(1000);
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = ctx.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("agent:main:discord:group:req");
expect(second?.deliver).toBe(false);
// No direct send to external channel (main agent handles delivery)
const sendCalls = ctx.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 () => {
const ctx = setupSessionsSpawnGatewayMock({
includeChatHistory: true,
chatHistoryText: "still working",
agentWaitResult: { status: "timeout", startedAt: 6000, endedAt: 7000 },
});
const tool = await getDiscordGroupSpawnTool();
await executeSpawnAndExpectAccepted({
tool,
callId: "call-timeout",
cleanup: "keep",
});
await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2);
const mainAgentCall = ctx.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 () => {
const ctx = setupSessionsSpawnGatewayMock({});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "whatsapp",
agentAccountId: "kev",
});
await executeSpawnAndExpectAccepted({
tool,
callId: "call-announce-account",
cleanup: "keep",
});
const child = ctx.getChild();
if (!child.runId) {
throw new Error("missing child runId");
}
await emitLifecycleEndAndFlush({
runId: child.runId,
startedAt: 1000,
endedAt: 2000,
});
const agentCalls = ctx.calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }
| undefined;
expect(announceParams?.deliver).toBe(false);
expect(announceParams?.channel).toBeUndefined();
expect(announceParams?.accountId).toBeUndefined();
});
});