evOo-local/packages/adapters/src/group-handoff.test.ts
Claude e71c16050d
feat(adapters): every run knows who it is and who the user is, in the user's time zone
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
2026-10-02 21:39:39 +00:00

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");
});
});