mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Reviewers annotate plans and issue documents with inline comments, and assigned agents act on that feedback > - The server already builds a bounded review context from open plan annotations and includes it in agent wake payloads > - Non-plan issue documents did not get the same treatment: their open annotation threads never reached the agent, and the properties pane did not surface their annotations > - This pull request extends the review-context path and the properties-pane UI to issue documents, at parity with plans > - The benefit is that agent feedback on any issue document reaches the assigned agent, not only feedback on the plan ## Linked Issues or Issue Description **What existing behavior does this improve?** The review-context pipeline that delivers inline annotation feedback to assigned agents, and the properties pane that surfaces those annotations to reviewers. **Subsystem affected** The server review-context path (`server/src/services/plan-review-context.ts`, wake payload assembly in `server/src/services/heartbeat.ts`, `server/src/routes/issues.ts`), shared wake-payload types (`packages/shared`, `packages/adapter-utils`), and the issue properties pane (`ui/src/components/issue-properties/`). **Current behavior** A reviewer can annotate any issue document, not only the plan. The agent wake payload includes open annotation threads for the plan document only. Feedback left on other issue documents is invisible to the assigned agent. In the properties pane, the Artifacts tab also gives no way to see or open a document's annotations. **Proposed behavior** Add `buildDocumentReviewContext` beside the existing plan builder. It collects open annotation threads for all non-plan issue documents, applies the same thread, comment, and character budgets across documents, and reports truncation. Include the result as a new `documentReviewContext` field in agent wake payloads and in the issue wake-context route. Keep the plan context on its legacy builder and field so plan-only wakes stay byte-for-byte compatible. Render the new context in the adapter wake-payload text, and surface annotation counts and the annotation panel for documents in the properties pane's Plans and Artifacts tabs. **Reason and benefit** The floating annotation popover and persistent highlight UI landed earlier; this change completes the loop so agent feedback on any issue document reaches the assigned agent, not only feedback on the plan. **Breaking changes** None. The wake payload gains a new optional `documentReviewContext` field; the existing plan context field and its legacy builder are unchanged, so plan-only wakes stay byte-for-byte compatible. ## What Changed - Add `buildDocumentReviewContext` in `server/src/services/plan-review-context.ts`: bounded review context (shared thread/comment/character budgets, per-document legacy limits) over all non-plan issue documents - Include `documentReviewContext` in agent wake payloads (`server/src/services/heartbeat.ts`) and in the issue wake-context response (`server/src/routes/issues.ts`) - Add shared `DocumentReviewContext` / `DocumentReviewContextDocument` types in `packages/shared` - Normalize and render the new context in adapter wake-payload text (`packages/adapter-utils/src/server-utils.ts`), with tests - Show a `DocumentAnnotationsCountChip` and the annotation panel for documents in the properties pane Plans and Artifacts tabs, with tests - Extend server document-annotations service tests to cover the new context builder ## Verification - Run `npx vitest run packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/document-annotations-service.test.ts` from the repo root — 104 tests pass - Run `TZ=UTC npx vitest run ui/src/components/issue-properties/IssuePropertiesDocumentAnnotations.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/IssueDocumentAnnotations.test.tsx ui/src/components/DocumentAnnotationPopover.test.tsx` from the repo root — 75 tests pass (one pre-existing monitor-row case asserts UTC timestamps, so use `TZ=UTC` locally; CI runs in UTC) - `pnpm run typecheck` in `server/` passes - Manual: annotate a non-plan issue document, then wake the assigned agent with a comment — the wake payload lists the open document annotation threads; the Artifacts tab shows the annotation count chip and opens the panel ## Risks - The wake payload gains a new optional `documentReviewContext` field; consumers that ignore unknown fields are unaffected, and the plan context field is unchanged - The context is new input to agent wakes; shared budgets (same limits as the plan context) bound token cost across all documents - Low UI risk: the properties-pane changes reuse the existing annotation components > 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 - Claude (Anthropic), model ID `claude-fable-5` (Claude Fable 5), with extended thinking and agentic tool use (Claude Code harness) ## 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>
861 lines
27 KiB
TypeScript
861 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("includes same-issue plan confirmation target/result and rejects 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",
|
|
issueId,
|
|
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 and open plan annotations 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",
|
|
issueId,
|
|
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);
|
|
});
|
|
});
|