Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
import type { PrismaClient } from "@rakazo/db";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { handoffToGroupBot } 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 }[] },
|
|
) {
|
|
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",
|
|
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<unknown>) => 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();
|
|
});
|
|
});
|