import { describe, expect, it, vi } from "vitest"; const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadConfig: () => ({ session: { mainKey: "main", scope: "per-sender", }, }), resolveGatewayPort: () => 18789, }; }); import { createClawdbotTools } from "./clawdbot-tools.js"; describe("subagents", () => { it("sessions_spawn announces back to the requester group provider", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; let lastWaitedRunId: string | undefined; const replyByRunId = new Map(); let sendParams: { to?: string; provider?: string; message?: string } = {}; let deletedKey: string | undefined; 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 } | undefined; const message = params?.message ?? ""; const reply = message === "Sub-agent announce step." ? "announce now" : "result"; replyByRunId.set(runId, reply); return { runId, status: "accepted", acceptedAt: 1000 + agentCallCount, }; } if (request.method === "agent.wait") { const params = request.params as { runId?: string } | undefined; lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [{ role: "assistant", content: [{ type: "text", text }] }], }; } if (request.method === "send") { const params = request.params as | { to?: string; provider?: string; message?: string } | undefined; sendParams = { to: params?.to, provider: params?.provider, message: params?.message, }; return { messageId: "m-announce" }; } if (request.method === "sessions.delete") { const params = request.params as { key?: string } | undefined; deletedKey = params?.key; return { ok: true }; } return {}; }); const tool = createClawdbotTools({ agentSessionKey: "discord:group:req", agentProvider: "discord", }).find((candidate) => candidate.name === "sessions_spawn"); if (!tool) throw new Error("missing sessions_spawn tool"); const result = await tool.execute("call1", { task: "do thing", timeoutSeconds: 1, cleanup: "delete", }); expect(result.details).toMatchObject({ status: "ok", reply: "result" }); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); const agentCalls = calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const first = agentCalls[0]?.params as | { lane?: string; deliver?: boolean; sessionKey?: string } | undefined; expect(first?.lane).toBe("subagent"); expect(first?.deliver).toBe(false); expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true); expect(sendParams.provider).toBe("discord"); expect(sendParams.to).toBe("channel:req"); expect(sendParams.message ?? "").toContain("announce now"); expect(sendParams.message ?? "").toContain("Stats:"); expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true); }); it("sessions_spawn resolves main announce target from sessions.list", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; let lastWaitedRunId: string | undefined; const replyByRunId = new Map(); let sendParams: { to?: string; provider?: string; message?: 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", lastProvider: "whatsapp", lastTo: "+123", }, ], }; } if (request.method === "agent") { agentCallCount += 1; const runId = `run-${agentCallCount}`; const params = request.params as | { message?: string; sessionKey?: string } | undefined; const message = params?.message ?? ""; const reply = message === "Sub-agent announce step." ? "hello from sub" : "done"; replyByRunId.set(runId, reply); return { runId, status: "accepted", acceptedAt: 2000 + agentCallCount, }; } if (request.method === "agent.wait") { const params = request.params as { runId?: string } | undefined; lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [{ role: "assistant", content: [{ type: "text", text }] }], }; } if (request.method === "send") { const params = request.params as | { to?: string; provider?: string; message?: string } | undefined; sendParams = { to: params?.to, provider: params?.provider, message: params?.message, }; return { messageId: "m1" }; } if (request.method === "sessions.delete") { return { ok: true }; } return {}; }); const tool = createClawdbotTools({ agentSessionKey: "main", agentProvider: "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", timeoutSeconds: 1, }); expect(result.details).toMatchObject({ status: "ok", reply: "done" }); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); expect(sendParams.provider).toBe("whatsapp"); expect(sendParams.to).toBe("+123"); expect(sendParams.message ?? "").toContain("hello from sub"); expect(sendParams.message ?? "").toContain("Stats:"); }); it("sessions_spawn applies a model to the child session", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; let lastWaitedRunId: string | undefined; const replyByRunId = new Map(); 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") { agentCallCount += 1; const runId = `run-${agentCallCount}`; const params = request.params as | { message?: string; sessionKey?: string } | undefined; const message = params?.message ?? ""; const reply = message === "Sub-agent announce step." ? "ANNOUNCE_SKIP" : "done"; replyByRunId.set(runId, reply); return { runId, status: "accepted", acceptedAt: 3000 + agentCallCount, }; } if (request.method === "agent.wait") { const params = request.params as { runId?: string } | undefined; lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [{ role: "assistant", content: [{ type: "text", text }] }], }; } if (request.method === "sessions.delete") { return { ok: true }; } return {}; }); const tool = createClawdbotTools({ agentSessionKey: "discord:group:req", agentSurface: "discord", }).find((candidate) => candidate.name === "sessions_spawn"); if (!tool) throw new Error("missing sessions_spawn tool"); const result = await tool.execute("call3", { task: "do thing", timeoutSeconds: 1, model: "claude-haiku-4-5", cleanup: "keep", }); expect(result.details).toMatchObject({ status: "ok", reply: "done" }); const patchIndex = calls.findIndex( (call) => call.method === "sessions.patch", ); const agentIndex = calls.findIndex((call) => call.method === "agent"); expect(patchIndex).toBeGreaterThan(-1); expect(agentIndex).toBeGreaterThan(-1); expect(patchIndex).toBeLessThan(agentIndex); const patchCall = calls[patchIndex]; expect(patchCall?.params).toMatchObject({ key: expect.stringContaining("subagent:"), model: "claude-haiku-4-5", }); }); it("sessions_spawn skips invalid model overrides and continues", async () => { callGatewayMock.mockReset(); const calls: Array<{ method?: string; params?: unknown }> = []; let agentCallCount = 0; let lastWaitedRunId: string | undefined; const replyByRunId = new Map(); 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}`; const params = request.params as | { message?: string; sessionKey?: string } | undefined; const message = params?.message ?? ""; const reply = message === "Sub-agent announce step." ? "ANNOUNCE_SKIP" : "done"; replyByRunId.set(runId, reply); return { runId, status: "accepted", acceptedAt: 4000 + agentCallCount, }; } if (request.method === "agent.wait") { const params = request.params as { runId?: string } | undefined; lastWaitedRunId = params?.runId; return { runId: params?.runId ?? "run-1", status: "ok" }; } if (request.method === "chat.history") { const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? ""; return { messages: [{ role: "assistant", content: [{ type: "text", text }] }], }; } if (request.method === "sessions.delete") { return { ok: true }; } return {}; }); const tool = createClawdbotTools({ agentSessionKey: "main", agentProvider: "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", timeoutSeconds: 1, model: "bad-model", }); expect(result.details).toMatchObject({ status: "ok", modelApplied: false, }); expect( String((result.details as { warning?: string }).warning ?? ""), ).toContain("invalid model"); expect(calls.some((call) => call.method === "agent")).toBe(true); }); });