feat(comments): add server-authoritative reply context

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-13 02:01:46 +00:00
1 parent 8a0db228a6
commit e785d0d5f2
9 files changed
+402 -7

No files matched your search

@@ -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",
+39 -1
View File
@@ -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]");
}
+10
View File
@@ -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[];
}
+32 -1
View File
@@ -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", () => {
+29 -2
View File
@@ -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<typeof issueCommentMetadataSchema>;
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(),
@@ -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",
@@ -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({
+49 -2
View File
@@ -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<typeof taskWatchdogService>;
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<IssueCommentMetadata | null> {
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<ReturnType<typeof svc.addComment>>; issue: NonNullable<Awaited<ReturnType<typeof svc.update>>> };
@@ -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),
});
}
+58 -1
View File
@@ -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<Record<string, unknown>> = [];
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 } : {}),
});
}