Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/agent-meeting-presets-grounding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
"@launchstack/core": minor
---

Ground meeting agents in the workspace's documents, and ship a preset startup
team.

**Turn-level grounding**

Meeting agents were the one part of the product that reasoned about documents
without ever reading them: `MeetingConfig.context` existed, and nothing
populated it. Retrieval is now a port on the engine —
`TurnGroundingProvider` — consulted before each turn for the persona *about to
speak*. The engine still knows nothing about documents or indexes; the
implementation over the existing ensemble retriever lives in the host.

**New exports** from `@launchstack/core/collab`: `TurnGroundingProvider`,
`TurnGrounding`, `TurnGroundingRequest`, `GroundingSource`,
`buildGroundingQuery`, `toExcerpt`, `EMPTY_GROUNDING`, `MAX_EXCERPT_CHARS`.
`MeetingOrchestratorOptions` and `CreateMeetingInput` accept
`groundingProvider`; the orchestrator additionally takes `onGroundingError`.

Retrieval is per-persona because the analyst and the engineer ask the corpus
different questions, capped at four passages because each turn already carries
the whole transcript, and never fatal — a failure yields an ungrounded turn
rather than a dead meeting.

**Behaviour change: `evaluateMeeting` grounding dimension**

`scoreGrounding` now builds its haystack from `config.context` *and* the
excerpts recorded on each message, rather than `config.context` alone. A
meeting grounded purely by retrieval previously scored "no context supplied —
dimension not applicable" at weight 0; it is now scored. Meetings with no
grounding at all are unaffected.

This is why turns record a truncated excerpt of what they read alongside the
citation: it keeps a grounded transcript checkable without the index still
being around, and lets the scorer tell a cited number from an invented one.

**Behaviour change: `selectNextSpeaker` follows the last mention**

`findMention` returned the *first* `@handle` in a message. A turn in a working
channel opens by answering whoever spoke last and closes by asking someone
else, so the first mention is a reply-to and the last is the actual handoff —
taking the first sent the floor back to the person who had just spoken and
starved the specialists. Under `reactive` and `moderated`, the last mention now
wins.

Single-mention messages are unaffected, which is why no existing test moved:
every scripted line in the suite carries exactly one mention. This was found by
running a real meeting.

37 changes: 37 additions & 0 deletions .changeset/collab-rooms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@launchstack/core": minor
---

Add rooms — ask every member one question at once, each answering from its own
documents.

A meeting is a conversation: one speaker, elected from the transcript, working
toward a close. A room is a query: one question, every member, concurrently, no
floor to hold. They share a channel log and nothing else.

**New exports** from `@launchstack/core/collab`: `askRoom`, `summarizeRounds`,
`buildRoomTurnContext`, `extractDecline`, `ROOM_DECLINE_MARKER`,
`DEFAULT_MEMBER_TIMEOUT_MS`, and the `RoomConfig` / `RoomAnswer` /
`AskRoomResult` / `RoomRound` types.

`askRoom` is a function rather than a method on `MeetingOrchestrator` for three
reasons: the orchestrator marks a whole meeting `failed` when no runtime serves
a persona, which is the ordinary case for a room member that is offline; a room
carries no state between rounds, so there is nothing to persist; and
`maxConsecutiveFailures` is meaningful for a conversation and actively wrong for
a fan-out, where one member failing says nothing about the rest.

A round has no row of its own. The question carries `meta.round = { id,
expected }`, each answer carries `meta.roundId` and its status, and
`summarizeRounds()` reconstructs the round from the log — the same rule meetings
follow, where the channel is the only transcript.

**`TurnContext` gains an optional `mode`.** Absent means `meeting`, so a worker
built before rooms existed keeps its current behaviour. `buildSystemPrompt`
branches on it: a room member answers once, alone, and is told that declining is
a useful answer, rather than being given a turn budget and handoff instructions
for a room that does not exist.

