diff --git a/server/src/__tests__/document-annotation-routes.test.ts b/server/src/__tests__/document-annotation-routes.test.ts index 0a2aa0e6..f7f4d199 100644 --- a/server/src/__tests__/document-annotation-routes.test.ts +++ b/server/src/__tests__/document-annotation-routes.test.ts @@ -237,7 +237,7 @@ describe("document annotation routes", () => { }); }); - it("creates annotation threads, syncs references, logs activity, and wakes the assignee", async () => { + it("creates annotation threads, syncs references, logs activity, and does not wake the assignee", async () => { mockIssueService.getById.mockResolvedValue({ id: issueId, companyId, @@ -261,15 +261,7 @@ describe("document annotation routes", () => { expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_created", })); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "99999999-9999-4999-8999-999999999999", - expect.objectContaining({ - payload: expect.objectContaining({ - annotationThreadId: annotationThread.id, - annotationCommentId: annotationComment.id, - }), - }), - ); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("rejects agent cross-company annotation reads", async () => { @@ -293,5 +285,6 @@ describe("document annotation routes", () => { expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "issue.document_annotation_thread_resolved", })); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); }); diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index 11f91785..831a6c95 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -737,6 +737,17 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { createdAt: new Date(), updatedAt: new Date(), }); + const planCommentId = randomUUID(); + await db.insert(issueComments).values({ + id: planCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + authorType: "user", + body: "Keep the accepted plan rollout split into separate child tasks.", + createdAt: new Date("2026-06-07T00:00:00.000Z"), + updatedAt: new Date("2026-06-07T00:00:00.000Z"), + }); await seedAcceptedPlanClaim({ companyId, issueId, @@ -803,5 +814,14 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session"); expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined(); expect(adapterInput.context.paperclipTaskMarkdown).toContain("Create child issues from the approved plan only"); + expect(adapterInput.context.paperclipTaskMarkdown).toContain("Comments included with the confirmed plan:"); + expect(adapterInput.context.paperclipTaskMarkdown).toContain(planCommentId); + expect(adapterInput.context.paperclipTaskMarkdown).toContain( + "Keep the accepted plan rollout split into separate child tasks.", + ); + expect(adapterInput.context.paperclipWake).toEqual(expect.objectContaining({ + commentIds: [planCommentId], + commentContextSource: "accepted_plan_confirmation", + })); }, 20_000); }); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 1fa9b800..84d7b0ce 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__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 410e0f11..1f62aa76 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -274,9 +274,11 @@ describe("resolveExecutionRunAdapterConfig", () => { }); describe("extractMentionedSkillIdsFromSources", () => { - it("collects explicit skill mention ids across issue sources", () => { - const releaseHref = buildSkillMentionHref("skill-1", "release-changelog"); - const browserHref = buildSkillMentionHref("skill-2", "agent-browser"); + it("collects UUID skill mention ids across issue sources", () => { + const releaseSkillId = "11111111-1111-4111-8111-111111111111"; + const browserSkillId = "22222222-2222-4222-8222-222222222222"; + const releaseHref = buildSkillMentionHref(releaseSkillId, "release-changelog"); + const browserHref = buildSkillMentionHref(browserSkillId, "agent-browser"); expect( extractMentionedSkillIdsFromSources([ @@ -284,7 +286,19 @@ describe("extractMentionedSkillIdsFromSources", () => { `And also [/agent-browser](${browserHref})`, `Duplicate mention [/release-changelog](${releaseHref})`, ]), - ).toEqual(["skill-1", "skill-2"]); + ).toEqual([releaseSkillId, browserSkillId]); + }); + + it("ignores legacy non-UUID skill mention ids before runtime database lookup", () => { + const validSkillId = "33333333-3333-4333-8333-333333333333"; + const validHref = buildSkillMentionHref(validSkillId, "greploop"); + const legacyHref = buildSkillMentionHref("skill-greploop", "greploop"); + + expect( + extractMentionedSkillIdsFromSources([ + `Use [/greploop](${legacyHref}) and [/prcheckloop](${validHref})`, + ]), + ).toEqual([validSkillId]); }); }); diff --git a/server/src/__tests__/issue-comment-redaction.test.ts b/server/src/__tests__/issue-comment-redaction.test.ts index 45e08d18..2a439e85 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/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 4043d86a..d56164ca 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -639,20 +639,8 @@ describe.sequential("issue comment reopen routes", () => { }), }), ); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - contextSnapshot: expect.objectContaining({ - wakeReason: "issue_commented", - source: "issue.comment", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not move scheduled-retry issues to todo when POST comment retry cancellation fails", async () => { @@ -701,12 +689,8 @@ describe.sequential("issue comment reopen routes", () => { expect(mockIssueService.getCurrentScheduledRetry).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111"); expect(mockIssueService.update).not.toHaveBeenCalled(); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("passes validated comment presentation fields to trusted board comment writes", async () => { @@ -808,21 +792,8 @@ describe.sequential("issue comment reopen routes", () => { expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - contextSnapshot: expect.objectContaining({ - issueId: "11111111-1111-4111-8111-111111111111", - wakeCommentId: "comment-1", - wakeReason: "issue_commented", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not implicitly reopen closed issues via POST comments when no agent is assigned", async () => { @@ -917,16 +888,8 @@ describe.sequential("issue comment reopen routes", () => { }), ); expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("retry-run-1"); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("does not move scheduled-retry issues to todo when PATCH comment retry cancellation fails", async () => { @@ -1028,16 +991,8 @@ describe.sequential("issue comment reopen routes", () => { "11111111-1111-4111-8111-111111111111", expect.objectContaining({ status: "todo" }), ); - await waitForWakeup(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - reason: "issue_commented", - payload: expect.objectContaining({ - commentId: "comment-1", - mutation: "comment", - }), - }), - )); + await waitForWakeup(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalled()); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 89b1b257..dceb8e4e 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -269,7 +269,7 @@ describe("issue update comment wakeups", () => { ); }); - it("wakes the assignee on comment-only issue updates", async () => { + it("does not wake the assignee on comment-only issue updates", async () => { const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, @@ -292,26 +292,10 @@ describe("issue update comment wakeups", () => { }); expect(res.status).toBe(200); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - ASSIGNEE_AGENT_ID, - expect.objectContaining({ - source: "automation", - reason: "issue_commented", - payload: expect.objectContaining({ - issueId: existing.id, - commentId: "comment-2", - mutation: "comment", - }), - contextSnapshot: expect.objectContaining({ - issueId: existing.id, - taskId: existing.id, - commentId: "comment-2", - wakeCommentId: "comment-2", - wakeReason: "issue_commented", - source: "issue.comment", - }), - }), - ); + await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith( + existing.companyId, + "please revise this", + )); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 40768a74..47d84ed3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1431,46 +1431,6 @@ export function issueRoutes( }; } - function queueAnnotationCommentWakeup(input: { - issue: { id: string; assigneeAgentId: string | null; status: string }; - actor: { actorType: "user" | "agent"; actorId: string }; - threadId: string; - commentId: string; - documentKey: string; - }) { - const assigneeId = input.issue.assigneeAgentId; - const selfComment = input.actor.actorType === "agent" && input.actor.actorId === assigneeId; - if (!assigneeId || selfComment || isClosedIssueStatus(input.issue.status)) return; - void heartbeat.wakeup(assigneeId, { - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { - issueId: input.issue.id, - annotationThreadId: input.threadId, - annotationCommentId: input.commentId, - documentKey: input.documentKey, - mutation: "document_annotation_comment", - }, - requestedByActorType: input.actor.actorType, - requestedByActorId: input.actor.actorId, - contextSnapshot: { - issueId: input.issue.id, - taskId: input.issue.id, - annotationThreadId: input.threadId, - annotationCommentId: input.commentId, - documentKey: input.documentKey, - source: "issue.document.annotation", - wakeReason: "issue_commented", - }, - }).catch((err) => logger.warn({ - err, - issueId: input.issue.id, - annotationThreadId: input.threadId, - annotationCommentId: input.commentId, - }, "failed to wake assignee on document annotation comment")); - } - async function canonicalizePaperclipArtifactMetadata(input: { issue: { id: string; companyId: string }; metadata: Record | null | undefined; @@ -3135,16 +3095,6 @@ export function issueRoutes( }, }); - if (firstComment) { - queueAnnotationCommentWakeup({ - issue, - actor, - threadId: thread.id, - commentId: firstComment.id, - documentKey: thread.documentKey, - }); - } - res.status(201).json(thread); }, ); @@ -3227,14 +3177,6 @@ export function issueRoutes( }, }); - queueAnnotationCommentWakeup({ - issue, - actor, - threadId: comment.threadId, - commentId: comment.id, - documentKey: keyParsed.data, - }); - res.status(201).json(comment); }, ); @@ -5546,20 +5488,17 @@ export function issueRoutes( if (commentBody && comment) { const assigneeId = issue.assigneeAgentId; - const actorIsAgent = actor.actorType === "agent"; - const selfComment = actorIsAgent && actor.actorId === assigneeId; - const skipAssigneeCommentWake = selfComment || isClosed; - if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) { + if (assigneeId && !assigneeChanged && reopened) { addWakeup(assigneeId, { source: "automation", triggerDetail: "system", - reason: reopened ? "issue_reopened_via_comment" : "issue_commented", + reason: "issue_reopened_via_comment", payload: { issueId: id, commentId: comment.id, mutation: "comment", - ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + reopenedFrom: reopenFromStatus, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -5570,9 +5509,9 @@ export function issueRoutes( taskId: id, commentId: comment.id, wakeCommentId: comment.id, - source: reopened ? "issue.comment.reopen" : "issue.comment", - wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", - ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + source: "issue.comment.reopen", + wakeReason: "issue_reopened_via_comment", + reopenedFrom: reopenFromStatus, ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -6714,62 +6653,33 @@ export function issueRoutes( const wakeups = new Map[1]>(); const assigneeId = currentIssue.assigneeAgentId; const actorIsAgent = actor.actorType === "agent"; - const selfComment = actorIsAgent && actor.actorId === assigneeId; - const skipWake = selfComment || isClosed; - if (assigneeId && (reopened || !skipWake)) { - if (reopened) { - wakeups.set(assigneeId, { - source: "automation", - triggerDetail: "system", - reason: "issue_reopened_via_comment", - payload: { - issueId: currentIssue.id, - commentId: comment.id, - reopenedFrom: reopenFromStatus, - mutation: "comment", - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - requestedByActorType: actor.actorType, - requestedByActorId: actor.actorId, - contextSnapshot: { - issueId: currentIssue.id, - taskId: currentIssue.id, - commentId: comment.id, - wakeCommentId: comment.id, - source: "issue.comment.reopen", - wakeReason: "issue_reopened_via_comment", - reopenedFrom: reopenFromStatus, - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - }); - } else { - wakeups.set(assigneeId, { - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { - issueId: currentIssue.id, - commentId: comment.id, - mutation: "comment", - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - requestedByActorType: actor.actorType, - requestedByActorId: actor.actorId, - contextSnapshot: { - issueId: currentIssue.id, - taskId: currentIssue.id, - commentId: comment.id, - wakeCommentId: comment.id, - source: "issue.comment", - wakeReason: "issue_commented", - ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), - ...(interruptedRunId ? { interruptedRunId } : {}), - }, - }); - } + if (assigneeId && reopened) { + wakeups.set(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: "issue_reopened_via_comment", + payload: { + issueId: currentIssue.id, + commentId: comment.id, + reopenedFrom: reopenFromStatus, + mutation: "comment", + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: currentIssue.id, + taskId: currentIssue.id, + commentId: comment.id, + wakeCommentId: comment.id, + source: "issue.comment.reopen", + wakeReason: "issue_reopened_via_comment", + reopenedFrom: reopenFromStatus, + ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }); } let mentionedIds: string[] = []; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6a7ec69e..b54cb59c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -528,7 +528,9 @@ export function extractMentionedSkillIdsFromSources( for (const source of sources) { if (typeof source !== "string" || source.length === 0) continue; for (const skillId of extractSkillMentionIds(source)) { - mentionedIds.add(skillId); + if (isUuidLike(skillId)) { + mentionedIds.add(skillId); + } } } return [...mentionedIds]; @@ -2289,6 +2291,20 @@ function normalizeInteractionContinuationWakeContext( clearInteractionContinuationWakeContext(contextSnapshot); } +function isAcceptedPlanContinuationWakeContext( + contextSnapshot: Record, + issueWorkMode?: string | null, +) { + return ( + readNonEmptyString(contextSnapshot.workspaceRefreshReason) === "accepted_plan_confirmation" || + ( + issueWorkMode === "planning" && + readNonEmptyString(contextSnapshot.interactionKind) === "request_confirmation" && + readNonEmptyString(contextSnapshot.interactionStatus) === "accepted" + ) + ); +} + type AcceptedPlanWakeRoutingDecision = { otherActiveClaimIssueId: string; otherActiveClaimIdentifier: string | null; @@ -2395,7 +2411,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; @@ -2415,6 +2431,28 @@ 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 = isAcceptedPlanContinuationWakeContext( + input.contextSnapshot, + issueSummary?.workMode, + ); + 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)) + .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 = @@ -2449,7 +2487,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 @@ -2587,6 +2625,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, @@ -2661,6 +2702,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); @@ -2721,6 +2767,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"); } @@ -7747,13 +7815,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) companyId: agent.companyId, agentId: agent.id, issueId, - acceptedPlanContinuationWake: - readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" - || ( - issueContext.workMode === "planning" - && readNonEmptyString(context.interactionKind) === "request_confirmation" - && readNonEmptyString(context.interactionStatus) === "accepted" - ), + acceptedPlanContinuationWake: isAcceptedPlanContinuationWakeContext(context, issueContext.workMode), contextSnapshot: context, }) : null; @@ -7892,6 +7954,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } else { delete context[PAPERCLIP_WAKE_PAYLOAD_KEY]; } + const acceptedPlanWakeRouting = parseObject(context.acceptedPlanWakeRouting); + const acceptedPlanContinuationForTask = + isAcceptedPlanContinuationWakeContext(context, issueRef?.workMode) && + Object.keys(acceptedPlanWakeRouting).length === 0; + 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 ? { @@ -7907,9 +7981,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 = {