diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 1fa9b800693..84d7b0ce2e6 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -72,6 +72,34 @@ describe("buildPaperclipTaskMarkdown", () => { expect(acceptedConfirmation).not.toContain("- Work mode: \"planning\""); }); + it("includes confirmed-plan comments in accepted-plan continuation task context", () => { + const acceptedConfirmation = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-1", + identifier: "PAP-3404", + title: "Plan first", + workMode: "planning", + description: null, + }, + interaction: { + kind: "request_confirmation", + status: "accepted", + }, + acceptedPlanComments: [ + { + id: "comment-1", + authorType: "user", + body: "Please keep the migration backwards compatible.", + }, + ], + }); + + expect(acceptedConfirmation).toContain("Create child issues from the approved plan only"); + expect(acceptedConfirmation).toContain("Comments included with the confirmed plan:"); + expect(acceptedConfirmation).toContain("Comment 1 - user - comment-1:"); + expect(acceptedConfirmation).toContain("Please keep the migration backwards compatible."); + }); + it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => { const commentWake = buildPaperclipTaskMarkdown({ issue: { diff --git a/server/src/__tests__/issue-comment-redaction.test.ts b/server/src/__tests__/issue-comment-redaction.test.ts index 45e08d18754..2a439e85816 100644 --- a/server/src/__tests__/issue-comment-redaction.test.ts +++ b/server/src/__tests__/issue-comment-redaction.test.ts @@ -175,6 +175,69 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => { expect(JSON.stringify(wakePayload)).not.toContain("secret metadata"); }); + it("adds recent issue comments to accepted-plan continuation wake payloads", async () => { + const { companyId, issueId } = await seedIssue(); + const firstCommentId = randomUUID(); + const secondCommentId = randomUUID(); + const deletedCommentId = randomUUID(); + await db.insert(issueComments).values([ + { + id: firstCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "Keep the migration backwards compatible.", + createdAt: new Date("2026-06-03T12:00:00.000Z"), + }, + { + id: deletedCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "Do not pass this deleted text downstream.", + deletedAt: new Date("2026-06-03T12:01:00.000Z"), + deletedByType: "user", + deletedByUserId: "board-user-1", + createdAt: new Date("2026-06-03T12:01:00.000Z"), + }, + { + id: secondCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "Split the rollout into a verification child task.", + createdAt: new Date("2026-06-03T12:02:00.000Z"), + }, + ]); + + const wakePayload = await buildPaperclipWakePayload({ + db, + companyId, + contextSnapshot: { + issueId, + interactionKind: "request_confirmation", + interactionStatus: "accepted", + workspaceRefreshReason: "accepted_plan_confirmation", + }, + issueSummary: { + id: issueId, + identifier: "RED-1", + title: "Deleted comment redaction", + status: "in_progress", + priority: "medium", + workMode: "planning", + }, + }); + + expect(wakePayload?.commentContextSource).toBe("accepted_plan_confirmation"); + expect(wakePayload?.commentIds).toEqual([firstCommentId, secondCommentId]); + expect(wakePayload?.comments.map((comment) => comment.body)).toEqual([ + "Keep the migration backwards compatible.", + "Split the rollout into a verification child task.", + ]); + expect(JSON.stringify(wakePayload)).not.toContain("Do not pass this deleted text downstream."); + }); + it("excludes deleted comment bodies from company search", async () => { const { companyId, issueId } = await seedIssue(); await db.insert(issueComments).values({ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5903c11f81a..4aadd872c59 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2397,7 +2397,7 @@ export async function buildPaperclipWakePayload(input: { exposeLowTrustRaw?: boolean; }) { const executionStage = parseObject(input.contextSnapshot.executionStage); - const commentIds = extractWakeCommentIds(input.contextSnapshot); + let commentIds = extractWakeCommentIds(input.contextSnapshot); const annotationCommentId = readNonEmptyString(input.contextSnapshot.annotationCommentId); const issueId = readNonEmptyString(input.contextSnapshot.issueId); const continuationSummary = input.continuationSummary ?? null; @@ -2417,6 +2417,31 @@ export async function buildPaperclipWakePayload(input: { .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) .then((rows) => rows[0] ?? null) : null); + let acceptedPlanCommentWindowTruncated = false; + const acceptedPlanContinuationWake = + readNonEmptyString(input.contextSnapshot.workspaceRefreshReason) === "accepted_plan_confirmation" || + ( + issueSummary?.workMode === "planning" && + readNonEmptyString(input.contextSnapshot.interactionKind) === "request_confirmation" && + readNonEmptyString(input.contextSnapshot.interactionStatus) === "accepted" + ); + if (commentIds.length === 0 && acceptedPlanContinuationWake && issueSummary?.id) { + const recentPlanCommentRows = await input.db + .select({ id: issueComments.id }) + .from(issueComments) + .where(and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.issueId, issueSummary.id), + isNull(issueComments.deletedAt), + )) + .orderBy(desc(issueComments.createdAt), desc(issueComments.id)) + .limit(MAX_INLINE_WAKE_COMMENTS + 1); + acceptedPlanCommentWindowTruncated = recentPlanCommentRows.length > MAX_INLINE_WAKE_COMMENTS; + commentIds = recentPlanCommentRows + .slice(0, MAX_INLINE_WAKE_COMMENTS) + .reverse() + .map((comment) => comment.id); + } if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null; const commentRows = @@ -2451,7 +2476,7 @@ export async function buildPaperclipWakePayload(input: { const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); const comments: Array> = []; let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; - let truncated = false; + let truncated = acceptedPlanCommentWindowTruncated; let missingCommentCount = 0; const safeContinuationSummary = continuationSummary && !input.exposeLowTrustRaw @@ -2589,6 +2614,9 @@ export async function buildPaperclipWakePayload(input: { : null, interactionKind: readNonEmptyString(input.contextSnapshot.interactionKind), interactionStatus: readNonEmptyString(input.contextSnapshot.interactionStatus), + commentContextSource: acceptedPlanContinuationWake && commentIds.length > 0 + ? "accepted_plan_confirmation" + : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, @@ -2663,6 +2691,11 @@ export function buildPaperclipTaskMarkdown(input: { kind?: string | null; status?: string | null; } | null; + acceptedPlanComments?: Array<{ + id?: string | null; + authorType?: string | null; + body?: string | null; + }> | null; acceptedPlanContinuation?: boolean; }) { const quoteTaskScalar = (value: string) => JSON.stringify(value); @@ -2723,6 +2756,28 @@ export function buildPaperclipTaskMarkdown(input: { if (wakeComment?.body.trim()) { lines.push("", "Latest wake comment:", fenceTaskText(wakeComment.body.trim())); } + if (acceptedPlanContinuation) { + const acceptedPlanComments = (input.acceptedPlanComments ?? []) + .map((comment) => ({ + ...comment, + body: comment.body?.trim() ?? "", + })) + .filter((comment) => comment.body.length > 0) + .slice(0, MAX_INLINE_WAKE_COMMENTS); + if (acceptedPlanComments.length > 0) { + lines.push("", "Comments included with the confirmed plan:"); + for (const [index, comment] of acceptedPlanComments.entries()) { + const authorType = comment.authorType?.trim(); + const commentId = comment.id?.trim(); + const labelParts = [ + `Comment ${index + 1}`, + ...(authorType ? [authorType] : []), + ...(commentId ? [commentId] : []), + ]; + lines.push("", `${labelParts.join(" - ")}:`, fenceTaskText(comment.body)); + } + } + } lines.push("", "Use this task context as the current assignment."); return lines.join("\n"); } @@ -7894,6 +7949,17 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } else { delete context[PAPERCLIP_WAKE_PAYLOAD_KEY]; } + const acceptedPlanContinuationForTask = + readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" + && !parseObject(context.acceptedPlanWakeRouting); + const acceptedPlanCommentsForTask = + acceptedPlanContinuationForTask && Array.isArray(paperclipWakePayload?.comments) + ? paperclipWakePayload.comments.map((comment) => ({ + id: readNonEmptyString(comment.id), + authorType: readNonEmptyString(comment.authorType), + body: readNonEmptyString(comment.body), + })) + : null; const taskMarkdown = buildPaperclipTaskMarkdown({ issue: issueRef ? { @@ -7909,9 +7975,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) kind: readNonEmptyString(context.interactionKind), status: readNonEmptyString(context.interactionStatus), }, - acceptedPlanContinuation: - readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" - && !parseObject(context.acceptedPlanWakeRouting), + acceptedPlanComments: acceptedPlanCommentsForTask, + acceptedPlanContinuation: acceptedPlanContinuationForTask, }); if (issueRef) { context.paperclipIssue = {