Files
PaperClipAI/server/src/__tests__/document-annotations-service.test.ts
T
DottaandDev Agent 25cf079ec5 feat(runner): add Codex-native application integration (#12591)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package is useful only when the application can start,
observe, and recover a native Codex run safely.
> - Existing direct adapters must keep their current execution and
finalization paths.
> - The application boundary therefore needs additive persistence,
authorization, coordination, and recovery behind an explicit
experimental adapter.
> - This pull request adds that Codex-only boundary without activating
generalized providers, remote environments, or the later task/SDK
surfaces.

## Linked Issues or Issue Description

**Subsystem affected**

Shared contracts, database persistence, adapter utilities, server
native-runtime services, and the experimental Paperclip Runner adapter.

**Problem or motivation**

The already-landed runner package has a qualified Codex path, but the
application needs durable native-run state, guarded runtime selection,
authenticated coordination, tool security, finalization, and recovery
before the experimental adapter can be exercised safely.

**Proposed solution**

Add a Codex-only `paperclip_runner` application path behind the existing
default-off native-runner setting. Bind native state and coordination to
company/run identity, preserve persisted-run recovery, and leave every
direct adapter on its existing legacy execution path.

**Alternatives considered**

The earlier stack boundary introduced a generalized executor and
remote-environment lifecycle here. That made this PR depend on
implementations in higher PRs and changed reusable sandbox behavior
globally. Those pieces are now deferred together to #12592.

**Roadmap alignment**

ROADMAP.md does not list a conflicting native-runner integration
project. This change adds the application boundary for the existing
Runner architecture.

## What Changed

- Added native run/result/finalization/provider-trace persistence,
shared validators, and idempotent migration/replay coverage.
- Added guarded Codex-only runtime selection, authenticated PRP
coordination, recovery, finalization, and interaction services.
- Added run/company-bound tool-gateway authorization, credential
redaction, SSRF protections, and replay-safe behavior.
- Added the explicit `paperclip_runner` adapter behind the default-off
rollout setting.
- Preserved legacy answered-question wake projection and direct-adapter
execution/finalization paths.
- Hardened cancellation so only owned in-memory child processes are
signaled; persisted recycled PIDs/process groups are never trusted.
- Retained the narrow Claude ACPX isolated-context security follow-up
discovered after #12590.
- Deferred the generalized executor, provider ingress, remote lifecycle,
SDK/lab/eval work, release-process changes, and lockfile.

## Verification

- Changed-file delta against `master`: 133 files.
- GitHub Actions is the authoritative verification environment for this
PR.
- Full CI, security, and Greptile review will run on this lowest
unmerged stack PR.
- Local tests/build/typecheck were not run because this checkout is
resource constrained.
- Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged.

## Risks

- This touches central heartbeat and agent-route code, so legacy
compatibility is the primary risk.
- Runtime selection remains Codex-only and explicit; direct Codex,
Claude, OpenCode, process, HTTP, and plugin adapters remain on their
existing paths.
- Fresh native starts fail closed while the rollout flag is off;
persisted native records remain readable and recoverable.
- Cancellation, company/run binding, tool calls, status decisions, and
completion writes are guarded or replay-safe.

> 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.

## Model Used

OpenAI Codex, GPT-5.6, with repository tools, code execution, and
parallel agent review.

## 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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass — GitHub Actions is
authoritative for this resource-constrained checkout
- [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 risks above
- [ ] All Paperclip CI and security gates are green
- [ ] Greptile is 5/5 with no open actionable findings
- [x] I will address all Greptile and reviewer comments before merge

## Stack

- Position: 3 of 5 overall; lowest of 3 currently unmerged
- Base: `master`
- Previous:
[#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified
Claude ACPX runtime — merged
- Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592),
generalized Codex executor, task experience, and developer SDKs

---------

Co-authored-by: Dev Agent <dev@paperclip.ing>
2026-08-31 14:38:38 -05:00

859 lines
27 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
companies,
createDb,
documentAnnotationAnchorSnapshots,
documentAnnotationComments,
documentAnnotationThreads,
documentRevisions,
documents,
issueComments,
issueDocuments,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { documentAnnotationService } from "../services/document-annotations.js";
import { documentService } from "../services/documents.js";
import { buildPaperclipWakePayload } from "../services/heartbeat.js";
import { buildDocumentReviewContext, buildPlanReviewContext, PLAN_REVIEW_CONTEXT_LIMITS } from "../services/plan-review-context.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres document annotation service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describeEmbeddedPostgres("documentAnnotationService", () => {
let db!: ReturnType<typeof createDb>;
let annotations!: ReturnType<typeof documentAnnotationService>;
let docs!: ReturnType<typeof documentService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-document-annotations-");
db = createDb(tempDb.connectionString);
annotations = documentAnnotationService(db);
docs = documentService(db);
}, 20_000);
afterEach(async () => {
await db.delete(documentAnnotationAnchorSnapshots);
await db.delete(documentAnnotationComments);
await db.delete(documentAnnotationThreads);
await db.delete(issueThreadInteractions);
await db.delete(documentRevisions);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createIssueWithDocument(workMode: "planning" | "standard" = "planning") {
const companyId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
title: "Annotation race",
description: "Validate annotation revision guards",
status: "in_progress",
workMode,
priority: "high",
});
const created = await docs.upsertIssueDocument({
issueId,
key: "plan",
title: "Plan",
format: "markdown",
body: "Alpha selected text omega",
});
return { companyId, issueId, document: created.document };
}
it("fails closed when a concurrent document update wins before annotation thread creation commits", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const concurrentUpdateCanCommit = deferred<void>();
const concurrentUpdateHasWritten = deferred<void>();
const concurrentUpdate = db.transaction(async (tx) => {
const now = new Date();
const [revision] = await tx
.insert(documentRevisions)
.values({
companyId,
documentId: document.id,
revisionNumber: document.latestRevisionNumber + 1,
title: "Plan",
format: "markdown",
body: "Alpha changed text omega",
changeSummary: "Concurrent edit",
createdAt: now,
})
.returning();
await tx
.update(documents)
.set({
latestBody: "Alpha changed text omega",
latestRevisionId: revision.id,
latestRevisionNumber: document.latestRevisionNumber + 1,
updatedAt: now,
})
.where(eq(documents.id, document.id));
concurrentUpdateHasWritten.resolve();
await concurrentUpdateCanCommit.promise;
});
await concurrentUpdateHasWritten.promise;
let annotationSettled = false;
const annotationResult = annotations
.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Please review this text",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
)
.then(
() => ({ status: "fulfilled" as const }),
(error: unknown) => ({ status: "rejected" as const, error }),
)
.finally(() => {
annotationSettled = true;
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(annotationSettled).toBe(false);
concurrentUpdateCanCommit.resolve();
await concurrentUpdate;
const result = await annotationResult;
expect(result.status).toBe("rejected");
if (result.status === "rejected") {
expect(result.error).toMatchObject({
status: 409,
message: "Annotation anchor requires the current document revision",
details: {
currentRevisionNumber: 2,
},
});
}
const threads = await db.select().from(documentAnnotationThreads);
expect(threads).toHaveLength(0);
});
it("removes linked annotation comments and resolves empty threads when an issue comment is deleted", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const [issueComment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "board-user",
body: "Delete this linked comment",
})
.returning();
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Linked annotation body",
issueCommentId: issueComment.id,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const cleanup = await annotations.cleanupForIssueCommentDeletion(
issueId,
issueComment.id,
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
expect(cleanup.deletedCommentIds).toEqual([thread.comments[0]!.id]);
expect(cleanup.resolvedThreadIds).toEqual([thread.id]);
await expect(
db.select().from(documentAnnotationComments).where(eq(documentAnnotationComments.id, thread.comments[0]!.id)),
).resolves.toHaveLength(0);
const [updatedThread] = await db
.select()
.from(documentAnnotationThreads)
.where(eq(documentAnnotationThreads.id, thread.id));
expect(updatedThread?.status).toBe("resolved");
expect(updatedThread?.resolvedByUserId).toBe("board-user");
});
it("rejects annotation comments linked to already-deleted issue comments", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const [issueComment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "board-user",
body: "",
deletedAt: new Date("2026-06-05T03:00:00.000Z"),
deletedByType: "user",
deletedByUserId: "board-user",
})
.returning();
await expect(
annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Do not link this annotation to a deleted comment",
issueCommentId: issueComment.id,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
),
).rejects.toMatchObject({
status: 422,
message: "Linked issue comment must belong to this issue",
});
await expect(db.select().from(documentAnnotationComments)).resolves.toHaveLength(0);
});
it("does not report already-resolved empty threads as newly resolved during linked comment cleanup", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const [issueComment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorType: "user",
authorUserId: "board-user",
body: "Delete this linked comment from a resolved thread",
})
.returning();
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Linked annotation body",
issueCommentId: issueComment.id,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
await db
.update(documentAnnotationThreads)
.set({ status: "resolved", resolvedByUserId: "board-user", resolvedAt: new Date("2026-06-05T03:05:00.000Z") })
.where(eq(documentAnnotationThreads.id, thread.id));
const cleanup = await annotations.cleanupForIssueCommentDeletion(
issueId,
issueComment.id,
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
expect(cleanup.deletedCommentIds).toEqual([thread.comments[0]!.id]);
expect(cleanup.resolvedThreadIds).toEqual([]);
});
it("builds compact open plan review context and excludes resolved threads", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const longBody = "x".repeat(PLAN_REVIEW_CONTEXT_LIMITS.maxBodyChars + 25);
const openThread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: longBody,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const resolvedThread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Already resolved",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
await db
.update(documentAnnotationThreads)
.set({
status: "resolved",
anchorState: "stale",
anchorConfidence: "fuzzy",
resolvedByUserId: "board-user",
resolvedAt: new Date("2026-06-05T03:05:00.000Z"),
})
.where(eq(documentAnnotationThreads.id, resolvedThread.id));
const context = await buildPlanReviewContext({
db,
companyId,
issueId,
issueWorkMode: "planning",
});
expect(context).toMatchObject({
documentKey: "plan",
issueId,
latestRevisionId: document.latestRevisionId,
latestRevisionNumber: document.latestRevisionNumber,
totals: {
openThreadCount: 1,
includedThreadCount: 1,
omittedThreadCount: 0,
commentCount: 1,
includedCommentCount: 1,
omittedCommentCount: 0,
},
truncated: true,
});
expect(context?.threads.map((thread) => thread.id)).toEqual([openThread.id]);
expect(context?.threads[0]).toMatchObject({
status: "open",
anchorState: "active",
anchorConfidence: "exact",
selectedText: "selected text",
prefixText: "Alpha ",
suffixText: " omega",
comments: [
expect.objectContaining({
body: "x".repeat(PLAN_REVIEW_CONTEXT_LIMITS.maxBodyChars),
bodyTruncated: true,
author: { type: "user", id: "board-user" },
}),
],
});
});
it("infers an omitted target issue id while rejecting cross-issue interaction context", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const otherIssueId = randomUUID();
await db.insert(issues).values({
id: otherIssueId,
companyId,
identifier: "PAP-9443",
title: "Other planning task",
description: null,
status: "in_progress",
workMode: "planning",
priority: "medium",
});
const [interaction] = await db
.insert(issueThreadInteractions)
.values({
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
documentId: document.id,
key: "plan",
revisionId: document.latestRevisionId,
revisionNumber: document.latestRevisionNumber,
},
},
result: {
version: 1,
outcome: "accepted",
reason: null,
},
resolvedAt: new Date("2026-06-05T03:10:00.000Z"),
})
.returning();
const context = await buildPlanReviewContext({
db,
companyId,
issueId,
issueWorkMode: "standard",
interactionId: interaction.id,
});
expect(context?.interaction).toMatchObject({
id: interaction.id,
status: "accepted",
target: {
issueId,
documentId: document.id,
key: "plan",
revisionId: document.latestRevisionId,
revisionNumber: document.latestRevisionNumber,
},
acceptedTargetRevision: {
revisionId: document.latestRevisionId,
revisionNumber: document.latestRevisionNumber,
},
result: {
outcome: "accepted",
reason: null,
},
});
await expect(buildPlanReviewContext({
db,
companyId,
issueId: otherIssueId,
issueWorkMode: "standard",
interactionId: interaction.id,
})).resolves.toBeNull();
});
it("includes open plan annotations for standard-mode issue comment wakes", async () => {
const { companyId, issueId, document } = await createIssueWithDocument("standard");
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Please incorporate this plan annotation in standard mode.",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const [comment] = await db
.insert(issueComments)
.values({
companyId,
issueId,
authorUserId: "board-user",
body: "Please continue with the plan feedback above.",
})
.returning();
const payload = await buildPaperclipWakePayload({
db,
companyId,
contextSnapshot: {
issueId,
wakeCommentIds: [comment.id],
wakeReason: "issue_commented",
},
});
expect(payload?.comments).toMatchObject([
expect.objectContaining({
id: comment.id,
body: "Please continue with the plan feedback above.",
}),
]);
expect(payload?.planReviewContext).toMatchObject({
issueId,
totals: {
openThreadCount: 1,
includedCommentCount: 1,
},
threads: [
expect.objectContaining({
id: thread.id,
comments: [
expect.objectContaining({
id: thread.comments[0]!.id,
body: "Please incorporate this plan annotation in standard mode.",
}),
],
}),
],
});
});
it("includes accepted plan annotations in the structured wake payload", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Please split this plan step before creating child issues.",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const [interaction] = await db
.insert(issueThreadInteractions)
.values({
companyId,
issueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
issueId,
documentId: document.id,
key: "plan",
revisionId: document.latestRevisionId,
revisionNumber: document.latestRevisionNumber,
},
},
result: {
version: 1,
outcome: "accepted",
},
resolvedAt: new Date("2026-06-05T03:10:00.000Z"),
})
.returning();
const payload = await buildPaperclipWakePayload({
db,
companyId,
contextSnapshot: {
issueId,
interactionId: interaction.id,
interactionKind: "request_confirmation",
interactionStatus: "accepted",
wakeReason: "issue_commented",
},
});
expect(payload?.planReviewContext).toMatchObject({
interaction: {
id: interaction.id,
status: "accepted",
acceptedTargetRevision: {
issueId,
documentId: document.id,
key: "plan",
revisionId: document.latestRevisionId,
revisionNumber: document.latestRevisionNumber,
},
result: {
outcome: "accepted",
},
},
totals: {
openThreadCount: 1,
includedCommentCount: 1,
},
threads: [
expect.objectContaining({
id: thread.id,
selectedText: "selected text",
comments: [
expect.objectContaining({
body: "Please split this plan step before creating child issues.",
}),
],
}),
],
});
});
it("fails closed when an annotation delta comment id points at a different issue", async () => {
const { companyId, issueId, document } = await createIssueWithDocument("standard");
const { issueId: otherIssueId, document: otherDocument } = await createIssueWithDocument("standard");
const otherThread = await annotations.createThread(
otherIssueId,
"plan",
{
baseRevisionId: otherDocument.latestRevisionId!,
baseRevisionNumber: otherDocument.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Different issue annotation comment.",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const payload = await buildPaperclipWakePayload({
db,
companyId,
contextSnapshot: {
issueId,
annotationCommentId: otherThread.comments[0]!.id,
wakeReason: "issue_commented",
},
});
expect(payload?.annotationDeltas).toEqual([]);
expect(payload?.planReviewContext).toBeNull();
});
it("includes plan review context for same-issue annotation deltas on standard issues", async () => {
const { companyId, issueId, document } = await createIssueWithDocument("standard");
const thread = await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "Direct same-issue annotation comment.",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const payload = await buildPaperclipWakePayload({
db,
companyId,
contextSnapshot: {
issueId,
annotationCommentId: thread.comments[0]!.id,
wakeReason: "issue_commented",
},
});
expect(payload?.annotationDeltas).toMatchObject([
expect.objectContaining({
id: thread.comments[0]!.id,
issueId,
threadId: thread.id,
body: "Direct same-issue annotation comment.",
}),
]);
expect(payload?.planReviewContext).toMatchObject({
issueId,
totals: {
openThreadCount: 1,
includedCommentCount: 1,
},
threads: [
expect.objectContaining({
id: thread.id,
comments: [
expect.objectContaining({
id: thread.comments[0]!.id,
body: "Direct same-issue annotation comment.",
}),
],
}),
],
});
});
it("includes rejection result with an omitted target issue id even when the reason is empty", async () => {
const { companyId, issueId, document } = await createIssueWithDocument();
await annotations.createThread(
issueId,
"plan",
{
baseRevisionId: document.latestRevisionId!,
baseRevisionNumber: document.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: "The plan needs a concrete QA owner.",
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
const [interaction] = await db
.insert(issueThreadInteractions)
.values({
companyId,
issueId,
kind: "request_confirmation",
status: "rejected",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Approve this plan?",
target: {
type: "issue_document",
documentId: document.id,
key: "plan",
revisionId: document.latestRevisionId,
revisionNumber: document.latestRevisionNumber,
},
},
result: {
version: 1,
outcome: "rejected",
reason: "",
},
resolvedAt: new Date("2026-06-05T03:10:00.000Z"),
})
.returning();
const payload = await buildPaperclipWakePayload({
db,
companyId,
contextSnapshot: {
issueId,
interactionId: interaction.id,
interactionKind: "request_confirmation",
interactionStatus: "rejected",
wakeReason: "issue_commented",
},
});
expect(payload?.planReviewContext).toMatchObject({
interaction: {
id: interaction.id,
status: "rejected",
result: {
outcome: "rejected",
reason: null,
},
},
totals: {
openThreadCount: 1,
includedCommentCount: 1,
},
threads: [
expect.objectContaining({
selectedText: "selected text",
comments: [
expect.objectContaining({
body: "The plan needs a concrete QA owner.",
}),
],
}),
],
});
});
it("groups non-plan annotations by most recently updated document and applies global caps", async () => {
const { companyId, issueId } = await createIssueWithDocument("standard");
const older = (await docs.upsertIssueDocument({
issueId,
key: "qa-evidence",
title: "QA evidence",
format: "markdown",
body: "Alpha selected text omega",
})).document;
const newer = (await docs.upsertIssueDocument({
issueId,
key: "run-summary",
title: "Run summary",
format: "markdown",
body: "Alpha selected text omega",
})).document;
await db.update(issueDocuments)
.set({ updatedAt: new Date("2026-06-01T00:00:00.000Z") })
.where(eq(issueDocuments.documentId, older.id));
await db.update(issueDocuments)
.set({ updatedAt: new Date("2026-06-02T00:00:00.000Z") })
.where(eq(issueDocuments.documentId, newer.id));
for (let index = 0; index < PLAN_REVIEW_CONTEXT_LIMITS.maxThreads + 1; index += 1) {
await annotations.createThread(
issueId,
index === 0 ? "run-summary" : "qa-evidence",
{
baseRevisionId: index === 0 ? newer.latestRevisionId! : older.latestRevisionId!,
baseRevisionNumber: index === 0 ? newer.latestRevisionNumber : older.latestRevisionNumber,
selector: {
quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" },
position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 },
},
body: `Annotation ${index}`,
},
{ actorType: "user", actorId: "board-user", userId: "board-user" },
);
}
const context = await buildDocumentReviewContext({
db,
companyId,
issueId,
includeForIssueComment: true,
});
expect(context?.documents.map((document) => document.documentKey)).toEqual(["run-summary", "qa-evidence"]);
expect(context?.totals).toMatchObject({
openThreadCount: PLAN_REVIEW_CONTEXT_LIMITS.maxThreads + 1,
includedThreadCount: PLAN_REVIEW_CONTEXT_LIMITS.maxThreads,
omittedThreadCount: 1,
});
expect(context?.documents[1]).toMatchObject({ truncated: true });
expect(context?.truncated).toBe(true);
});
});