[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:
parent
e6407b3225
commit
5e3d6e3627
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}`,
|
||||
|
|
|
|||
|
|
@ -584,6 +584,13 @@ export type {
|
|||
DocumentAnnotationTextQuoteSelector,
|
||||
DocumentAnnotationThread,
|
||||
DocumentAnnotationThreadWithComments,
|
||||
PlanReviewContext,
|
||||
PlanReviewContextAuthor,
|
||||
PlanReviewContextComment,
|
||||
PlanReviewContextThread,
|
||||
PlanReviewInteractionContext,
|
||||
PlanReviewInteractionResultContext,
|
||||
PlanReviewInteractionTargetContext,
|
||||
DocumentTextPosition,
|
||||
DocumentTextProjection,
|
||||
DocumentTextRange,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,13 @@ export type {
|
|||
DocumentAnnotationTextQuoteSelector,
|
||||
DocumentAnnotationThread,
|
||||
DocumentAnnotationThreadWithComments,
|
||||
PlanReviewContext,
|
||||
PlanReviewContextAuthor,
|
||||
PlanReviewContextComment,
|
||||
PlanReviewContextThread,
|
||||
PlanReviewInteractionContext,
|
||||
PlanReviewInteractionResultContext,
|
||||
PlanReviewInteractionTargetContext,
|
||||
DocumentTextPosition,
|
||||
DocumentTextProjection,
|
||||
DocumentTextRange,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue