373 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|