Files
PaperClipAI/server/src/services/plan-review-context.ts
T
DottaandPaperclip 5e3d6e3627 [codex] Preserve plan review context in agent wakes (#8649)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Planning work relies on issue documents, request-confirmation
interactions, and inline plan annotations
> - Agents can be woken after a plan comment, annotation, or
confirmation decision
> - The wake payload needs enough plan-review context for the agent to
act on the specific feedback instead of losing the thread and falling
back to broad refetches
> - This pull request adds bounded plan-review context to wake payloads
and heartbeat context
> - It also teaches the adapter wake prompt renderer to surface those
open plan annotations and interaction results directly
> - The benefit is that agents can continue plan review and plan
acceptance flows with the relevant comments in hand while keeping wake
payloads bounded and company-scoped

## Linked Issues or Issue Description

No matching public GitHub issue was found.

### Subsystem affected

Cross-cutting: `server/`, `packages/shared`, and
`packages/adapter-utils`.

### Problem or motivation

Plan-review continuations can wake an agent after a plan comment, inline
annotation, or request-confirmation decision without enough inline
context about the open plan annotations or accepted/rejected
confirmation target. That makes scoped wakes less reliable because the
agent may need to refetch broad issue history before it can tell what
feedback should be incorporated.

### Proposed solution

Include bounded, company-scoped plan review context in wake payloads and
heartbeat context. The context includes open `plan` annotation threads,
recent annotation comments, truncation metadata, and plan-confirmation
interaction target/result details. Render that information in the
adapter wake prompt so agents see the relevant plan-review feedback
immediately.

### Alternatives considered

Relying on agents to fetch the full issue thread after every plan-review
wake was rejected because it is slower, harder to audit, and easier to
mishandle when the wake is meant to be scoped to a specific comment,
annotation, or interaction result.

### Roadmap alignment

This supports the roadmap areas for Agent Reviews and Approvals, Deep
Planning, and Enforced Outcomes by making plan approval continuations
explicit and actionable.

### Additional context

The implementation keeps payload size bounded with per-thread,
per-comment, and total-body limits. Resolved annotation threads are
intentionally omitted so the wake focuses on feedback still needing
action.

## What Changed

- Added shared `PlanReviewContext` types for plan annotation threads,
comments, interaction targets, and continuation results.
- Added server-side plan review context assembly for open `plan`
annotation threads with bounded thread/comment/body limits.
- Included plan review context in heartbeat context and scoped wake
payloads for planning, annotation, comment, and plan-confirmation
interaction wakes.
- Rendered plan annotation deltas, open plan comments, interaction
results, and accepted target revisions in adapter wake prompts.
- Added focused regression coverage for scoped plan review context, wake
prompt rendering, annotation filtering, and safe standard-mode
annotation wakes.
- Addressed Greptile feedback by bounding the plan-comment DB fetch and
removing unused plan review context input fields.

## Verification

- `pnpm run preflight:workspace-links`
- `pnpm exec vitest run --project @paperclipai/adapter-utils
packages/adapter-utils/src/server-utils.test.ts`
- `pnpm exec vitest run --project @paperclipai/server
--no-file-parallelism --maxWorkers=1
server/src/__tests__/document-annotations-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issues-goal-context-routes.test.ts`
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `git diff --check public-gh/master...HEAD`
- GitHub PR checks are green on
`36d0ac6a5dce27b9d62e201bf6d9829170c5974e`n- Rebased onto current
`paperclipai/paperclip:master` and confirmed GitHub reports the PR as
mergeable
- Greptile Review completed successfully after 2 comments were addressed
and resolved; 0 unresolved review threads remain

## Risks

- Medium: wake payloads now include additional plan-review data, so
limits and truncation behavior need to stay conservative as annotation
volume grows.
- Low migration risk: no database schema or migration changes.
- Low repository hygiene risk: this PR does not touch `pnpm-lock.yaml`,
`.github/workflows`, or media assets.

> 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 using `gpt-5` as a coding agent with shell/tool execution.
Reasoning mode and exact context window were not exposed by the runtime.

## 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-06-29 13:26:26 -05:00

331 lines
12 KiB
TypeScript

import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
documentAnnotationComments,
documentAnnotationThreads,
documents,
issueDocuments,
issueThreadInteractions,
} from "@paperclipai/db";
import type {
PlanReviewContext,
PlanReviewContextAuthor,
PlanReviewInteractionContext,
PlanReviewInteractionResultContext,
PlanReviewInteractionTargetContext,
} from "@paperclipai/shared";
import { parseObject } from "../adapters/utils.js";
export const PLAN_REVIEW_CONTEXT_LIMITS = {
maxThreads: 20,
maxComments: 80,
maxBodyChars: 1_200,
maxTotalBodyChars: 12_000,
maxAnchorTextChars: 500,
} as const;
type BuildPlanReviewContextInput = {
db: Db;
companyId: string;
issueId: string;
issueWorkMode?: string | null;
includeForIssueComment?: boolean;
includeForAnnotationDelta?: boolean;
interactionId?: string | null;
};
function nonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function truncateText(value: string, maxChars: number) {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function authorFrom(row: {
authorType?: string | null;
authorAgentId?: string | null;
authorUserId?: string | null;
}): PlanReviewContextAuthor {
if (row.authorAgentId) return { type: "agent", id: row.authorAgentId };
if (row.authorUserId) return { type: "user", id: row.authorUserId };
return {
type: row.authorType === "agent" || row.authorType === "user" || row.authorType === "system"
? row.authorType
: "system",
id: null,
};
}
function readPlanTarget(value: unknown, issueId: string): PlanReviewInteractionTargetContext | null {
const target = parseObject(value);
if (target.type !== "issue_document") return null;
if (target.key !== "plan") return null;
if (nonEmptyString(target.issueId) !== issueId) return null;
return {
issueId,
documentId: nonEmptyString(target.documentId),
key: "plan",
revisionId: nonEmptyString(target.revisionId),
revisionNumber: typeof target.revisionNumber === "number" ? target.revisionNumber : null,
};
}
function readResult(value: unknown): PlanReviewInteractionResultContext | null {
const result = parseObject(value);
if (Object.keys(result).length === 0) return null;
return {
outcome: nonEmptyString(result.outcome),
reason: nonEmptyString(result.reason) ?? nonEmptyString(result.rejectionReason),
commentId: nonEmptyString(result.commentId),
};
}
async function getPlanInteractionContext(input: {
db: Db;
companyId: string;
issueId: string;
interactionId: string | null;
}): Promise<PlanReviewInteractionContext | null> {
if (!input.interactionId) return null;
const row = await input.db
.select({
id: issueThreadInteractions.id,
kind: issueThreadInteractions.kind,
status: issueThreadInteractions.status,
continuationPolicy: issueThreadInteractions.continuationPolicy,
sourceCommentId: issueThreadInteractions.sourceCommentId,
sourceRunId: issueThreadInteractions.sourceRunId,
payload: issueThreadInteractions.payload,
result: issueThreadInteractions.result,
resolvedAt: issueThreadInteractions.resolvedAt,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.id, input.interactionId),
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const payload = parseObject(row.payload);
const target = readPlanTarget(payload.target, input.issueId);
if (!target) return null;
const result = readResult(row.result);
return {
id: row.id,
kind: row.kind,
status: row.status,
continuationPolicy: row.continuationPolicy,
sourceCommentId: row.sourceCommentId ?? null,
sourceRunId: row.sourceRunId ?? null,
target,
acceptedTargetRevision: row.status === "accepted" ? target : null,
result,
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
};
}
export async function buildPlanReviewContext(input: BuildPlanReviewContextInput): Promise<PlanReviewContext | null> {
const interaction = await getPlanInteractionContext({
db: input.db,
companyId: input.companyId,
issueId: input.issueId,
interactionId: nonEmptyString(input.interactionId),
});
const shouldInclude =
input.issueWorkMode === "planning" ||
input.includeForIssueComment === true ||
input.includeForAnnotationDelta === true ||
interaction !== null;
if (!shouldInclude) return null;
const planDocument = await input.db
.select({
documentId: documents.id,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(
eq(issueDocuments.companyId, input.companyId),
eq(issueDocuments.issueId, input.issueId),
eq(issueDocuments.key, "plan"),
eq(documents.companyId, input.companyId),
))
.then((rows) => rows[0] ?? null);
if (!planDocument) return null;
const [{ count: openThreadCount }] = await input.db
.select({ count: sql<number>`count(*)::int` })
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, input.issueId),
eq(documentAnnotationThreads.documentId, planDocument.documentId),
eq(documentAnnotationThreads.documentKey, "plan"),
eq(documentAnnotationThreads.status, "open"),
));
const threadRows = await input.db
.select({
id: documentAnnotationThreads.id,
documentId: documentAnnotationThreads.documentId,
documentKey: documentAnnotationThreads.documentKey,
status: documentAnnotationThreads.status,
revisionId: documentAnnotationThreads.currentRevisionId,
revisionNumber: documentAnnotationThreads.currentRevisionNumber,
anchorState: documentAnnotationThreads.anchorState,
anchorConfidence: documentAnnotationThreads.anchorConfidence,
selectedText: documentAnnotationThreads.selectedText,
prefixText: documentAnnotationThreads.prefixText,
suffixText: documentAnnotationThreads.suffixText,
createdByAgentId: documentAnnotationThreads.createdByAgentId,
createdByUserId: documentAnnotationThreads.createdByUserId,
createdAt: documentAnnotationThreads.createdAt,
updatedAt: documentAnnotationThreads.updatedAt,
})
.from(documentAnnotationThreads)
.where(and(
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, input.issueId),
eq(documentAnnotationThreads.documentId, planDocument.documentId),
eq(documentAnnotationThreads.documentKey, "plan"),
eq(documentAnnotationThreads.status, "open"),
))
.orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id))
.limit(PLAN_REVIEW_CONTEXT_LIMITS.maxThreads);
const threadIds = threadRows.map((thread) => thread.id);
const commentRows = threadIds.length === 0
? []
: await input.db
.select({
id: documentAnnotationComments.id,
threadId: documentAnnotationComments.threadId,
body: documentAnnotationComments.body,
authorType: documentAnnotationComments.authorType,
authorAgentId: documentAnnotationComments.authorAgentId,
authorUserId: documentAnnotationComments.authorUserId,
createdAt: documentAnnotationComments.createdAt,
updatedAt: documentAnnotationComments.updatedAt,
})
.from(documentAnnotationComments)
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.issueId, input.issueId),
eq(documentAnnotationComments.documentId, planDocument.documentId),
inArray(documentAnnotationComments.threadId, threadIds),
))
.orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id))
.limit(PLAN_REVIEW_CONTEXT_LIMITS.maxComments);
const [{ count: commentCount }] = await input.db
.select({ count: sql<number>`count(*)::int` })
.from(documentAnnotationComments)
.innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id))
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.issueId, input.issueId),
eq(documentAnnotationComments.documentId, planDocument.documentId),
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, input.issueId),
eq(documentAnnotationThreads.documentId, planDocument.documentId),
eq(documentAnnotationThreads.documentKey, "plan"),
eq(documentAnnotationThreads.status, "open"),
));
const commentsByThread = new Map<string, typeof commentRows>();
for (const comment of commentRows) {
const existing = commentsByThread.get(comment.threadId) ?? [];
existing.push(comment);
commentsByThread.set(comment.threadId, existing);
}
let remainingBodyChars = PLAN_REVIEW_CONTEXT_LIMITS.maxTotalBodyChars;
let includedCommentCount = 0;
let truncated = openThreadCount > threadRows.length;
const threads = threadRows.map((thread) => {
const selectedText = truncateText(thread.selectedText, PLAN_REVIEW_CONTEXT_LIMITS.maxAnchorTextChars);
const prefixText = truncateText(thread.prefixText, PLAN_REVIEW_CONTEXT_LIMITS.maxAnchorTextChars);
const suffixText = truncateText(thread.suffixText, PLAN_REVIEW_CONTEXT_LIMITS.maxAnchorTextChars);
if (selectedText.truncated || prefixText.truncated || suffixText.truncated) truncated = true;
const threadComments = commentsByThread.get(thread.id) ?? [];
const comments = [];
for (const comment of threadComments) {
if (includedCommentCount >= PLAN_REVIEW_CONTEXT_LIMITS.maxComments || remainingBodyChars <= 0) {
truncated = true;
break;
}
const allowedChars = Math.min(PLAN_REVIEW_CONTEXT_LIMITS.maxBodyChars, remainingBodyChars);
const body = truncateText(comment.body, allowedChars);
if (body.truncated) truncated = true;
remainingBodyChars -= body.text.length;
includedCommentCount += 1;
comments.push({
id: comment.id,
threadId: comment.threadId,
body: body.text,
bodyTruncated: body.truncated,
author: authorFrom(comment),
createdAt: comment.createdAt.toISOString(),
updatedAt: comment.updatedAt.toISOString(),
});
}
const commentsTruncated = comments.length < threadComments.length;
if (commentsTruncated) truncated = true;
return {
id: thread.id,
documentKey: thread.documentKey,
documentId: thread.documentId,
status: thread.status,
revisionId: thread.revisionId,
revisionNumber: thread.revisionNumber,
anchorState: thread.anchorState,
anchorConfidence: thread.anchorConfidence,
selectedText: selectedText.text,
selectedTextTruncated: selectedText.truncated,
prefixText: prefixText.text,
prefixTextTruncated: prefixText.truncated,
suffixText: suffixText.text,
suffixTextTruncated: suffixText.truncated,
author: authorFrom({ authorAgentId: thread.createdByAgentId, authorUserId: thread.createdByUserId }),
commentCount: threadComments.length,
comments,
commentsTruncated,
createdAt: thread.createdAt.toISOString(),
updatedAt: thread.updatedAt.toISOString(),
};
});
const omittedCommentCount = Math.max(0, commentCount - includedCommentCount);
if (omittedCommentCount > 0) truncated = true;
return {
documentKey: "plan",
issueId: input.issueId,
latestRevisionId: planDocument.latestRevisionId,
latestRevisionNumber: planDocument.latestRevisionNumber,
threads,
interaction,
totals: {
openThreadCount,
includedThreadCount: threads.length,
omittedThreadCount: Math.max(0, openThreadCount - threads.length),
commentCount,
includedCommentCount,
omittedCommentCount,
},
limits: { ...PLAN_REVIEW_CONTEXT_LIMITS },
truncated,
};
}