[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>
This commit is contained in:
Dotta 2026-06-29 13:26:26 -05:00 committed by GitHub
parent e6407b3225
commit 5e3d6e3627
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1700 additions and 25 deletions

View File

@ -795,6 +795,235 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).not.toContain("Update the plan only");
});
it("renders accepted plan review context with annotation text and comments", () => {
const payload = {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
interactionKind: "request_confirmation",
interactionStatus: "accepted",
annotationDeltas: [
{
id: "annotation-delta-1",
issueId: "issue-1",
threadId: "thread-1",
documentKey: "plan",
revisionNumber: 2,
quote: "Create worker issue",
prefix: "Before context",
suffix: "After context",
threadStatus: "open",
anchorState: "active",
anchorConfidence: "exact",
body: "New direct annotation comment.",
bodyTruncated: true,
author: { type: "user", id: "board-user-1" },
createdAt: "2026-06-01T12:00:00.000Z",
},
],
planReviewContext: {
documentKey: "plan",
issueId: "issue-1",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
interaction: {
id: "interaction-1",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
target: {
issueId: "issue-1",
documentId: "document-1",
key: "plan",
revisionId: "revision-2",
revisionNumber: 2,
},
acceptedTargetRevision: {
issueId: "issue-1",
documentId: "document-1",
key: "plan",
revisionId: "revision-2",
revisionNumber: 2,
},
result: {
outcome: "accepted",
},
},
threads: [
{
id: "thread-1",
documentKey: "plan",
documentId: "document-1",
status: "open",
revisionId: "revision-2",
revisionNumber: 2,
anchorState: "active",
anchorConfidence: "exact",
selectedText: "Create worker issue",
selectedTextTruncated: true,
prefixText: "Before context",
suffixText: "After context",
comments: [
{
id: "annotation-comment-1",
threadId: "thread-1",
body: "Split this into QA and implementation child tasks.",
bodyTruncated: true,
author: { type: "user", id: "board-user-1" },
createdAt: "2026-06-01T12:01:00.000Z",
},
],
},
],
totals: {
openThreadCount: 1,
includedThreadCount: 1,
omittedThreadCount: 0,
commentCount: 1,
includedCommentCount: 1,
omittedCommentCount: 0,
},
},
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
comments: [],
fallbackFetchNeeded: false,
};
expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({
annotationDeltas: [
{
body: "New direct annotation comment.",
quote: "Create worker issue",
prefix: "Before context",
suffix: "After context",
bodyTruncated: true,
},
],
planReviewContext: {
interaction: {
status: "accepted",
acceptedTargetRevision: {
revisionNumber: 2,
},
},
threads: [
{
selectedText: "Create worker issue",
prefixText: "Before context",
suffixText: "After context",
comments: [
{
body: "Split this into QA and implementation child tasks.",
bodyTruncated: true,
},
],
},
],
},
});
const prompt = renderPaperclipWakePrompt(payload);
expect(prompt).toContain("New plan annotation deltas:");
expect(prompt).toContain("These direct annotation deltas are user feedback tied to plan text.");
expect(prompt).toContain(" context before: Before context");
expect(prompt).toContain(" context after: After context");
expect(prompt).toContain("[annotation comment body truncated]");
expect(prompt).toContain("These open plan annotations are user feedback. Resolved annotations were intentionally omitted.");
expect(prompt).toContain("- result: accepted");
expect(prompt).toContain("- accepted target: plan revision #2");
expect(prompt).toContain("- thread thread-1 (open, revision #2, active, exact)");
expect(prompt).toContain(" selected text: Create worker issue");
expect(prompt).toContain("[selected text truncated]");
expect(prompt).toContain("Split this into QA and implementation child tasks.");
expect(prompt).toContain("[plan comment body truncated]");
});
it("renders rejected plan review context even when the rejection reason is empty", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-3404",
title: "Plan first",
status: "in_progress",
workMode: "planning",
},
interactionKind: "request_confirmation",
interactionStatus: "rejected",
planReviewContext: {
documentKey: "plan",
issueId: "issue-1",
latestRevisionId: "revision-2",
latestRevisionNumber: 2,
interaction: {
id: "interaction-1",
kind: "request_confirmation",
status: "rejected",
continuationPolicy: "wake_assignee",
target: {
issueId: "issue-1",
documentId: "document-1",
key: "plan",
revisionId: "revision-2",
revisionNumber: 2,
},
result: {
outcome: "rejected",
},
},
threads: [
{
id: "thread-1",
documentKey: "plan",
documentId: "document-1",
status: "open",
revisionId: "revision-2",
revisionNumber: 2,
selectedText: "Launch checklist",
comments: [
{
id: "annotation-comment-1",
threadId: "thread-1",
body: "The rollout step needs an owner.",
author: { type: "user", id: "board-user-1" },
},
],
},
],
totals: {
openThreadCount: 1,
includedThreadCount: 1,
omittedThreadCount: 0,
commentCount: 1,
includedCommentCount: 1,
omittedCommentCount: 0,
},
},
commentIds: ["comment-1"],
latestCommentId: "comment-1",
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
comments: [
{
id: "comment-1",
body: "Also mention launch owner in the plan.",
author: { type: "user", id: "board-user-1" },
createdAt: "2026-06-01T12:05:00.000Z",
},
],
fallbackFetchNeeded: false,
});
expect(prompt).toContain("- result: rejected");
expect(prompt).toContain("- thread thread-1 (open, revision #2)");
expect(prompt).toContain("The rollout step needs an owner.");
expect(prompt.indexOf("Open plan comments to incorporate:")).toBeLessThan(prompt.indexOf("New comments in order:"));
});
it("renders dependency-blocked interaction guidance", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_commented",

View File

@ -432,6 +432,114 @@ type PaperclipWakeComment = {
authorId: string | null;
};
type PaperclipWakePlanReviewAuthor = {
type: string | null;
id: string | null;
};
type PaperclipWakeAnnotationDelta = {
id: string | null;
issueId: string | null;
threadId: string | null;
documentKey: string | null;
revisionNumber: number | null;
quote: string;
prefix: string;
suffix: string;
threadStatus: string | null;
anchorState: string | null;
anchorConfidence: string | null;
body: string;
bodyTruncated: boolean;
createdAt: string | null;
author: PaperclipWakePlanReviewAuthor | null;
};
type PaperclipWakePlanReviewComment = {
id: string | null;
threadId: string | null;
body: string;
bodyTruncated: boolean;
author: PaperclipWakePlanReviewAuthor | null;
createdAt: string | null;
updatedAt: string | null;
};
type PaperclipWakePlanReviewThread = {
id: string | null;
documentKey: string | null;
documentId: string | null;
status: string | null;
revisionId: string | null;
revisionNumber: number | null;
anchorState: string | null;
anchorConfidence: string | null;
selectedText: string;
selectedTextTruncated: boolean;
prefixText: string;
prefixTextTruncated: boolean;
suffixText: string;
suffixTextTruncated: boolean;
author: PaperclipWakePlanReviewAuthor | null;
commentCount: number;
comments: PaperclipWakePlanReviewComment[];
commentsTruncated: boolean;
createdAt: string | null;
updatedAt: string | null;
};
type PaperclipWakePlanReviewInteractionTarget = {
issueId: string | null;
documentId: string | null;
key: string | null;
revisionId: string | null;
revisionNumber: number | null;
};
type PaperclipWakePlanReviewInteractionResult = {
outcome: string | null;
reason: string | null;
commentId: string | null;
};
type PaperclipWakePlanReviewInteraction = {
id: string | null;
kind: string | null;
status: string | null;
continuationPolicy: string | null;
sourceCommentId: string | null;
sourceRunId: string | null;
target: PaperclipWakePlanReviewInteractionTarget | null;
acceptedTargetRevision: PaperclipWakePlanReviewInteractionTarget | null;
result: PaperclipWakePlanReviewInteractionResult | null;
resolvedAt: string | null;
};
type PaperclipWakePlanReviewContext = {
documentKey: string | null;
issueId: string | null;
latestRevisionId: string | null;
latestRevisionNumber: number | null;
threads: PaperclipWakePlanReviewThread[];
interaction: PaperclipWakePlanReviewInteraction | null;
totals: {
openThreadCount: number;
includedThreadCount: number;
omittedThreadCount: number;
commentCount: number;
includedCommentCount: number;
omittedCommentCount: number;
};
limits: {
maxThreads: number;
maxComments: number;
maxBodyChars: number;
maxTotalBodyChars: number;
maxAnchorTextChars: number;
} | null;
truncated: boolean;
};
type PaperclipWakeContinuationSummary = {
key: string | null;
title: string | null;
@ -484,10 +592,12 @@ type PaperclipWakePayload = {
unresolvedBlockerSummaries: PaperclipWakeBlockerSummary[];
executionStage: PaperclipWakeExecutionStage | null;
continuationSummary: PaperclipWakeContinuationSummary | null;
planReviewContext: PaperclipWakePlanReviewContext | null;
livenessContinuation: PaperclipWakeLivenessContinuation | null;
taskWatchdog: PaperclipWakeTaskWatchdogContext | null;
interactionKind: string | null;
interactionStatus: string | null;
annotationDeltas: PaperclipWakeAnnotationDelta[];
childIssueSummaries: PaperclipWakeChildIssueSummary[];
childIssueSummaryTruncated: boolean;
commentIds: string[];
@ -535,6 +645,225 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
};
}
function normalizePaperclipWakePlanReviewAuthor(value: unknown): PaperclipWakePlanReviewAuthor | null {
const author = parseObject(value);
const type = asString(author.type, "").trim() || null;
const id = asString(author.id, "").trim() || null;
if (!type && !id) return null;
return { type, id };
}
function normalizePaperclipWakeAnnotationDelta(value: unknown): PaperclipWakeAnnotationDelta | null {
const delta = parseObject(value);
const id = asString(delta.id, "").trim() || null;
const issueId = asString(delta.issueId, "").trim() || null;
const threadId = asString(delta.threadId, "").trim() || null;
const documentKey = asString(delta.documentKey, "").trim() || null;
const revisionNumber = asNumber(delta.revisionNumber, 0);
const quote = asString(delta.quote, "");
const prefix = asString(delta.prefix, "");
const suffix = asString(delta.suffix, "");
const threadStatus = asString(delta.threadStatus, "").trim() || null;
const anchorState = asString(delta.anchorState, "").trim() || null;
const anchorConfidence = asString(delta.anchorConfidence, "").trim() || null;
const body = asString(delta.body, "");
const createdAt = asString(delta.createdAt, "").trim() || null;
const author = normalizePaperclipWakePlanReviewAuthor(delta.author);
if (!id && !threadId && !documentKey && !quote.trim() && !body.trim()) return null;
return {
id,
issueId,
threadId,
documentKey,
revisionNumber: revisionNumber > 0 ? revisionNumber : null,
quote,
prefix,
suffix,
threadStatus,
anchorState,
anchorConfidence,
body,
bodyTruncated: asBoolean(delta.bodyTruncated, false),
createdAt,
author,
};
}
function normalizePaperclipWakePlanReviewComment(value: unknown): PaperclipWakePlanReviewComment | null {
const comment = parseObject(value);
const id = asString(comment.id, "").trim() || null;
const threadId = asString(comment.threadId, "").trim() || null;
const body = asString(comment.body, "");
const author = normalizePaperclipWakePlanReviewAuthor(comment.author);
const createdAt = asString(comment.createdAt, "").trim() || null;
const updatedAt = asString(comment.updatedAt, "").trim() || null;
if (!id && !threadId && !body.trim()) return null;
return {
id,
threadId,
body,
bodyTruncated: asBoolean(comment.bodyTruncated, false),
author,
createdAt,
updatedAt,
};
}
function normalizePaperclipWakePlanReviewThread(value: unknown): PaperclipWakePlanReviewThread | null {
const thread = parseObject(value);
const comments = Array.isArray(thread.comments)
? thread.comments
.map((entry) => normalizePaperclipWakePlanReviewComment(entry))
.filter((entry): entry is PaperclipWakePlanReviewComment => Boolean(entry))
: [];
const id = asString(thread.id, "").trim() || null;
const documentKey = asString(thread.documentKey, "").trim() || null;
const documentId = asString(thread.documentId, "").trim() || null;
const status = asString(thread.status, "").trim() || null;
const revisionId = asString(thread.revisionId, "").trim() || null;
const revisionNumber = asNumber(thread.revisionNumber, 0);
const anchorState = asString(thread.anchorState, "").trim() || null;
const anchorConfidence = asString(thread.anchorConfidence, "").trim() || null;
const selectedText = asString(thread.selectedText, "");
const prefixText = asString(thread.prefixText, "");
const suffixText = asString(thread.suffixText, "");
const author = normalizePaperclipWakePlanReviewAuthor(thread.author);
const commentCount = asNumber(thread.commentCount, comments.length);
const createdAt = asString(thread.createdAt, "").trim() || null;
const updatedAt = asString(thread.updatedAt, "").trim() || null;
if (!id && !documentId && !selectedText.trim() && comments.length === 0) return null;
return {
id,
documentKey,
documentId,
status,
revisionId,
revisionNumber: revisionNumber > 0 ? revisionNumber : null,
anchorState,
anchorConfidence,
selectedText,
selectedTextTruncated: asBoolean(thread.selectedTextTruncated, false),
prefixText,
prefixTextTruncated: asBoolean(thread.prefixTextTruncated, false),
suffixText,
suffixTextTruncated: asBoolean(thread.suffixTextTruncated, false),
author,
commentCount: commentCount >= 0 ? commentCount : comments.length,
comments,
commentsTruncated: asBoolean(thread.commentsTruncated, false),
createdAt,
updatedAt,
};
}
function normalizePaperclipWakePlanReviewInteractionTarget(
value: unknown,
): PaperclipWakePlanReviewInteractionTarget | null {
const target = parseObject(value);
const issueId = asString(target.issueId, "").trim() || null;
const documentId = asString(target.documentId, "").trim() || null;
const key = asString(target.key, "").trim() || null;
const revisionId = asString(target.revisionId, "").trim() || null;
const revisionNumber = asNumber(target.revisionNumber, 0);
if (!issueId && !documentId && !key && !revisionId && !revisionNumber) return null;
return {
issueId,
documentId,
key,
revisionId,
revisionNumber: revisionNumber > 0 ? revisionNumber : null,
};
}
function normalizePaperclipWakePlanReviewInteractionResult(
value: unknown,
): PaperclipWakePlanReviewInteractionResult | null {
const result = parseObject(value);
const outcome = asString(result.outcome, "").trim() || null;
const reason = asString(result.reason, "").trim() || null;
const commentId = asString(result.commentId, "").trim() || null;
if (!outcome && !reason && !commentId) return null;
return { outcome, reason, commentId };
}
function normalizePaperclipWakePlanReviewInteraction(value: unknown): PaperclipWakePlanReviewInteraction | null {
const interaction = parseObject(value);
const id = asString(interaction.id, "").trim() || null;
const kind = asString(interaction.kind, "").trim() || null;
const status = asString(interaction.status, "").trim() || null;
const continuationPolicy = asString(interaction.continuationPolicy, "").trim() || null;
const sourceCommentId = asString(interaction.sourceCommentId, "").trim() || null;
const sourceRunId = asString(interaction.sourceRunId, "").trim() || null;
const target = normalizePaperclipWakePlanReviewInteractionTarget(interaction.target);
const acceptedTargetRevision = normalizePaperclipWakePlanReviewInteractionTarget(interaction.acceptedTargetRevision);
const result = normalizePaperclipWakePlanReviewInteractionResult(interaction.result);
const resolvedAt = asString(interaction.resolvedAt, "").trim() || null;
if (!id && !kind && !status && !target && !acceptedTargetRevision && !result) return null;
return {
id,
kind,
status,
continuationPolicy,
sourceCommentId,
sourceRunId,
target,
acceptedTargetRevision,
result,
resolvedAt,
};
}
function normalizePaperclipWakePlanReviewContext(value: unknown): PaperclipWakePlanReviewContext | null {
const context = parseObject(value);
const threads = Array.isArray(context.threads)
? context.threads
.map((entry) => normalizePaperclipWakePlanReviewThread(entry))
.filter((entry): entry is PaperclipWakePlanReviewThread => Boolean(entry))
: [];
const interaction = normalizePaperclipWakePlanReviewInteraction(context.interaction);
const totalsRaw = parseObject(context.totals);
const limitsRaw = parseObject(context.limits);
const limits = Object.keys(limitsRaw).length > 0
? {
maxThreads: asNumber(limitsRaw.maxThreads, 0),
maxComments: asNumber(limitsRaw.maxComments, 0),
maxBodyChars: asNumber(limitsRaw.maxBodyChars, 0),
maxTotalBodyChars: asNumber(limitsRaw.maxTotalBodyChars, 0),
maxAnchorTextChars: asNumber(limitsRaw.maxAnchorTextChars, 0),
}
: null;
const documentKey = asString(context.documentKey, "").trim() || null;
const issueId = asString(context.issueId, "").trim() || null;
const latestRevisionId = asString(context.latestRevisionId, "").trim() || null;
const latestRevisionNumber = asNumber(context.latestRevisionNumber, 0);
const openThreadCount = asNumber(totalsRaw.openThreadCount, threads.length);
const includedThreadCount = asNumber(totalsRaw.includedThreadCount, threads.length);
const commentCount = asNumber(totalsRaw.commentCount, threads.reduce((sum, thread) => sum + thread.commentCount, 0));
const includedCommentCount = asNumber(
totalsRaw.includedCommentCount,
threads.reduce((sum, thread) => sum + thread.comments.length, 0),
);
if (!documentKey && !issueId && threads.length === 0 && !interaction) return null;
return {
documentKey,
issueId,
latestRevisionId,
latestRevisionNumber: latestRevisionNumber > 0 ? latestRevisionNumber : null,
threads,
interaction,
totals: {
openThreadCount: Math.max(0, openThreadCount),
includedThreadCount: Math.max(0, includedThreadCount),
omittedThreadCount: Math.max(0, asNumber(totalsRaw.omittedThreadCount, Math.max(0, openThreadCount - threads.length))),
commentCount: Math.max(0, commentCount),
includedCommentCount: Math.max(0, includedCommentCount),
omittedCommentCount: Math.max(0, asNumber(totalsRaw.omittedCommentCount, Math.max(0, commentCount - includedCommentCount))),
},
limits,
truncated: asBoolean(context.truncated, false),
};
}
function normalizePaperclipWakeContinuationSummary(value: unknown): PaperclipWakeContinuationSummary | null {
const summary = parseObject(value);
const body = asString(summary.body, "").trim();
@ -759,6 +1088,12 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
: [];
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
const continuationSummary = normalizePaperclipWakeContinuationSummary(payload.continuationSummary);
const planReviewContext = normalizePaperclipWakePlanReviewContext(payload.planReviewContext);
const annotationDeltas = Array.isArray(payload.annotationDeltas)
? payload.annotationDeltas
.map((entry) => normalizePaperclipWakeAnnotationDelta(entry))
.filter((entry): entry is PaperclipWakeAnnotationDelta => Boolean(entry))
: [];
const livenessContinuation = normalizePaperclipWakeLivenessContinuation(payload.livenessContinuation);
const taskWatchdog = normalizePaperclipWakeTaskWatchdog(payload.taskWatchdog);
const childIssueSummaries = Array.isArray(payload.childIssueSummaries)
@ -778,7 +1113,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
: [];
const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold);
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !livenessContinuation && !taskWatchdog && !normalizePaperclipWakeIssue(payload.issue)) {
if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !normalizePaperclipWakeIssue(payload.issue)) {
return null;
}
@ -793,6 +1128,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
unresolvedBlockerSummaries,
executionStage,
continuationSummary,
planReviewContext,
annotationDeltas,
livenessContinuation,
taskWatchdog,
interactionKind: asString(payload.interactionKind, "").trim() || null,
@ -838,6 +1175,25 @@ export function renderPaperclipWakePrompt(
if (principal.type === "agent") return principal.agentId ? `agent ${principal.agentId}` : "agent";
return principal.userId ? `user ${principal.userId}` : "user";
};
const planReviewTargetLabel = (target: PaperclipWakePlanReviewInteractionTarget | null) => {
if (!target) return "none";
const revision = target.revisionNumber
? `revision #${target.revisionNumber}`
: target.revisionId
? `revision ${target.revisionId}`
: "unknown revision";
return `${target.key ?? "document"} ${revision}`;
};
const planReviewAuthorLabel = (author: PaperclipWakePlanReviewAuthor | null) => {
if (!author) return "unknown";
return author.id ? `${author.type ?? "unknown"} ${author.id}` : author.type ?? "unknown";
};
const renderPlanReviewText = (label: string, text: string, truncated: boolean) => {
lines.push(`${label}: ${text.trim() ? text : "(empty)"}`);
if (truncated) {
lines.push(`[${label.trim().toLowerCase()} truncated]`);
}
};
const lines = resumedSession
? [
@ -929,6 +1285,93 @@ export function renderPaperclipWakePrompt(
lines.push(`- omitted comments: ${normalized.missingCount}`);
}
if (normalized.annotationDeltas.length > 0) {
lines.push(
"",
"New plan annotation deltas:",
"These direct annotation deltas are user feedback tied to plan text.",
);
for (const delta of normalized.annotationDeltas) {
const state = [
delta.threadStatus,
delta.revisionNumber ? `revision #${delta.revisionNumber}` : null,
delta.anchorState,
delta.anchorConfidence,
].filter(Boolean).join(", ");
lines.push(`- annotation ${delta.id ?? delta.threadId ?? "unknown"}${state ? ` (${state})` : ""}`);
if (delta.threadId) lines.push(` thread: ${delta.threadId}`);
if (delta.documentKey) lines.push(` document: ${delta.documentKey}`);
renderPlanReviewText(" selected text", delta.quote, false);
renderPlanReviewText(" context before", delta.prefix, false);
renderPlanReviewText(" context after", delta.suffix, false);
lines.push(` comment by ${planReviewAuthorLabel(delta.author)}${delta.createdAt ? ` at ${delta.createdAt}` : ""}:`);
lines.push(delta.body);
if (delta.bodyTruncated) {
lines.push("[annotation comment body truncated]");
}
}
}
if (normalized.planReviewContext) {
const context = normalized.planReviewContext;
lines.push(
"",
"Open plan comments to incorporate:",
"These open plan annotations are user feedback. Resolved annotations were intentionally omitted.",
"Read this before revising the plan or creating child issues from an accepted plan.",
);
if (context.latestRevisionNumber || context.latestRevisionId) {
lines.push(
`- latest plan revision: ${context.latestRevisionNumber ?? "unknown"}${context.latestRevisionId ? ` (${context.latestRevisionId})` : ""}`,
);
}
if (context.interaction) {
lines.push(`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`);
if (context.interaction.result) {
const result = context.interaction.result;
lines.push(`- result: ${result.outcome ?? "unknown"}${result.reason ? ` (${result.reason})` : ""}`);
if (result.commentId) {
lines.push(`- result comment id: ${result.commentId}`);
}
}
lines.push(`- target: ${planReviewTargetLabel(context.interaction.target)}`);
if (context.interaction.acceptedTargetRevision) {
lines.push(`- accepted target: ${planReviewTargetLabel(context.interaction.acceptedTargetRevision)}`);
}
}
lines.push(
`- open annotation threads included: ${context.totals.includedThreadCount}/${context.totals.openThreadCount}`,
`- annotation comments included: ${context.totals.includedCommentCount}/${context.totals.commentCount}`,
);
for (const thread of context.threads) {
const state = [
thread.status,
thread.revisionNumber ? `revision #${thread.revisionNumber}` : null,
thread.anchorState,
thread.anchorConfidence,
].filter(Boolean).join(", ");
lines.push(`- thread ${thread.id ?? "unknown"}${state ? ` (${state})` : ""}`);
renderPlanReviewText(" selected text", thread.selectedText, thread.selectedTextTruncated);
renderPlanReviewText(" context before", thread.prefixText, thread.prefixTextTruncated);
renderPlanReviewText(" context after", thread.suffixText, thread.suffixTextTruncated);
for (const comment of thread.comments) {
lines.push(
` comment ${comment.id ?? "unknown"} by ${planReviewAuthorLabel(comment.author)}${comment.createdAt ? ` at ${comment.createdAt}` : ""}:`,
);
lines.push(comment.body);
if (comment.bodyTruncated) {
lines.push("[plan comment body truncated]");
}
}
if (thread.commentsTruncated) {
lines.push("[plan thread comments truncated]");
}
}
if (context.totals.omittedThreadCount > 0 || context.totals.omittedCommentCount > 0 || context.truncated) {
lines.push("[plan review context truncated]");
}
}
if (executionStage) {
lines.push(
`- execution wake role: ${executionStage.wakeRole ?? "unknown"}`,

View File

@ -584,6 +584,13 @@ export type {
DocumentAnnotationTextQuoteSelector,
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
PlanReviewContext,
PlanReviewContextAuthor,
PlanReviewContextComment,
PlanReviewContextThread,
PlanReviewInteractionContext,
PlanReviewInteractionResultContext,
PlanReviewInteractionTargetContext,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,

View File

@ -3,6 +3,9 @@ import type {
DocumentAnnotationAnchorState,
DocumentAnnotationThreadStatus,
IssueCommentAuthorType,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionKind,
IssueThreadInteractionStatus,
} from "../constants.js";
export interface DocumentTextPosition {
@ -137,3 +140,93 @@ export interface CreateDocumentAnnotationCommentRequest {
export interface UpdateDocumentAnnotationThreadRequest {
status?: DocumentAnnotationThreadStatus;
}
export interface PlanReviewContextAuthor {
type: IssueCommentAuthorType;
id: string | null;
}
export interface PlanReviewContextComment {
id: string;
threadId: string;
body: string;
bodyTruncated: boolean;
author: PlanReviewContextAuthor;
createdAt: string;
updatedAt: string;
}
export interface PlanReviewContextThread {
id: string;
documentKey: string;
documentId: string;
status: DocumentAnnotationThreadStatus;
revisionId: string | null;
revisionNumber: number;
anchorState: DocumentAnnotationAnchorState;
anchorConfidence: DocumentAnnotationAnchorConfidence;
selectedText: string;
selectedTextTruncated: boolean;
prefixText: string;
prefixTextTruncated: boolean;
suffixText: string;
suffixTextTruncated: boolean;
author: PlanReviewContextAuthor;
commentCount: number;
comments: PlanReviewContextComment[];
commentsTruncated: boolean;
createdAt: string;
updatedAt: string;
}
export interface PlanReviewInteractionTargetContext {
issueId: string;
documentId: string | null;
key: string;
revisionId: string | null;
revisionNumber: number | null;
}
export interface PlanReviewInteractionResultContext {
outcome: string | null;
reason: string | null;
commentId: string | null;
}
export interface PlanReviewInteractionContext {
id: string;
kind: IssueThreadInteractionKind | string;
status: IssueThreadInteractionStatus | string;
continuationPolicy: IssueThreadInteractionContinuationPolicy | string;
sourceCommentId: string | null;
sourceRunId: string | null;
target: PlanReviewInteractionTargetContext | null;
acceptedTargetRevision: PlanReviewInteractionTargetContext | null;
result: PlanReviewInteractionResultContext | null;
resolvedAt: string | null;
}
export interface PlanReviewContext {
documentKey: "plan";
issueId: string;
latestRevisionId: string | null;
latestRevisionNumber: number | null;
threads: PlanReviewContextThread[];
interaction: PlanReviewInteractionContext | null;
totals: {
openThreadCount: number;
includedThreadCount: number;
omittedThreadCount: number;
commentCount: number;
includedCommentCount: number;
omittedCommentCount: number;
};
limits: {
maxThreads: number;
maxComments: number;
maxBodyChars: number;
maxTotalBodyChars: number;
maxAnchorTextChars: number;
};
truncated: boolean;
}

View File

@ -179,6 +179,13 @@ export type {
DocumentAnnotationTextQuoteSelector,
DocumentAnnotationThread,
DocumentAnnotationThreadWithComments,
PlanReviewContext,
PlanReviewContextAuthor,
PlanReviewContextComment,
PlanReviewContextThread,
PlanReviewInteractionContext,
PlanReviewInteractionResultContext,
PlanReviewInteractionTargetContext,
DocumentTextPosition,
DocumentTextProjection,
DocumentTextRange,

View File

@ -11,6 +11,7 @@ import {
documents,
issueComments,
issueDocuments,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
@ -19,6 +20,8 @@ import {
} 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 { buildPlanReviewContext, PLAN_REVIEW_CONTEXT_LIMITS } from "../services/plan-review-context.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@ -56,6 +59,7 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
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);
@ -68,7 +72,7 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
await tempDb?.cleanup();
});
async function createIssueWithDocument() {
async function createIssueWithDocument(workMode: "planning" | "standard" = "planning") {
const companyId = randomUUID();
const issueId = randomUUID();
@ -82,10 +86,11 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "PAP-9442",
identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
title: "Annotation race",
description: "Validate annotation revision guards",
status: "in_progress",
workMode,
priority: "high",
});
@ -314,4 +319,485 @@ describeEmbeddedPostgres("documentAnnotationService", () => {
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.",
}),
],
}),
],
});
});
});

