Runs dropped the bot's name, title and description whenever custom instructions existed, always stated the time in UTC, and knew the user only through free-form durable memory. Groups with a lead, like the main conversation, still got the peer-group lines that forbid handing work back. And every group follow-up run failed during setup: the inbound handoff lookup selected a `bot` relation that Message does not have, which Prisma rejects. - Instructions now open with the bot's identity, then its own instructions, a main-agent role paragraph for the main bot, the current time in the user's time zone with its offset (UTC when unknown), and a `<user>` block built from loadUserContext: profile fields and the active facts visible in the run's space, escaped, clipped and framed as data rather than instructions. Runs that reply in a shared messaging channel get only the name, zone, locale, language and style, matching how those runs already leave out durable memory. - run_subagent helpers receive the identity, time and user block through a new optional AgentRunRequest.subagentContext instead of nothing. - Groups with an active lead get lead and specialist lines and matching inbound handoff instructions. Members may hand a stage back to the lead and the lead may delegate the next stage; the bounce rules still apply between members. Ordinary groups keep today's lines. - The teammate directory is rendered only when the run has message_bot, which group runs never have, and tool guidance names a tool only when the run has it. - The auto-review judge, history compaction and the team chat judge read the time in the user's zone, and schedule_create defaults to it. - The inbound handoff sender is read from the handoff block and looked up among the user's bots; a Postgres test covers the follow-up run, the prompt it receives, and handing the result back to the lead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
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<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();
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|