Members are deliberately isolated — each receives the question only, never the
other answers. If they saw each other, the first to finish would anchor the
rest, and the fan-out would collapse into a slower conversation.
4 changes: 4 additions & 0 deletions apps/web/__tests__/api/collab/meetings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ function mockBuildRow(config: {
status: "scheduled" as const,
slackChannelId: null as string | null,
slackMirrorEnabled: false,
// Both columns are NOT NULL with defaults, so a real row always carries
// them — the fixture has to as well or it stops representing the row.
groundingEnabled: false,
documentIds: [] as string[],
createdAt: new Date("2026-01-01T00:00:00.000Z"),
startedAt: null as Date | null,
endedAt: null as Date | null,
Expand Down
263 changes: 263 additions & 0 deletions apps/web/__tests__/api/collab/presets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/**
* Preset agent teams.
*
* Two things matter here and neither is cosmetic. Applying a pack must be
* additive — a handle already in use is referenced by past transcripts and by
* the frozen roster on every meeting that used it, so overwriting one rewrites
* history. And the prompts themselves are the product: a preset agent with a
* vague prompt produces a meeting of six agreeable voices, which is the exact
* failure the preset exists to prevent.
*/

import { partitionByHandle } from "~/server/collab/personas";
import { PERSONA_PACKS, getPack, listPackSummaries } from "~/server/collab/presets";

interface StoredPersona {
dbId: string;
id: string;
archived: boolean;
}

const mockCtx: { userId: string | null; personas: StoredPersona[] } = {
userId: "user_1",
personas: [],
};

jest.mock("~/lib/require-workspace-context", () => ({
requireWorkspaceContext: () =>
mockCtx.userId
? Promise.resolve({
success: true,
data: {
clerkUserId: mockCtx.userId,
userPk: 1n,
companyId: 7n,
role: "owner",
status: "verified",
},
})
: Promise.resolve({
success: false,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
}),
}));

// Keeps the Postgres client from loading — it pulls `~/env`, which is
// ESM-only and cannot be required under Jest.
jest.mock("~/server/db", () => ({ db: {} }));

// `applyPersonas` is storage; `partitionByHandle` is the rule. The rule stays
// real here — the mock only supplies the rows it would have read and records
// the writes it would have made.
jest.mock("~/server/collab/personas", () => {
const actual: {
partitionByHandle: (
existing: Iterable<string>,
candidates: Array<{ key: string }>,
) => { toCreate: Array<{ key: string }>; skipped: string[] };
} = jest.requireActual("~/server/collab/personas");

return {
...actual,
listPersonas: (_companyId: bigint, includeArchived = false) =>
Promise.resolve(mockCtx.personas.filter((p) => (includeArchived ? true : !p.archived))),
applyPersonas: (_companyId: bigint, candidates: Array<{ key: string }>) => {
const { toCreate, skipped } = actual.partitionByHandle(
mockCtx.personas.map((p) => p.id),
candidates,
);
for (const persona of toCreate) {
mockCtx.personas.push({ dbId: `p_${persona.key}`, id: persona.key, archived: false });
}
return Promise.resolve({ created: toCreate.map((p) => p.key), skipped });
},
};
});

async function loadRoute() {
return import("~/app/api/collab/agents/presets/route");
}

function postRequest(body: unknown): Request {
return new Request("http://localhost/api/collab/agents/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

beforeEach(() => {
mockCtx.userId = "user_1";
mockCtx.personas = [];
jest.resetModules();
});

describe("preset pack definitions", () => {
const packs = PERSONA_PACKS;

it("ships the tech startup core team", () => {
const pack = getPack("startup-core");
expect(pack).not.toBeNull();
expect(pack!.personas.map((p) => p.key)).toEqual([
"founder",
"product",
"eng",
"design",
"growth",
"data",
]);
});

it("uses handles that are mention-safe and unique within a pack", () => {
for (const pack of packs) {
const keys = pack.personas.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) expect(key).toMatch(/^[a-z0-9][a-z0-9_-]*$/);
}
});

it("names a moderator that is actually in the pack", () => {
for (const pack of packs) {
if (pack.suggested.turnPolicy !== "moderated") continue;
expect(pack.suggested.moderatorKey).toBeDefined();
expect(pack.personas.map((p) => p.key)).toContain(pack.suggested.moderatorKey!);
}
});

it("fits inside the meeting participant cap", () => {
// The create-meeting API accepts at most 10 participants; a pack you
// cannot put in one room is not a usable preset.
for (const pack of packs) expect(pack.personas.length).toBeLessThanOrEqual(10);
});

it("gives every agent a prompt with a stance, a shape, and a handoff", () => {
for (const pack of packs) {
for (const persona of pack.personas) {
const prompt = persona.systemPrompt;
// Substantial enough to actually steer a model.
expect(prompt.length).toBeGreaterThan(600);
// What this agent refuses to let pass — the source of real disagreement.
expect(prompt).toContain("## What you refuse to let pass");
// How a good turn is shaped, so "be concrete" is not the instruction.
expect(prompt).toContain("## How you run a turn");
// Who to hand to, so the room does not stall with nobody addressed.
expect(prompt).toMatch(/@[a-z0-9_-]+/);
// Explicit permission to not know, so gaps do not become inventions.
expect(prompt).toContain("do not support a claim");
}
}
});

it("only hands off to handles that exist in the same pack", () => {
for (const pack of packs) {
const keys = new Set(pack.personas.map((p) => p.key));
for (const persona of pack.personas) {
const mentioned = [...persona.systemPrompt.matchAll(/@([a-z0-9_-]+)/g)].map((m) => m[1]!);
for (const handle of mentioned) {
// A prompt telling an agent to hand to @finance in a pack with no
// finance seat produces a turn addressed to nobody.
expect(keys.has(handle)).toBe(true);
}
}
}
});

it("keeps full prompts out of the pack summaries", () => {
for (const summary of listPackSummaries()) {
for (const persona of summary.personas) {
expect(persona.promptPreview.length).toBeLessThan(300);
expect(persona).not.toHaveProperty("systemPrompt");
}
}
});
});

describe("partitionByHandle", () => {
const p = (key: string) => ({ key, displayName: key, role: key, systemPrompt: "x" });

it("keeps existing handles and creates the rest", () => {
const result = partitionByHandle(["eng"], [p("founder"), p("eng"), p("data")]);
expect(result.toCreate.map((c) => c.key)).toEqual(["founder", "data"]);
expect(result.skipped).toEqual(["eng"]);
});

it("creates a repeated handle once rather than tripping the unique index", () => {
const result = partitionByHandle([], [p("eng"), p("eng")]);
expect(result.toCreate).toHaveLength(1);
expect(result.skipped).toEqual(["eng"]);
});
});

describe("POST /api/collab/agents/presets", () => {
it("creates the whole pack on a fresh workspace", async () => {
const { POST } = await loadRoute();
const response = await POST(postRequest({ packId: "startup-core" }));
const body = (await response.json()) as { created: string[]; skipped: string[] };

expect(response.status).toBe(201);
expect(body.created).toHaveLength(6);
expect(body.skipped).toEqual([]);
});

it("is idempotent — applying twice creates nothing the second time", async () => {
const { POST } = await loadRoute();
await POST(postRequest({ packId: "startup-core" }));
const response = await POST(postRequest({ packId: "startup-core" }));
const body = (await response.json()) as { created: string[]; skipped: string[] };

expect(response.status).toBe(200);
expect(body.created).toEqual([]);
expect(body.skipped).toHaveLength(6);
expect(mockCtx.personas).toHaveLength(6);
});

it("never overwrites a handle the workspace already uses", async () => {
mockCtx.personas.push({ dbId: "mine", id: "eng", archived: false });
const { POST } = await loadRoute();
const response = await POST(postRequest({ packId: "startup-core" }));
const body = (await response.json()) as { created: string[]; skipped: string[] };

expect(body.skipped).toEqual(["eng"]);
expect(body.created).not.toContain("eng");
// The original row is untouched — same dbId, not replaced by the preset.
expect(mockCtx.personas.filter((p) => p.id === "eng")).toEqual([
{ dbId: "mine", id: "eng", archived: false },
]);
});

it("treats an archived handle as taken", async () => {
// The handle still appears in old transcripts, and the unique index does
// not care that the row is archived.
mockCtx.personas.push({ dbId: "old", id: "growth", archived: true });
const { POST } = await loadRoute();
const response = await POST(postRequest({ packId: "startup-core" }));
const body = (await response.json()) as { skipped: string[] };

expect(body.skipped).toEqual(["growth"]);
});

it("404s an unknown pack and 400s a malformed body", async () => {
const { POST } = await loadRoute();
expect((await POST(postRequest({ packId: "nope" }))).status).toBe(404);
expect((await POST(postRequest({}))).status).toBe(400);
});

it("requires a workspace", async () => {
mockCtx.userId = null;
const { GET, POST } = await loadRoute();
expect((await GET()).status).toBe(401);
expect((await POST(postRequest({ packId: "startup-core" }))).status).toBe(401);
});
});

describe("GET /api/collab/agents/presets", () => {
it("flags handles that would conflict before the user clicks", async () => {
mockCtx.personas.push({ dbId: "mine", id: "product", archived: false });
const { GET } = await loadRoute();
const body = (await (await GET()).json()) as {
packs: Array<{ id: string; conflicts: string[] }>;
};

expect(body.packs.find((p) => p.id === "startup-core")!.conflicts).toEqual(["product"]);
});
});
Loading