diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index a0c7a5f3b2..45b335f795 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -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", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index d90260cea3..8261abdf36 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -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"}`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8e5b53d5ae..e1e8a5e58b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -584,6 +584,13 @@ export type { DocumentAnnotationTextQuoteSelector, DocumentAnnotationThread, DocumentAnnotationThreadWithComments, + PlanReviewContext, + PlanReviewContextAuthor, + PlanReviewContextComment, + PlanReviewContextThread, + PlanReviewInteractionContext, + PlanReviewInteractionResultContext, + PlanReviewInteractionTargetContext, DocumentTextPosition, DocumentTextProjection, DocumentTextRange, diff --git a/packages/shared/src/types/document-annotation.ts b/packages/shared/src/types/document-annotation.ts index ec975a954b..7c57fdf3e1 100644 --- a/packages/shared/src/types/document-annotation.ts +++ b/packages/shared/src/types/document-annotation.ts @@ -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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index aaf07216be..2716cbdcff 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -179,6 +179,13 @@ export type { DocumentAnnotationTextQuoteSelector, DocumentAnnotationThread, DocumentAnnotationThreadWithComments, + PlanReviewContext, + PlanReviewContextAuthor, + PlanReviewContextComment, + PlanReviewContextThread, + PlanReviewInteractionContext, + PlanReviewInteractionResultContext, + PlanReviewInteractionTargetContext, DocumentTextPosition, DocumentTextProjection, DocumentTextRange, diff --git a/server/src/__tests__/document-annotations-service.test.ts b/server/src/__tests__/document-annotations-service.test.ts index 14f0612faf..d27fd40822 100644 --- a/server/src/__tests__/document-annotations-service.test.ts +++ b/server/src/__tests__/document-annotations-service.test.ts @@ -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.", + }), + ], + }), + ], + }); + }); }); diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 9a09c39670..e383235c96 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -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", }), diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 64b761805d..e68bb0a379 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -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, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 2913a05fc0..8e0889c619 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +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) { 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, }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 81c4a3af9f..c530f09513 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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, }; } diff --git a/server/src/services/plan-review-context.ts b/server/src/services/plan-review-context.ts new file mode 100644 index 0000000000..e95e76704e --- /dev/null +++ b/server/src/services/plan-review-context.ts @@ -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 { + 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 { + 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`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`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(); + 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, + }; +}