View File

@ -667,6 +667,21 @@ describe.sequential("issue thread interaction routes", () => {
interactionId: "interaction-plan",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
planReviewInteraction: expect.objectContaining({
id: "interaction-plan",
kind: "request_confirmation",
status: "accepted",
acceptedTargetRevision: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
documentId: "document-plan",
key: "plan",
revisionId: "revision-plan",
revisionNumber: 1,
}),
result: expect.objectContaining({
outcome: "accepted",
}),
}),
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),

View File

@ -211,23 +211,15 @@ describe.sequential("issue goal context routes", () => {
mockDocumentsService.getIssueDocumentPayload.mockResolvedValue({});
mockDocumentsService.getIssueDocumentByKey.mockResolvedValue(null);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockDb.select.mockReturnValue({
from: vi.fn(() => {
let hasJoin = false;
const query = {
innerJoin: vi.fn(() => {
hasJoin = true;
return query;
}),
where: vi.fn(() => hasJoin
? Promise.resolve([])
: {
orderBy: vi.fn(async () => []),
}),
};
return query;
}),
});
const emptyQuery: any = {};
emptyQuery.from = vi.fn(() => emptyQuery);
emptyQuery.innerJoin = vi.fn(() => emptyQuery);
emptyQuery.where = vi.fn(() => emptyQuery);
emptyQuery.orderBy = vi.fn(() => emptyQuery);
emptyQuery.limit = vi.fn(async () => []);
emptyQuery.then = (resolve: (rows: unknown[]) => unknown, reject?: (error: unknown) => unknown) =>
Promise.resolve([]).then(resolve, reject);
mockDb.select.mockReturnValue(emptyQuery);
mockDb.execute.mockResolvedValue([]);
mockProjectService.getById.mockResolvedValue({
id: legacyProjectLinkedIssue.projectId,

View File

@ -97,6 +97,7 @@ import {
routineService,
workProductService,
} from "../services/index.js";
import { buildPlanReviewContext } from "../services/plan-review-context.js";
import {
TASK_WATCHDOG_ORIGIN_KIND,
resolveTaskWatchdogMutationScope,
@ -387,6 +388,33 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readObject(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function readPlanConfirmationTargetForIssue(payload: unknown, issueId: string) {
const target = readObject(readObject(payload).target);
if (target.type !== "issue_document" || target.key !== "plan") return null;
if (readNonEmptyString(target.issueId) !== issueId) return null;
return {
issueId,
documentId: readNonEmptyString(target.documentId),
key: "plan",
revisionId: readNonEmptyString(target.revisionId),
revisionNumber: typeof target.revisionNumber === "number" ? target.revisionNumber : null,
};
}
function readConfirmationResultForWake(result: unknown) {
const parsed = readObject(result);
if (Object.keys(parsed).length === 0) return null;
return {
outcome: readNonEmptyString(parsed.outcome),
reason: readNonEmptyString(parsed.reason) ?? readNonEmptyString(parsed.rejectionReason),
commentId: readNonEmptyString(parsed.commentId),
};
}
function hasIssueWorkspaceAuditChange(previous: Record<string, unknown>) {
return Object.keys(previous).some((key) => ISSUE_WORKSPACE_AUDIT_FIELDS.has(key));
}
@ -916,6 +944,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
continuationPolicy: string;
sourceCommentId?: string | null;
sourceRunId?: string | null;
payload?: unknown;
result?: unknown;
};
actor: { actorType: "user" | "agent"; actorId: string };
source: string;
@ -935,6 +965,19 @@ function queueResolvedInteractionContinuationWakeup(input: {
const forceFreshSession = input.forceFreshSession === true;
const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason);
const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id);
const interactionResult = readConfirmationResultForWake(input.interaction.result);
const planReviewInteraction =
planTarget && input.interaction.kind === "request_confirmation"
? {
id: input.interaction.id,
kind: input.interaction.kind,
status: input.interaction.status,
target: planTarget,
acceptedTargetRevision: input.interaction.status === "accepted" ? planTarget : null,
result: interactionResult,
}
: null;
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
@ -946,6 +989,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
interactionStatus: input.interaction.status,
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
...(planReviewInteraction ? { planReviewInteraction } : {}),
mutation: "interaction",
},
requestedByActorType: input.actor.actorType,
@ -958,6 +1002,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
interactionStatus: input.interaction.status,
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
...(planReviewInteraction ? { planReviewInteraction } : {}),
wakeReason: "issue_commented",
source: input.source,
...(forceFreshSession ? { forceFreshSession: true } : {}),
@ -3360,6 +3405,13 @@ export function issueRoutes(
continuationSummary && redactLowTrust
? redactQuarantinedBodyForHigherTrust(continuationSummary)
: continuationSummary;
const planReviewContext = await buildPlanReviewContext({
db,
companyId: issue.companyId,
issueId: issue.id,
issueWorkMode: issue.workMode,
includeForIssueComment: wakeCommentId !== null,
});
res.json({
issue: {
@ -3430,6 +3482,7 @@ export function issueRoutes(
sourceTrust: safeContinuationSummary.sourceTrust ?? null,
}
: null,
planReviewContext,
currentExecutionWorkspace,
});
});

View File

@ -121,6 +121,7 @@ import {
getIssueContinuationSummaryDocument,
refreshIssueContinuationSummary,
} from "./issue-continuation-summary.js";
import { buildPlanReviewContext } from "./plan-review-context.js";
import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
import { workspaceOperationService } from "./workspace-operations.js";
import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js";
@ -2917,7 +2918,7 @@ export async function buildPaperclipWakePayload(input: {
});
}
const annotationDeltas = annotationCommentId
const annotationDeltas = annotationCommentId && issueId
? await input.db
.select({
id: documentAnnotationComments.id,
@ -2941,7 +2942,10 @@ export async function buildPaperclipWakePayload(input: {
.innerJoin(documentAnnotationThreads, eq(documentAnnotationComments.threadId, documentAnnotationThreads.id))
.where(and(
eq(documentAnnotationComments.companyId, input.companyId),
eq(documentAnnotationComments.issueId, issueId),
eq(documentAnnotationComments.id, annotationCommentId),
eq(documentAnnotationThreads.companyId, input.companyId),
eq(documentAnnotationThreads.issueId, issueId),
))
.then((rows) => rows.map((row) => ({
id: row.id,
@ -2967,6 +2971,21 @@ export async function buildPaperclipWakePayload(input: {
: { type: row.authorType, id: null },
})))
: [];
const interactionId = readNonEmptyString(input.contextSnapshot.interactionId);
const interactionKind = readNonEmptyString(input.contextSnapshot.interactionKind);
const interactionStatus = readNonEmptyString(input.contextSnapshot.interactionStatus);
const planReviewContext = issueId
? await buildPlanReviewContext({
db: input.db,
companyId: input.companyId,
issueId,
issueWorkMode: issueSummary?.workMode ?? null,
includeForIssueComment: commentIds.length > 0,
includeForAnnotationDelta: annotationDeltas.length > 0,
interactionId,
})
: null;
const payloadTruncated = truncated || planReviewContext?.truncated === true;
return {
reason: readNonEmptyString(input.contextSnapshot.wakeReason),
@ -2997,8 +3016,8 @@ export async function buildPaperclipWakePayload(input: {
instruction: readNonEmptyString(input.contextSnapshot.livenessContinuationInstruction),
}
: null,
interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind),
interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus),
interactionKind,
interactionStatus,
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
@ -3028,13 +3047,14 @@ export async function buildPaperclipWakePayload(input: {
latestCommentId: commentIds[commentIds.length - 1] ?? null,
comments,
annotationDeltas,
planReviewContext,
commentWindow: {
requestedCount: commentIds.length,
includedCount: comments.length,
missingCount: missingCommentCount,
},
truncated,
fallbackFetchNeeded: truncated || missingCommentCount > 0,
truncated: payloadTruncated,
fallbackFetchNeeded: payloadTruncated || missingCommentCount > 0,
};
}

View File

@ -0,0 +1,330 @@
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,
};
}