637 lines
25 KiB
TypeScript
637 lines
25 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||
import { eq } from "drizzle-orm";
|
||
import {
|
||
activityLog,
|
||
agents,
|
||
companies,
|
||
createDb,
|
||
documentRevisions,
|
||
documents,
|
||
executionWorkspaces,
|
||
heartbeatRuns,
|
||
issues,
|
||
projectWorkspaces,
|
||
projects,
|
||
summarySlots,
|
||
} from "@paperclipai/db";
|
||
import {
|
||
getEmbeddedPostgresTestSupport,
|
||
startEmbeddedPostgresTestDatabase,
|
||
} from "./helpers/embedded-postgres.js";
|
||
import { summarySlotService } from "../services/summary-slots.ts";
|
||
import { withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts";
|
||
import { issueService } from "../services/issues.ts";
|
||
|
||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||
|
||
function issuePrefix(id: string) {
|
||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||
}
|
||
|
||
if (!embeddedPostgresSupport.supported) {
|
||
console.warn(
|
||
`Skipping embedded Postgres summary-slot tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||
);
|
||
}
|
||
|
||
describeEmbeddedPostgres("summary slot service", () => {
|
||
let db!: ReturnType<typeof createDb>;
|
||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||
|
||
beforeAll(async () => {
|
||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-summary-slots-");
|
||
db = createDb(tempDb.connectionString);
|
||
}, 20_000);
|
||
|
||
afterEach(async () => {
|
||
await db.delete(summarySlots);
|
||
await db.delete(documentRevisions);
|
||
await db.delete(documents);
|
||
await db.delete(issues);
|
||
await db.delete(heartbeatRuns);
|
||
await db.delete(executionWorkspaces);
|
||
await db.delete(projectWorkspaces);
|
||
await db.delete(projects);
|
||
await db.delete(activityLog);
|
||
await db.delete(agents);
|
||
await db.delete(companies);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await tempDb?.cleanup();
|
||
});
|
||
|
||
async function seedCompany() {
|
||
const companyId = randomUUID();
|
||
await db.insert(companies).values({
|
||
id: companyId,
|
||
name: "Paperclip",
|
||
issuePrefix: issuePrefix(companyId),
|
||
defaultResponsibleUserId: "responsible-user",
|
||
});
|
||
return companyId;
|
||
}
|
||
|
||
async function seedProject(companyId: string) {
|
||
const projectId = randomUUID();
|
||
await db.insert(projects).values({ id: projectId, companyId, name: "Paperclip App" });
|
||
return projectId;
|
||
}
|
||
|
||
async function seedProjectWorkspace(companyId: string, projectId: string) {
|
||
const projectWorkspaceId = randomUUID();
|
||
await db.insert(projectWorkspaces).values({
|
||
id: projectWorkspaceId,
|
||
companyId,
|
||
projectId,
|
||
name: "Primary workspace",
|
||
});
|
||
return projectWorkspaceId;
|
||
}
|
||
|
||
async function seedExecutionWorkspace(
|
||
companyId: string,
|
||
projectId: string,
|
||
projectWorkspaceId: string | null = null,
|
||
) {
|
||
const executionWorkspaceId = randomUUID();
|
||
await db.insert(executionWorkspaces).values({
|
||
id: executionWorkspaceId,
|
||
companyId,
|
||
projectId,
|
||
projectWorkspaceId,
|
||
mode: "isolated_workspace",
|
||
strategyType: "git_worktree",
|
||
name: `Execution workspace ${executionWorkspaceId}`,
|
||
});
|
||
return executionWorkspaceId;
|
||
}
|
||
|
||
async function seedSummarizer(companyId: string, ready = true) {
|
||
const agentId = randomUUID();
|
||
await db.insert(agents).values({
|
||
id: agentId,
|
||
companyId,
|
||
name: "Summarizer",
|
||
role: "general",
|
||
status: "idle",
|
||
adapterType: "codex_local",
|
||
adapterConfig: ready ? { model: "gpt-5.4" } : {},
|
||
metadata: withBuiltInAgentMarker(null, { key: "summarizer", featureKeys: ["summarizer"] }),
|
||
});
|
||
return agentId;
|
||
}
|
||
|
||
async function seedPlainAgent(companyId: string) {
|
||
const agentId = randomUUID();
|
||
await db.insert(agents).values({
|
||
id: agentId,
|
||
companyId,
|
||
name: "Coder",
|
||
role: "engineer",
|
||
status: "active",
|
||
adapterType: "codex_local",
|
||
adapterConfig: { model: "gpt-5.4" },
|
||
});
|
||
return agentId;
|
||
}
|
||
|
||
async function seedRun(companyId: string, agentId: string) {
|
||
const runId = randomUUID();
|
||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running" });
|
||
return runId;
|
||
}
|
||
|
||
function projectSelector(companyId: string, projectId: string) {
|
||
return { companyId, scopeKind: "project", slotKey: "header", scopeId: projectId };
|
||
}
|
||
|
||
function executionWorkspaceSelector(companyId: string, executionWorkspaceId: string) {
|
||
return {
|
||
companyId,
|
||
scopeKind: "execution_workspace",
|
||
slotKey: "header",
|
||
scopeId: executionWorkspaceId,
|
||
};
|
||
}
|
||
|
||
describe("reads and target visibility", () => {
|
||
it("returns an empty slot state before any generation", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const svc = summarySlotService(db);
|
||
const result = await svc.getSlot(projectSelector(companyId, projectId));
|
||
expect(result).toEqual({ slot: null, document: null, generatingIssue: null });
|
||
});
|
||
|
||
it("rejects targets that do not exist in the company", async () => {
|
||
const companyId = await seedCompany();
|
||
const svc = summarySlotService(db);
|
||
await expect(svc.getSlot(projectSelector(companyId, randomUUID()))).rejects.toMatchObject({
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it("rejects a project owned by another company (company scoping)", async () => {
|
||
const companyId = await seedCompany();
|
||
const otherCompanyId = await seedCompany();
|
||
const foreignProjectId = await seedProject(otherCompanyId);
|
||
const svc = summarySlotService(db);
|
||
await expect(svc.getSlot(projectSelector(companyId, foreignProjectId))).rejects.toMatchObject({
|
||
status: 404,
|
||
});
|
||
});
|
||
|
||
it("rejects an execution workspace owned by another company", async () => {
|
||
const companyId = await seedCompany();
|
||
const otherCompanyId = await seedCompany();
|
||
const otherProjectId = await seedProject(otherCompanyId);
|
||
const foreignExecutionWorkspaceId = await seedExecutionWorkspace(otherCompanyId, otherProjectId);
|
||
const svc = summarySlotService(db);
|
||
|
||
await expect(
|
||
svc.getSlot(executionWorkspaceSelector(companyId, foreignExecutionWorkspaceId)),
|
||
).rejects.toMatchObject({ status: 404 });
|
||
});
|
||
|
||
it("rejects a workspaces_overview selector that carries a scopeId", async () => {
|
||
const companyId = await seedCompany();
|
||
const svc = summarySlotService(db);
|
||
await expect(
|
||
svc.getSlot({ companyId, scopeKind: "workspaces_overview", slotKey: "header", scopeId: randomUUID() }),
|
||
).rejects.toMatchObject({ status: 422 });
|
||
});
|
||
});
|
||
|
||
describe("generate", () => {
|
||
it("fails when the Summarizer built-in is not configured", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const svc = summarySlotService(db);
|
||
await expect(
|
||
svc.generate(projectSelector(companyId, projectId), { userId: "board-user" }),
|
||
).rejects.toMatchObject({ status: 422, details: { code: "summarizer_not_configured" } });
|
||
});
|
||
|
||
it("creates a summarizer task, links it, and marks the slot generating", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const otherProjectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const svc = summarySlotService(db);
|
||
|
||
await db.insert(issues).values([
|
||
{
|
||
companyId,
|
||
projectId,
|
||
identifier: `${issuePrefix(companyId)}-101`,
|
||
issueNumber: 101,
|
||
title: "Waiting on board approval",
|
||
status: "blocked",
|
||
priority: "high",
|
||
},
|
||
{
|
||
companyId,
|
||
projectId,
|
||
identifier: `${issuePrefix(companyId)}-102`,
|
||
issueNumber: 102,
|
||
title: "Implement summary cards",
|
||
status: "in_progress",
|
||
priority: "medium",
|
||
},
|
||
{
|
||
companyId,
|
||
projectId,
|
||
identifier: `${issuePrefix(companyId)}-103`,
|
||
issueNumber: 103,
|
||
title: "Ship the previous summary",
|
||
status: "done",
|
||
priority: "low",
|
||
},
|
||
{
|
||
companyId,
|
||
projectId: otherProjectId,
|
||
identifier: `${issuePrefix(companyId)}-104`,
|
||
issueNumber: 104,
|
||
title: "Other project issue",
|
||
status: "blocked",
|
||
priority: "critical",
|
||
},
|
||
]);
|
||
|
||
const result = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
|
||
|
||
expect(result.alreadyGenerating).toBe(false);
|
||
expect(result.slot.status).toBe("generating");
|
||
expect(result.slot.generatingIssueId).toBe(result.generatingIssue.id);
|
||
|
||
const issueRow = await db
|
||
.select()
|
||
.from(issues)
|
||
.where(eq(issues.id, result.generatingIssue.id))
|
||
.then((rows) => rows[0]!);
|
||
expect(issueRow.assigneeAgentId).toBe(summarizerAgentId);
|
||
expect(issueRow.companyId).toBe(companyId);
|
||
expect(issueRow.title).toMatch(/^Summarize project on \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC$/);
|
||
expect(issueRow.hiddenAt).toBeInstanceOf(Date);
|
||
expect(issueRow.description).toContain(
|
||
'"generationIssueId": "' + result.generatingIssue.id + '"',
|
||
);
|
||
expect(issueRow.description).toContain("Call `/summarize-status`");
|
||
expect(issueRow.description).not.toContain("Follow the Summarizer skill");
|
||
expect(issueRow.description).toContain(
|
||
`GET /api/companies/${companyId}/summary-slots/project/header?scopeId=${projectId}`,
|
||
);
|
||
expect(issueRow.description).not.toContain(
|
||
"do not call the revisions or issues-list endpoints",
|
||
);
|
||
expect(issueRow.description).toContain(
|
||
`PUT /api/companies/${companyId}/summary-slots/project/header`,
|
||
);
|
||
expect(issueRow.description).toContain(
|
||
"opens with the 1–3 specific, concrete, actionable items",
|
||
);
|
||
expect(issueRow.description).toContain("unblock this work");
|
||
expect(issueRow.description).toContain(
|
||
"read whatever issues you need to understand the state",
|
||
);
|
||
expect(issueRow.description).toContain(
|
||
"a reader who has not memorized issue ids or threads",
|
||
);
|
||
expect(issueRow.description).toContain(
|
||
"a trailing list of issue links or any link dump",
|
||
);
|
||
expect(issueRow.description).toContain("Not a task list");
|
||
expect(issueRow.description).toContain(
|
||
"first plain-text `STATUS:` line immediately",
|
||
);
|
||
expect(issueRow.description).toContain("sentinel-wrapped summary draft");
|
||
expect(issueRow.description).toContain("## Prebuilt scope snapshot");
|
||
expect(issueRow.description).toContain("### Blocked");
|
||
expect(issueRow.description).toContain("Waiting on board approval");
|
||
expect(issueRow.description).toContain("### In progress");
|
||
expect(issueRow.description).toContain("Implement summary cards");
|
||
expect(issueRow.description).toContain("### Recently done");
|
||
expect(issueRow.description).toContain("Ship the previous summary");
|
||
expect(issueRow.description).toContain(`/${issuePrefix(companyId)}/issues/`);
|
||
expect(issueRow.description).not.toContain("/PAP/issues/");
|
||
expect(issueRow.description).not.toContain("Other project issue");
|
||
});
|
||
|
||
it("keeps summaries and snapshots isolated between execution workspaces", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const projectWorkspaceId = await seedProjectWorkspace(companyId, projectId);
|
||
const firstExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
|
||
const secondExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
|
||
await seedSummarizer(companyId);
|
||
const svc = summarySlotService(db);
|
||
|
||
await db.insert(issues).values([
|
||
{
|
||
companyId,
|
||
projectId,
|
||
projectWorkspaceId,
|
||
executionWorkspaceId: firstExecutionWorkspaceId,
|
||
identifier: `${issuePrefix(companyId)}-201`,
|
||
issueNumber: 201,
|
||
title: "First workspace task",
|
||
status: "in_progress",
|
||
priority: "medium",
|
||
},
|
||
{
|
||
companyId,
|
||
projectId,
|
||
projectWorkspaceId,
|
||
executionWorkspaceId: secondExecutionWorkspaceId,
|
||
identifier: `${issuePrefix(companyId)}-202`,
|
||
issueNumber: 202,
|
||
title: "Second workspace task",
|
||
status: "blocked",
|
||
priority: "high",
|
||
},
|
||
]);
|
||
|
||
const firstSelector = executionWorkspaceSelector(companyId, firstExecutionWorkspaceId);
|
||
const secondSelector = executionWorkspaceSelector(companyId, secondExecutionWorkspaceId);
|
||
const generated = await svc.generate(firstSelector, { userId: "board-user" });
|
||
|
||
expect(generated.slot).toMatchObject({
|
||
scopeKind: "execution_workspace",
|
||
scopeId: firstExecutionWorkspaceId,
|
||
status: "generating",
|
||
});
|
||
const generationIssue = await db
|
||
.select()
|
||
.from(issues)
|
||
.where(eq(issues.id, generated.generatingIssue.id))
|
||
.then((rows) => rows[0]!);
|
||
expect(generationIssue.description).toContain("First workspace task");
|
||
expect(generationIssue.description).not.toContain("Second workspace task");
|
||
expect(generationIssue.description).toContain('"scopeKind": "execution_workspace"');
|
||
expect(generationIssue.description).toContain(`"scopeId": "${firstExecutionWorkspaceId}"`);
|
||
await expect(svc.getSlot(secondSelector)).resolves.toEqual({
|
||
slot: null,
|
||
document: null,
|
||
generatingIssue: null,
|
||
});
|
||
});
|
||
|
||
it("dedupes concurrent generate clicks without creating an orphan task", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
await seedSummarizer(companyId);
|
||
const svc = summarySlotService(db);
|
||
|
||
const [first, second] = await Promise.all([
|
||
svc.generate(projectSelector(companyId, projectId), { userId: "board-user" }),
|
||
svc.generate(projectSelector(companyId, projectId), { userId: "board-user" }),
|
||
]);
|
||
|
||
expect(second.generatingIssue.id).toBe(first.generatingIssue.id);
|
||
expect([first.alreadyGenerating, second.alreadyGenerating].sort()).toEqual([false, true]);
|
||
|
||
const issueRows = await db.select().from(issues).where(eq(issues.companyId, companyId));
|
||
expect(issueRows).toHaveLength(1);
|
||
});
|
||
|
||
it("creates a fresh task once the previous generation task is terminal", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
await seedSummarizer(companyId);
|
||
const svc = summarySlotService(db);
|
||
|
||
const first = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
|
||
await issueService(db).update(first.generatingIssue.id, { status: "done" });
|
||
|
||
const failed = await svc.getSlot(projectSelector(companyId, projectId));
|
||
expect(failed.slot).toMatchObject({
|
||
status: "failed",
|
||
generatingIssueId: first.generatingIssue.id,
|
||
failureReason: expect.stringContaining("finished without writing a summary"),
|
||
});
|
||
|
||
const second = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
|
||
expect(second.alreadyGenerating).toBe(false);
|
||
expect(second.generatingIssue.id).not.toBe(first.generatingIssue.id);
|
||
expect(second.slot).toMatchObject({
|
||
status: "generating",
|
||
failureReason: null,
|
||
generatingIssueId: second.generatingIssue.id,
|
||
});
|
||
|
||
const issueRows = await db.select().from(issues).where(eq(issues.companyId, companyId));
|
||
expect(issueRows).toHaveLength(2);
|
||
});
|
||
|
||
it("marks the slot failed when its generation task is cancelled without a write", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
await seedSummarizer(companyId);
|
||
const svc = summarySlotService(db);
|
||
|
||
const generated = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
|
||
await issueService(db).update(generated.generatingIssue.id, { status: "cancelled" });
|
||
|
||
const result = await svc.getSlot(projectSelector(companyId, projectId));
|
||
expect(result.slot).toMatchObject({
|
||
status: "failed",
|
||
generatingIssueId: generated.generatingIssue.id,
|
||
failureReason: expect.stringContaining("was cancelled before writing a summary"),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("summarizer writes", () => {
|
||
async function startGeneration(companyId: string, projectId: string, summarizerAgentId: string) {
|
||
const svc = summarySlotService(db);
|
||
const generated = await svc.generate(projectSelector(companyId, projectId), { userId: "board-user" });
|
||
const runId = await seedRun(companyId, summarizerAgentId);
|
||
// Simulate the summarizer run checking out its linked generation task.
|
||
await db.update(issues).set({ checkoutRunId: runId }).where(eq(issues.id, generated.generatingIssue.id));
|
||
return { svc, generationIssueId: generated.generatingIssue.id, runId };
|
||
}
|
||
|
||
it("writes a board-readable revision, preserves the previous revision, and clears the generating state", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
|
||
|
||
const initial = await svc.write(
|
||
{
|
||
...projectSelector(companyId, projectId),
|
||
markdown:
|
||
"Quiet scope — nothing is in flight and nothing is waiting on you. First summary for this scope.\n\n**Next:** nothing needs a decision from you right now; the next thing worth watching is the first issue landing here.",
|
||
model: "cheap-model",
|
||
generationIssueId,
|
||
},
|
||
{ agentId: summarizerAgentId, runId },
|
||
);
|
||
|
||
const nextGeneration = await svc.generate(projectSelector(companyId, projectId), {
|
||
userId: "board-user",
|
||
});
|
||
const nextRunId = await seedRun(companyId, summarizerAgentId);
|
||
await db
|
||
.update(issues)
|
||
.set({ checkoutRunId: nextRunId })
|
||
.where(eq(issues.id, nextGeneration.generatingIssue.id));
|
||
const written = await svc.write(
|
||
{
|
||
...projectSelector(companyId, projectId),
|
||
markdown:
|
||
"**Decide:**\n- The change is done and the review is sitting with you — [T-123](/T/issues/T-123). **I suggest:** approve it, the tests are green.\n\nNothing else moved since last time.",
|
||
baseRevisionId: initial.revision.id,
|
||
generationIssueId: nextGeneration.generatingIssue.id,
|
||
model: "cheap-model",
|
||
},
|
||
{ agentId: summarizerAgentId, runId: nextRunId },
|
||
);
|
||
|
||
expect(written.revision.revisionNumber).toBe(2);
|
||
expect(written.document.body).toMatch(/^\*\*Decide:\*\*[\s\S]*\*\*I suggest:\*\*/m);
|
||
expect(written.document.body).not.toMatch(/^Issues: /m);
|
||
expect(written.slot.status).toBe("idle");
|
||
expect(written.slot.generatingIssueId).toBeNull();
|
||
expect(written.slot.documentId).toBe(written.document.id);
|
||
expect(written.slot.lastGeneratedByAgentId).toBe(summarizerAgentId);
|
||
expect(written.slot.lastModel).toBe("cheap-model");
|
||
|
||
const revisions = await svc.listRevisions(projectSelector(companyId, projectId));
|
||
expect(revisions.revisions).toHaveLength(2);
|
||
expect(revisions.revisions[0]!.id).toBe(written.revision.id);
|
||
expect(revisions.revisions[1]!.id).toBe(initial.revision.id);
|
||
expect(revisions.revisions[1]!.body).toContain("First summary for this scope.");
|
||
});
|
||
|
||
it("returns only the 20 most recent summary revisions", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
|
||
const written = await svc.write(
|
||
{ ...projectSelector(companyId, projectId), markdown: "# Summary v1", generationIssueId },
|
||
{ agentId: summarizerAgentId, runId },
|
||
);
|
||
|
||
await db.insert(documentRevisions).values(
|
||
Array.from({ length: 24 }, (_, index) => ({
|
||
companyId,
|
||
documentId: written.document.id,
|
||
revisionNumber: index + 2,
|
||
body: `# Summary v${index + 2}`,
|
||
})),
|
||
);
|
||
|
||
const revisions = await svc.listRevisions(projectSelector(companyId, projectId));
|
||
expect(revisions.revisions).toHaveLength(20);
|
||
expect(revisions.revisions[0]!.revisionNumber).toBe(25);
|
||
expect(revisions.revisions.at(-1)!.revisionNumber).toBe(6);
|
||
});
|
||
|
||
it("appends further revisions and enforces optimistic baseRevisionId", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
|
||
|
||
const first = await svc.write(
|
||
{ ...projectSelector(companyId, projectId), markdown: "# Summary v1", generationIssueId },
|
||
{ agentId: summarizerAgentId, runId },
|
||
);
|
||
|
||
// A stale baseRevisionId must be rejected.
|
||
const second = await summarySlotService(db).generate(projectSelector(companyId, projectId), {
|
||
userId: "board-user",
|
||
});
|
||
const runId2 = await seedRun(companyId, summarizerAgentId);
|
||
await db.update(issues).set({ checkoutRunId: runId2 }).where(eq(issues.id, second.generatingIssue.id));
|
||
|
||
await expect(
|
||
svc.write(
|
||
{
|
||
...projectSelector(companyId, projectId),
|
||
markdown: "# Summary v2",
|
||
baseRevisionId: randomUUID(),
|
||
generationIssueId: second.generatingIssue.id,
|
||
},
|
||
{ agentId: summarizerAgentId, runId: runId2 },
|
||
),
|
||
).rejects.toMatchObject({ status: 409 });
|
||
|
||
const ok = await svc.write(
|
||
{
|
||
...projectSelector(companyId, projectId),
|
||
markdown: "# Summary v2",
|
||
baseRevisionId: first.revision.id,
|
||
generationIssueId: second.generatingIssue.id,
|
||
},
|
||
{ agentId: summarizerAgentId, runId: runId2 },
|
||
);
|
||
expect(ok.revision.revisionNumber).toBe(2);
|
||
});
|
||
|
||
it("rejects writes from a non-Summarizer agent", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const plainAgentId = await seedPlainAgent(companyId);
|
||
const { svc, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
|
||
|
||
await expect(
|
||
svc.write(
|
||
{ ...projectSelector(companyId, projectId), markdown: "# Sneaky" },
|
||
{ agentId: plainAgentId, runId },
|
||
),
|
||
).rejects.toMatchObject({ status: 403 });
|
||
});
|
||
|
||
it("rejects Summarizer writes that do not run from the linked generation task", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const { generationIssueId } = await startGeneration(companyId, projectId, summarizerAgentId);
|
||
const svc = summarySlotService(db);
|
||
|
||
await expect(
|
||
svc.write(
|
||
{ ...projectSelector(companyId, projectId), markdown: "# Wrong run", generationIssueId },
|
||
{ agentId: summarizerAgentId, runId: randomUUID() },
|
||
),
|
||
).rejects.toMatchObject({ status: 403 });
|
||
});
|
||
|
||
it("rejects using one generation task to write a different slot", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const otherProjectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const { svc, generationIssueId, runId } = await startGeneration(companyId, projectId, summarizerAgentId);
|
||
|
||
await expect(
|
||
svc.write(
|
||
{ ...projectSelector(companyId, otherProjectId), markdown: "# Wrong slot", generationIssueId },
|
||
{ agentId: summarizerAgentId, runId },
|
||
),
|
||
).rejects.toMatchObject({ status: 403 });
|
||
});
|
||
|
||
it("rejects writes when there is no active generation", async () => {
|
||
const companyId = await seedCompany();
|
||
const projectId = await seedProject(companyId);
|
||
const summarizerAgentId = await seedSummarizer(companyId);
|
||
const svc = summarySlotService(db);
|
||
|
||
await expect(
|
||
svc.write(
|
||
{ ...projectSelector(companyId, projectId), markdown: "# No generation" },
|
||
{ agentId: summarizerAgentId, runId: randomUUID() },
|
||
),
|
||
).rejects.toMatchObject({ status: 403 });
|
||
});
|
||
});
|
||
});
|