import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import { handoffToGroupBot, loadGroupContext } from "./group-handoff.js"; const run = { id: "run-a", spaceId: "workspace-1", threadId: "thread-1", botId: "bot-a", userId: "user-1", }; function harness( sourceBlocks: unknown, existing?: { sourceRuns: { id: string; botId: string }[] }, leadBotId: string | null = null, ) { const runCreate = vi.fn(async () => ({ id: "run-b" })); const messageCreate = vi.fn(async () => ({ id: "message-1" })); const tx = { $queryRaw: vi.fn(async () => [{ id: "group-1" }]), chatGroup: { findFirst: vi.fn(async () => ({ id: "group-1", leadBotId, members: ["bot-a", "bot-b", "bot-c"].map((id) => ({ bot: { id, name: id.toUpperCase() }, })), })), update: vi.fn(async () => ({ id: "group-1" })), }, run: { findFirst: vi.fn(async () => ({ id: run.id, sourceMessage: { blocks: sourceBlocks }, })), findUnique: vi.fn(async () => ({ status: "running" })), create: runCreate, }, message: { findUnique: vi.fn(async () => existing ?? null), create: messageCreate, }, thread: { update: vi.fn(async (args: { select: { nextMessageSeq?: boolean } }) => args.select.nextMessageSeq ? { nextMessageSeq: 2 } : { nextEventSeq: 2 }, ), }, task: { create: vi.fn(async () => ({ id: "task-b" })) }, event: { findFirst: vi.fn(async () => ({ seq: 1 })), create: vi.fn(async () => ({ seq: 1 })), }, }; const prisma = { $transaction: vi.fn(async (callback: (value: typeof tx) => Promise) => callback(tx)), } as unknown as PrismaClient; return { deps: { prisma, events: { notify: vi.fn(async () => undefined) }, jobs: { enqueue: vi.fn(async () => undefined) }, }, messageCreate, runCreate, }; } describe("group handoff ownership", () => { it("marks a new ownership transfer as a follow-up with a chain hop", async () => { const { deps, messageCreate, runCreate } = harness([{ kind: "text", text: "user request" }]); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "Do the distinct next stage", }), ).resolves.toMatchObject({ ok: true, botId: "bot-b" }); expect(messageCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ blocks: [ expect.objectContaining({ kind: "handoff", fromBotId: "bot-a", toBotId: "bot-b", hop: 1, }), ], }), }), ); expect(runCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ trigger: "follow_up" }) }), ); }); it("refuses to re-delegate the same inbound task instead of workshoping", async () => { const { deps, runCreate } = harness([ { kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Erstelle ein Konzept für mobile Voice-First UI", hop: 1, }, ]); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-c", message: "Erstelle ein Konzept für mobile Voice-First UI", }), ).resolves.toEqual({ error: "you own this handed-off stage; workshop with the user and post your concept here instead of re-delegating the same task", }); expect(runCreate).not.toHaveBeenCalled(); }); it("refuses to bounce a handed-off stage straight back to its sender", async () => { const { deps, runCreate } = harness([ { kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Investigate", hop: 1 }, ]); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "You investigate it", }), ).resolves.toEqual({ error: "do not hand this stage back to its sender; post the result in the shared thread instead", }); expect(runCreate).not.toHaveBeenCalled(); }); it("caps longer multi-agent handoff chains", async () => { const { deps, runCreate } = harness([ { kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Stage six", hop: 6 }, ]); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-c", message: "Stage seven", }), ).resolves.toEqual({ error: "group handoff limit reached for this chain; finish the current stage in the shared thread instead", }); expect(runCreate).not.toHaveBeenCalled(); }); it("reuses the recorded transfer when a source run is retried", async () => { const { deps, messageCreate, runCreate } = harness([], { sourceRuns: [{ id: "run-b", botId: "bot-b" }], }); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-c", message: "A duplicate stage", }), ).resolves.toMatchObject({ ok: true, botId: "bot-b", runId: "run-b" }); expect(messageCreate).not.toHaveBeenCalled(); expect(runCreate).not.toHaveBeenCalled(); }); it("rejects malformed source ancestry instead of restarting its hop count", async () => { const { deps, runCreate } = harness({ kind: "not-an-array" }); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "Continue", }), ).resolves.toEqual({ error: "cannot verify the group handoff chain" }); expect(runCreate).not.toHaveBeenCalled(); }); }); describe("group handoff with a lead", () => { const voiceTask = "Erstelle ein Konzept für mobile Voice-First UI"; it("lets a member hand its stage back to the lead", async () => { const { deps, runCreate } = harness( [{ kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Investigate", hop: 1 }], undefined, "bot-b", ); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "Findings: the import fails on empty rows.", }), ).resolves.toMatchObject({ ok: true, botId: "bot-b", note: expect.stringContaining("Handed back to the lead."), }); expect(runCreate).toHaveBeenCalledOnce(); }); it("lets the lead give the member that just reported its next stage", async () => { const { deps, runCreate } = harness( [{ kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Draft is done", hop: 2 }], undefined, "bot-a", ); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "Now tighten the second section.", }), ).resolves.toMatchObject({ ok: true, botId: "bot-b", note: expect.stringContaining("Handoff recorded."), }); expect(runCreate).toHaveBeenCalledOnce(); }); it("lets the lead forward a returned result to another member", async () => { const result = "Three voice-first directions with sketches are ready for review"; const { deps, runCreate } = harness( [{ kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: result, hop: 2 }], undefined, "bot-a", ); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-c", message: `Build a prototype of the first direction. ${result}`, }), ).resolves.toMatchObject({ ok: true, botId: "bot-c" }); expect(runCreate).toHaveBeenCalledOnce(); }); it("still refuses a member handing the same task back to the lead", async () => { const { deps, runCreate } = harness( [{ kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: voiceTask, hop: 1 }], undefined, "bot-b", ); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: voiceTask }), ).resolves.toEqual({ error: "you own this handed-off stage; workshop with the user and post your concept here instead of re-delegating the same task", }); expect(runCreate).not.toHaveBeenCalled(); }); it("keeps the bounce rule between members", async () => { const { deps, runCreate } = harness( [{ kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Investigate", hop: 1 }], undefined, "bot-c", ); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "You investigate it", }), ).resolves.toEqual({ error: "do not hand this stage back to its sender; post the result in the shared thread instead", }); expect(runCreate).not.toHaveBeenCalled(); }); it("ignores a lead that is no longer a member", async () => { const { deps, runCreate } = harness( [{ kind: "handoff", fromBotId: "bot-b", toBotId: "bot-a", text: "Investigate", hop: 1 }], undefined, "bot-gone", ); await expect( handoffToGroupBot(deps as never, run, "group-1", { bot_id: "bot-b", message: "You investigate it", }), ).resolves.toEqual({ error: "do not hand this stage back to its sender; post the result in the shared thread instead", }); expect(runCreate).not.toHaveBeenCalled(); }); }); describe("loadGroupContext", () => { const prismaWith = (leadBotId: string | null) => ({ chatGroup: { findUnique: vi.fn(async () => ({ id: "group-1", name: "Main", leadBotId, members: [ { bot: { id: "lead-1", name: "Chief", title: "Chief of Staff", description: "" } }, { bot: { id: "ux-1", name: "Designer", title: "UX", description: "" } }, ], })), }, }) as unknown as PrismaClient; it("returns the lead and the specialist lines for a member of a lead group", async () => { const context = await loadGroupContext(prismaWith("lead-1"), "group-1", { id: "ux-1", name: "Designer", }); expect(context?.lead).toEqual({ id: "lead-1", name: "Chief" }); expect(context?.instructions).toContain("Chief leads this conversation."); }); it("returns no lead and the peer lines for an ordinary group", async () => { const context = await loadGroupContext(prismaWith(null), "group-1", { id: "ux-1", name: "Designer", }); expect(context?.lead).toBeUndefined(); expect(context?.instructions).toContain("Never handoff_to_bot upward"); }); it("treats a lead outside the active members as no lead", async () => { const context = await loadGroupContext(prismaWith("archived-1"), "group-1", { id: "ux-1", name: "Designer", }); expect(context?.lead).toBeUndefined(); expect(context?.instructions).not.toContain("leads this conversation"); }); });