diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 0b59b309195..93206018cdd 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -843,6 +843,47 @@ describe("renderPaperclipWakePrompt", () => { expect(fallbackPrompt).toContain("- fallback fetch needed: yes"); }); + it("renders replied-to comment context and a fetch path when truncated", () => { + const issueId = "11111111-1111-4111-8111-111111111111"; + const replyTargetId = "22222222-2222-4222-8222-222222222222"; + const prompt = renderPaperclipWakePrompt({ + reason: "issue_commented", + issue: { + id: issueId, + identifier: "PAP-1581", + title: "Handle a direct reply", + status: "in_progress", + }, + commentWindow: { + requestedCount: 1, + includedCount: 1, + missingCount: 0, + }, + comments: [{ + id: "33333333-3333-4333-8333-333333333333", + issueId, + body: "This is my reply.", + bodyTruncated: false, + createdAt: "2026-07-13T01:00:00.000Z", + author: { type: "user", id: "board-user" }, + replyToComment: { + id: replyTargetId, + body: "Full original body prefix", + bodyTruncated: true, + createdAt: "2026-07-13T00:00:00.000Z", + author: { type: "agent", id: "agent-1" }, + }, + }], + fallbackFetchNeeded: false, + }); + + expect(prompt).toContain("This comment is a reply to an earlier comment by agent agent-1"); + expect(prompt).toContain("Treat that earlier comment as the direct subject of this reply."); + expect(prompt).toContain("Full original body prefix"); + expect(prompt).toContain(`GET /api/issues/${issueId}/comments/${replyTargetId}`); + expect(prompt).toContain("Replying comment:\nThis is my reply."); + }); + it("renders the execution workspace branch guard only on non-resumed sessions", () => { const payload = { reason: "issue_assigned", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 5c5a9c5ee7d..c46ca4a937d 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -450,6 +450,14 @@ type PaperclipWakeComment = { createdAt: string | null; authorType: string | null; authorId: string | null; + replyToComment: { + id: string | null; + body: string; + bodyTruncated: boolean; + createdAt: string | null; + authorType: string | null; + authorId: string | null; + } | null; }; type PaperclipWakePlanReviewAuthor = { @@ -668,6 +676,9 @@ function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | null { const comment = parseObject(value); const author = parseObject(comment.author); + const replyToComment = parseObject(comment.replyToComment); + const replyToAuthor = parseObject(replyToComment.author); + const replyToBody = asString(replyToComment.body, ""); const body = asString(comment.body, ""); if (!body.trim()) return null; return { @@ -678,6 +689,16 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n createdAt: asString(comment.createdAt, "").trim() || null, authorType: asString(author.type, "").trim() || null, authorId: asString(author.id, "").trim() || null, + replyToComment: replyToBody.trim() + ? { + id: asString(replyToComment.id, "").trim() || null, + body: replyToBody, + bodyTruncated: asBoolean(replyToComment.bodyTruncated, false), + createdAt: asString(replyToComment.createdAt, "").trim() || null, + authorType: asString(replyToAuthor.type, "").trim() || null, + authorId: asString(replyToAuthor.id, "").trim() || null, + } + : null, }; } @@ -1676,8 +1697,25 @@ export function renderPaperclipWakePrompt( : comment.authorType ?? "unknown"; lines.push( `${index + 1}. comment ${comment.id ?? "unknown"} at ${comment.createdAt ?? "unknown"} by ${authorLabel}`, - comment.body, ); + if (comment.replyToComment) { + const replyAuthorLabel = comment.replyToComment.authorId + ? `${comment.replyToComment.authorType ?? "unknown"} ${comment.replyToComment.authorId}` + : comment.replyToComment.authorType ?? "unknown"; + lines.push( + `This comment is a reply to an earlier comment by ${replyAuthorLabel} (${comment.replyToComment.createdAt ?? "unknown"}).`, + "Treat that earlier comment as the direct subject of this reply.", + "Full original comment:", + comment.replyToComment.body, + ); + if (comment.replyToComment.bodyTruncated) { + lines.push( + `[original comment body truncated; fetch the rest via GET /api/issues/${comment.issueId ?? "{issueId}"}/comments/${comment.replyToComment.id ?? "{commentId}"}]`, + ); + } + lines.push("Replying comment:"); + } + lines.push(comment.body); if (comment.bodyTruncated) { lines.push("[comment body truncated]"); } diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index d9c3324a47c..00d4041f37d 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -922,9 +922,19 @@ export interface IssueCommentMetadataSection { rows: IssueCommentMetadataRow[]; } +export interface IssueCommentReplyToMetadata { + commentId: string; + authorType: IssueCommentAuthorType; + authorAgentId?: string | null; + authorUserId?: string | null; + excerpt: string; + excerptTruncated: boolean; +} + export interface IssueCommentMetadata { version: 1; sourceRunId?: string | null; + replyTo?: IssueCommentReplyToMetadata | null; sections: IssueCommentMetadataSection[]; } diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index f766dd78900..8aa4a982b48 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -184,7 +184,38 @@ describe("issue validators", () => { expect(parsed.presentation?.detailsDefaultOpen).toBe(false); expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111"); - expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3); + expect(parsed.metadata?.sections?.[0]?.rows).toHaveLength(3); + }); + + it("accepts only a reply target id from comment clients", () => { + const parsed = addIssueCommentSchema.parse({ + body: "Following up on that point.", + metadata: { + replyTo: { + commentId: "11111111-1111-4111-8111-111111111111", + }, + }, + }); + + expect(parsed.metadata).toEqual({ + replyTo: { + commentId: "11111111-1111-4111-8111-111111111111", + }, + }); + }); + + it("rejects client-supplied reply snapshots", () => { + const parsed = addIssueCommentSchema.safeParse({ + body: "Forged quote attempt.", + metadata: { + replyTo: { + commentId: "11111111-1111-4111-8111-111111111111", + excerpt: "The target never said this.", + }, + }, + }); + + expect(parsed.success).toBe(false); }); it("rejects arbitrary issue comment metadata", () => { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 0726a0cf752..e3a1c8c0a9f 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -554,19 +554,46 @@ export const issueCommentMetadataSectionSchema = z.object({ rows: z.array(issueCommentMetadataRowSchema).min(1).max(50), }).strict(); +const issueCommentReplyToMetadataSchema = z.object({ + commentId: z.string().uuid(), + authorType: issueCommentAuthorTypeSchema, + authorAgentId: z.string().uuid().nullable().optional(), + authorUserId: z.string().trim().min(1).nullable().optional(), + excerpt: z.string(), + excerptTruncated: z.boolean(), +}).strict(); + export const issueCommentMetadataSchema = z.object({ version: z.literal(1), sourceRunId: z.string().uuid().nullable().optional(), - sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20), + replyTo: issueCommentReplyToMetadataSchema.nullable().optional(), + sections: z.array(issueCommentMetadataSectionSchema).max(20), }).strict(); export type IssueCommentMetadata = z.infer; +const addIssueCommentMetadataSchema = z.object({ + version: z.literal(1).optional(), + sourceRunId: z.string().uuid().nullable().optional(), + sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20).optional(), + replyTo: z.object({ + commentId: z.string().uuid(), + }).strict().optional(), +}).strict().superRefine((value, ctx) => { + if (!value.replyTo && (!value.version || !value.sections)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Comment metadata requires structured sections or a reply target", + path: ["sections"], + }); + } +}); + export const addIssueCommentSchema = z.object({ body: multilineTextSchema.pipe(z.string().min(1)), authorType: issueCommentAuthorTypeSchema.optional(), presentation: issueCommentPresentationSchema.nullable().optional(), - metadata: issueCommentMetadataSchema.nullable().optional(), + metadata: addIssueCommentMetadataSchema.nullable().optional(), reopen: z.boolean().optional(), resume: z.boolean().optional(), interrupt: z.boolean().optional(), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 821a5c37f0a..9eee2c420d9 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -792,6 +792,46 @@ describe("agent issue mutation checkout ownership", () => { expect(mockIssueService.update).not.toHaveBeenCalled(); }); + it.each([ + ["missing", null], + ["cross-issue", { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + issueId: "99999999-9999-4999-8999-999999999999", + companyId, + body: "Other issue body", + authorType: "user", + authorAgentId: null, + authorUserId: "other-user", + deletedAt: null, + }], + ["deleted", { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + issueId, + companyId, + body: "Deleted body", + authorType: "user", + authorAgentId: null, + authorUserId: "board-user", + deletedAt: new Date("2026-07-13T00:00:00.000Z"), + }], + ])("rejects a %s reply target with 422", async (_case, replyTarget) => { + mockIssueService.getComment.mockResolvedValueOnce(replyTarget); + + const res = await request(await createApp(boardActor())) + .post(`/api/issues/${issueId}/comments`) + .send({ + body: "Reply body", + metadata: { + replyTo: { + commentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }, + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(mockIssueService.addComment).not.toHaveBeenCalled(); + }); + it("rejects non-mentioned peer agents from posting comments", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ allowed: input.action === "issue:read", diff --git a/server/src/__tests__/issue-comment-redaction.test.ts b/server/src/__tests__/issue-comment-redaction.test.ts index d53392056c3..9e67f483c93 100644 --- a/server/src/__tests__/issue-comment-redaction.test.ts +++ b/server/src/__tests__/issue-comment-redaction.test.ts @@ -4,6 +4,7 @@ import request from "supertest"; import { sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + activityLog, companies, companyMemberships, createDb, @@ -45,6 +46,7 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => { afterEach(async () => { await db.delete(issueReferenceMentions); + await db.delete(activityLog); await db.delete(issueComments); await db.delete(issues); await db.delete(companyMemberships); @@ -187,6 +189,108 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => { expect(JSON.stringify(wakePayload)).not.toContain("secret metadata"); }); + it("injects the canonical replied-to comment into wake payloads", async () => { + const { companyId, issueId } = await seedIssue(); + const targetCommentId = randomUUID(); + const replyCommentId = randomUUID(); + const targetBody = "o".repeat(4_005); + await db.insert(issueComments).values([ + { + id: targetCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + authorType: "user", + body: targetBody, + createdAt: new Date("2026-07-13T00:00:00.000Z"), + }, + { + id: replyCommentId, + companyId, + issueId, + authorUserId: "board-user-1", + authorType: "user", + body: "Reply body", + metadata: { + version: 1, + sections: [], + replyTo: { + commentId: targetCommentId, + authorType: "user", + authorAgentId: null, + authorUserId: "board-user-1", + excerpt: targetBody.slice(0, 200), + excerptTruncated: true, + }, + }, + createdAt: new Date("2026-07-13T00:01:00.000Z"), + }, + ]); + + const wakePayload = await buildPaperclipWakePayload({ + db, + companyId, + contextSnapshot: { + issueId, + commentId: replyCommentId, + wakeCommentIds: [replyCommentId], + wakeReason: "issue_commented", + }, + }); + + expect(wakePayload?.comments).toEqual([ + expect.objectContaining({ + id: replyCommentId, + replyToComment: { + id: targetCommentId, + author: { type: "user", id: "board-user-1" }, + createdAt: "2026-07-13T00:00:00.000Z", + body: targetBody.slice(0, 4_000), + bodyTruncated: true, + }, + }), + ]); + }); + + it("stores a server-authored truncated reply snapshot", async () => { + const { companyId, issueId } = await seedIssue(); + const targetCommentId = randomUUID(); + const targetBody = "x".repeat(205); + await db.insert(issueComments).values({ + id: targetCommentId, + companyId, + issueId, + authorType: "user", + authorUserId: "board-user-1", + body: targetBody, + }); + + const response = await request(createApp(companyId)) + .post(`/api/issues/${issueId}/comments`) + .send({ + body: "Reply body", + metadata: { + replyTo: { + commentId: targetCommentId, + }, + }, + }); + + expect(response.status, JSON.stringify(response.body)).toBe(201); + expect(response.body.metadata).toEqual({ + version: 1, + sections: [], + replyTo: { + commentId: targetCommentId, + authorType: "user", + authorAgentId: null, + authorUserId: "board-user-1", + excerpt: "x".repeat(200), + excerptTruncated: true, + }, + }); + }); + it("excludes deleted comment bodies from company search", async () => { const { companyId, issueId } = await seedIssue(); await db.insert(issueComments).values({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 391c5a25b33..33687506711 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -63,6 +63,7 @@ import { isUuidLike, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompactIssue, + type AddIssueComment, type CompanySearchQuery, type CompanySearchResponse, type ExecutionWorkspace, @@ -71,6 +72,7 @@ import { type IssueBlockerDiagnosticNode, type IssueBlockerDiagnosticsReadiness, type IssueBlockerDiagnosticsResponse, + type IssueCommentMetadata, type IssueSubtreeDiagnosticEdge, type IssueSubtreeDiagnosticNode, type IssueSubtreeDiagnosticsResponse, @@ -283,6 +285,8 @@ type SuccessfulRunHandoffActivityRow = { type TaskWatchdogService = ReturnType; type TaskWatchdogServiceFactory = typeof taskWatchdogService; +const ISSUE_COMMENT_REPLY_EXCERPT_CHARS = 200; + function applyCreateIssueStatusDefault(req: Request, res: Response, next: () => void) { if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) { next(); @@ -3979,6 +3983,48 @@ export function issueRoutes( return false; } + async function resolveIssueCommentMetadata( + issueId: string, + requestedMetadata: AddIssueComment["metadata"], + ): Promise { + if (!requestedMetadata) return null; + + const replyTargetId = requestedMetadata.replyTo?.commentId; + if (!replyTargetId) { + return { + version: requestedMetadata.version ?? 1, + ...(requestedMetadata.sourceRunId !== undefined + ? { sourceRunId: requestedMetadata.sourceRunId } + : {}), + sections: requestedMetadata.sections ?? [], + }; + } + + const replyTarget = await svc.getComment(replyTargetId); + if (!replyTarget || replyTarget.issueId !== issueId || replyTarget.deletedAt) { + throw unprocessable("Reply target comment is missing, belongs to another issue, or was deleted"); + } + + const excerpt = replyTarget.body.slice(0, ISSUE_COMMENT_REPLY_EXCERPT_CHARS); + return { + version: requestedMetadata.version ?? 1, + ...(requestedMetadata.sourceRunId !== undefined + ? { sourceRunId: requestedMetadata.sourceRunId } + : {}), + sections: requestedMetadata.sections ?? [], + replyTo: { + commentId: replyTarget.id, + authorType: + replyTarget.authorType ?? + (replyTarget.authorAgentId ? "agent" : replyTarget.authorUserId ? "user" : "system"), + authorAgentId: replyTarget.authorAgentId ?? null, + authorUserId: replyTarget.authorUserId ?? null, + excerpt, + excerptTruncated: excerpt.length < replyTarget.body.length, + }, + }; + } + async function assertExplicitResumeIntentAllowed( req: Request, res: Response, @@ -9585,6 +9631,7 @@ export function issueRoutes( presentation: req.body.presentation, metadata: req.body.metadata, })) return; + const commentMetadata = await resolveIssueCommentMetadata(id, req.body.metadata); const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue); if (closedExecutionWorkspace) { respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace); @@ -9803,7 +9850,7 @@ export function issueRoutes( const commentOptions = { authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"), presentation: req.body.presentation ?? null, - metadata: req.body.metadata ?? null, + metadata: commentMetadata, sourceTrust, }; let txResult: { comment: Awaited>; issue: NonNullable>> }; @@ -9887,7 +9934,7 @@ export function issueRoutes( }, { authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"), presentation: req.body.presentation ?? null, - metadata: req.body.metadata ?? null, + metadata: commentMetadata, sourceTrust: await sourceTrustForActorWrite(currentIssue, actor), }); } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2c792bef198..f231832d401 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4145,7 +4145,38 @@ export async function buildPaperclipWakePayload(input: { ), ); + const replyTargetIds = Array.from(new Set(commentRows.flatMap((comment) => { + const metadata = parseObject(comment.metadata); + const replyTo = parseObject(metadata.replyTo); + const commentId = readNonEmptyString(replyTo.commentId); + return commentId ? [commentId] : []; + }))); + const replyTargetRows = replyTargetIds.length === 0 + ? [] + : await input.db + .select({ + id: issueComments.id, + issueId: issueComments.issueId, + body: issueComments.body, + authorType: issueComments.authorType, + authorAgentId: issueComments.authorAgentId, + authorUserId: issueComments.authorUserId, + presentation: issueComments.presentation, + metadata: issueComments.metadata, + deletedAt: issueComments.deletedAt, + sourceTrust: issueComments.sourceTrust, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + inArray(issueComments.id, replyTargetIds), + ), + ); + const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); + const replyTargetsById = new Map(replyTargetRows.map((comment) => [comment.id, comment])); const comments: Array> = []; let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; let truncated = false; @@ -4181,6 +4212,31 @@ export async function buildPaperclipWakePayload(input: { if (bodyTruncated) truncated = true; remainingBodyChars -= body.length; + const metadata = deletedAt ? null : safeRow.metadata ?? null; + const replyToCommentId = readNonEmptyString(parseObject(parseObject(metadata).replyTo).commentId); + const replyTarget = replyToCommentId ? replyTargetsById.get(replyToCommentId) : null; + const safeReplyTarget = replyTarget && !input.exposeLowTrustRaw + ? sanitizeQuarantinedCommentForHigherTrust(replyTarget) + : replyTarget; + const replyToComment = safeReplyTarget && + safeReplyTarget.issueId === row.issueId && + !safeReplyTarget.deletedAt + ? { + id: safeReplyTarget.id, + author: safeReplyTarget.authorAgentId + ? { type: "agent", id: safeReplyTarget.authorAgentId } + : safeReplyTarget.authorUserId + ? { type: "user", id: safeReplyTarget.authorUserId } + : { type: safeReplyTarget.authorType ?? "system", id: null }, + createdAt: safeReplyTarget.createdAt.toISOString(), + body: safeReplyTarget.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS + ? safeReplyTarget.body.slice(0, MAX_INLINE_WAKE_COMMENT_BODY_CHARS) + : safeReplyTarget.body, + bodyTruncated: safeReplyTarget.body.length > MAX_INLINE_WAKE_COMMENT_BODY_CHARS, + } + : null; + if (replyToComment?.bodyTruncated) truncated = true; + comments.push({ id: row.id, issueId: row.issueId, @@ -4188,7 +4244,7 @@ export async function buildPaperclipWakePayload(input: { body, bodyTruncated, presentation: deletedAt ? null : safeRow.presentation ?? null, - metadata: deletedAt ? null : safeRow.metadata ?? null, + metadata, deletedAt: deletedAt ? deletedAt.toISOString() : null, deletedByType: deletedAt ? row.deletedByType ?? null : null, deletedByAgentId: deletedAt ? row.deletedByAgentId ?? null : null, @@ -4201,6 +4257,7 @@ export async function buildPaperclipWakePayload(input: { : row.authorUserId ? { type: "user", id: row.authorUserId } : { type: "system", id: null }, + ...(replyToComment ? { replyToComment } : {}), }); }