Files
PaperClipAI/server/src/__tests__/summary-slots.test.ts
T
DottaandPaperclip b58ce27a02 fix: isolate execution workspace summaries (#10790)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip gives operators a summary for each workspace.
> - An execution workspace detail page used the parent project-workspace
summary slot.
> - Two execution workspaces under one project workspace could therefore
show the same summary.
> - This pull request gives each execution workspace its own summary
scope.
> - It also limits the summary snapshot and generated issue to that
execution workspace.
> - The benefit is that a new or parallel execution workspace cannot
inherit unrelated status.

## Linked Issues or Issue Description

**What happened?**

An execution workspace detail page read and refreshed the summary slot
for its parent project workspace. Parallel execution workspaces could
show the same status and include issues from each other.

**Expected behavior**

Each execution workspace must have one isolated summary slot. Its
generated snapshot must include only issues assigned to that execution
workspace.

**Steps to reproduce**

1. Create two execution workspaces under one project workspace.
2. Add different issues to each execution workspace.
3. Generate the summary in the first execution workspace.
4. Open the second execution workspace.
5. Observe that the old implementation could reuse the first summary.

**Paperclip version or commit**

The problem exists on `master` before this pull request.

**Deployment mode**

The issue affects both local trusted and authenticated deployments.

## What Changed

- Added `execution_workspace` to the shared summary-slot scope contract.
- Validated execution-workspace ownership and stored generated summary
issues on the correct execution workspace.
- Limited execution-workspace snapshots to issues with the matching
execution workspace ID.
- Updated the execution workspace page to use its own summary slot.
- Updated Summarizer instructions, routine options, catalog metadata,
documentation, and regression tests.

## Verification

- `NODE_ENV=test pnpm exec vitest run
packages/shared/src/summary-slot.test.ts
server/src/__tests__/summary-slots.test.ts
ui/src/pages/ExecutionWorkspaceDetail.test.tsx` — 30 focused tests
passed; the embedded-Postgres server tests were run outside the
process-restricted sandbox.
- `pnpm check:token-gates` — passed.
- `pnpm --filter @paperclipai/skills-catalog validate` — passed with 17
catalog skills.
- [Latest-head GitHub
Actions](https://github.com/paperclipai/paperclip/actions/runs/31491475405)
— all 22 jobs passed on `beea14cbaf`, including typecheck, build,
server/workspace tests, serialized suites, e2e, canary, and aggregate
verification. One unrelated adapter cleanup test initially hit an
`ENOTEMPTY` temp-directory race; its single permitted rerun passed.
- Greptile — 5/5 confidence on `beea14cbaf`, 12 files reviewed, zero
comments added, and zero unresolved threads.

## Risks

- Low risk. The new scope is additive.
- Existing project and project-workspace summary slots keep their
current keys and behavior.
- A summary generated for an execution workspace now excludes sibling
workspace issues by design.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with GPT-5. The deployment does not expose a more
specific model ID or context-window value. It used agentic reasoning,
repository tools, code execution, and GitHub tooling.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-11 08:56:32 -04:00

637 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});
});
});