From c445e5925628d11bf59d52604b8aa63a6e9aa800 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 12 May 2026 01:20:49 -0700 Subject: [PATCH] fix(ui): fix message attribution for agent-posted comments with user author IDs (#5780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip’s issue chat is an audit surface: reviewers need to trust who actually authored a message. > - Some historical agent comments were persisted with `authorUserId` and no surviving `createdByRunId`, so the UI rendered real agent output as if it came from the board user. > - A pure timestamp-window fallback is too risky because human reviewers can comment while agents are running. > - The safe recovery path is to derive attribution only when the server can prove it from same-issue run logs that include the exact posted comment id, then let the chat renderer prefer that recovered agent attribution. > - This keeps historical threads trustworthy without mutating old database rows or guessing in ambiguous cases. ## What Changed - Added shared `IssueComment` fields for derived attribution so server and UI can carry recovered `derivedAuthorAgentId`, `derivedCreatedByRunId`, and `derivedAuthorSource` consistently. - Added server-side attribution recovery in `server/src/services/issues.ts` that reads same-issue run logs and only derives agent authorship when a run log contains the exact `comment id: ...` emitted during posting. - Updated issue chat rendering in `ui/src/lib/issue-chat-messages.ts` to prefer direct agent authorship, then activity-log `runAgentId`, then the server-derived attribution. - Removed the unsafe UI-only run-window fallback from `ui/src/pages/IssueDetail.tsx` so human comments posted during an active run are not silently relabeled as agent output. - Added regression coverage for both the run-log derivation path and the chat-rendering fallback behavior. - Bounded server-side run-log enrichment to 8 concurrent reads per request and removed the unused `issueCommentSchema` declaration during PR cleanup. ## Verification - `pnpm exec vitest run ui/src/lib/issue-chat-messages.test.ts server/src/__tests__/issues-service.test.ts` - `pnpm test:run:general` - Live validation on May 12, 2026 in `PAPA-322`: confirmed the previously misattributed historical comments on `PAPA-316` now render as Claude-authored on `http://goldie.gerbil-company.ts.net:3100`. - Reviewer check: open `PAPA-316` in the running instance and confirm historical comments such as `## Investigation: exe.dev 422 + codex re-test` render under Claude instead of the board user. ## Risks - Low risk. The change is scoped to comment attribution recovery and rendering. - Derived attribution is intentionally conservative: if there is no exact run-log proof, the comment remains user-authored instead of guessing. - Run-log recovery depends on retained same-issue logs, so older comments without that evidence remain unchanged. ## Model Used - OpenAI Codex via the Paperclip `codex_local` adapter (GPT-5-class coding agent with tool use in the local Paperclip runtime; the exact deployment/model ID is not surfaced by this workspace). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/types/issue.ts | 4 + server/src/__tests__/issues-service.test.ts | 70 ++++++- server/src/services/issues.ts | 221 +++++++++++++++++++- ui/src/lib/issue-chat-messages.test.ts | 66 ++++++ ui/src/lib/issue-chat-messages.ts | 35 +++- 5 files changed, 379 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 827c259f..43d796c3 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -399,6 +399,10 @@ export interface IssueComment { authorType: IssueCommentAuthorType; authorAgentId: string | null; authorUserId: string | null; + createdByRunId?: string | null; + derivedAuthorAgentId?: string | null; + derivedCreatedByRunId?: string | null; + derivedAuthorSource?: "run_log_comment_post" | null; body: string; presentation: IssueCommentPresentation | null; metadata: IssueCommentMetadata | null; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 2c705495..17d1a8ef 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -24,7 +24,12 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; -import { clampIssueListLimit, ISSUE_LIST_MAX_LIMIT, issueService } from "../services/issues.ts"; +import { + clampIssueListLimit, + deriveIssueCommentRunLogAttribution, + ISSUE_LIST_MAX_LIMIT, + issueService, +} from "../services/issues.ts"; import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); @@ -38,6 +43,69 @@ describe("issue list limit helpers", () => { }); }); +describe("deriveIssueCommentRunLogAttribution", () => { + it("recovers agent attribution from run logs that printed the posted comment id", () => { + const commentId = randomUUID(); + const runId = randomUUID(); + const agentId = randomUUID(); + + const derived = deriveIssueCommentRunLogAttribution( + [ + { + id: commentId, + authorAgentId: null, + authorUserId: "user-1", + createdByRunId: null, + createdAt: new Date("2026-05-11T18:55:40.090Z"), + }, + ], + [ + { + runId, + agentId, + createdAt: new Date("2026-05-11T18:51:56.246Z"), + startedAt: new Date("2026-05-11T18:51:56.257Z"), + finishedAt: new Date("2026-05-11T18:55:45.600Z"), + logContent: `comment id: ${commentId}\n`, + }, + ], + ); + + expect(derived.get(commentId)).toEqual({ + derivedAuthorAgentId: agentId, + derivedCreatedByRunId: runId, + derivedAuthorSource: "run_log_comment_post", + }); + }); + + it("does not rewrite comments without exact run-log proof", () => { + const commentId = randomUUID(); + const derived = deriveIssueCommentRunLogAttribution( + [ + { + id: commentId, + authorAgentId: null, + authorUserId: "user-1", + createdByRunId: null, + createdAt: new Date("2026-05-11T18:55:40.090Z"), + }, + ], + [ + { + runId: randomUUID(), + agentId: randomUUID(), + createdAt: new Date("2026-05-11T18:51:56.246Z"), + startedAt: new Date("2026-05-11T18:51:56.257Z"), + finishedAt: new Date("2026-05-11T18:55:45.600Z"), + logContent: "posted results without echoing the comment id", + }, + ], + ); + + expect(derived.has(commentId)).toBe(false); + }); +}); + async function ensureIssueRelationsTable(db: ReturnType) { await db.execute(sql.raw(` CREATE TABLE IF NOT EXISTS "issue_relations" ( diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 2d4ba3ff..402de33f 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -60,6 +60,7 @@ import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from ". import { instanceSettingsService } from "./instance-settings.js"; import { redactCurrentUserText } from "../log-redaction.js"; import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js"; +import { getRunLogStore } from "./run-log-store.js"; import { getDefaultCompanyGoal } from "./goals.js"; import { isVerifiedIssueTreeControlInteractionWake, @@ -76,6 +77,10 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; +const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000; +const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000; +const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000; +const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8; function assertTransition(from: string, to: string) { if (from === to) return; if (!ALL_ISSUE_STATUSES.includes(to)) { @@ -118,6 +123,86 @@ function buildReusedExecutionWorkspaceConfigPatchFromIssueSettings( }; } +function toTimestampMs(value: Date | string | null | undefined) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + const timestamp = date.getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +type IssueCommentRunLogAttributionCandidate = { + id: string; + createdAt: Date | string; + authorAgentId?: string | null; + authorUserId?: string | null; + createdByRunId?: string | null; +}; + +type IssueCommentRunLogAttributionRun = { + runId: string; + agentId: string; + createdAt: Date | string; + startedAt?: Date | string | null; + finishedAt?: Date | string | null; + logContent: string; +}; + +export function deriveIssueCommentRunLogAttribution( + comments: readonly IssueCommentRunLogAttributionCandidate[], + runs: readonly IssueCommentRunLogAttributionRun[], +) { + const derivedByCommentId = new Map(); + + for (const comment of comments) { + if (comment.authorAgentId || !comment.authorUserId || comment.createdByRunId) continue; + const commentCreatedAtMs = toTimestampMs(comment.createdAt); + if (commentCreatedAtMs === null) continue; + + let bestMatch: + | { + runId: string; + agentId: string; + distanceMs: number; + } + | null = null; + + for (const run of runs) { + const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt); + const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt); + if (runStartMs === null || runEndMs === null) continue; + if ( + commentCreatedAtMs < runStartMs + || commentCreatedAtMs > runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS + ) { + continue; + } + if (!run.logContent.includes(`comment id: ${comment.id}`)) continue; + + const distanceMs = Math.abs(runEndMs - commentCreatedAtMs); + if (!bestMatch || distanceMs < bestMatch.distanceMs) { + bestMatch = { + runId: run.runId, + agentId: run.agentId, + distanceMs, + }; + } + } + + if (!bestMatch) continue; + derivedByCommentId.set(comment.id, { + derivedAuthorAgentId: bestMatch.agentId, + derivedCreatedByRunId: bestMatch.runId, + derivedAuthorSource: "run_log_comment_post", + }); + } + + return derivedByCommentId; +} + export interface IssueFilters { status?: string; assigneeAgentId?: string; @@ -1779,6 +1864,124 @@ export function issueService(db: Db) { }; } + async function readRunLogText(run: { + logStore: string | null; + logRef: string | null; + logBytes: number | null; + }) { + if (run.logStore !== "local_file" || !run.logRef) return ""; + const logBytes = Number(run.logBytes ?? 0); + if (!Number.isFinite(logBytes) || logBytes <= 0) return ""; + + const store = getRunLogStore(); + let offset = 0; + let content = ""; + let nextOffset: number | undefined = 0; + + while (nextOffset !== undefined) { + const remainingBytes = ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES - Buffer.byteLength(content, "utf8"); + if (remainingBytes <= 0) break; + const chunk = await store.read( + { store: "local_file", logRef: run.logRef }, + { + offset, + limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes), + }, + ); + content += chunk.content; + nextOffset = chunk.nextOffset; + offset = chunk.nextOffset ?? 0; + } + + return content; + } + + async function enrichCommentsWithDerivedAgentAttribution< + T extends { + id: string; + companyId: string; + issueId: string; + authorAgentId?: string | null; + authorUserId?: string | null; + createdByRunId?: string | null; + createdAt: Date | string; + }, + >(comments: readonly T[]) { + const candidates = comments.filter((comment) => + !comment.authorAgentId + && !!comment.authorUserId + && !comment.createdByRunId, + ); + if (candidates.length === 0) return comments; + + const companyId = comments[0]?.companyId ?? null; + const issueId = comments[0]?.issueId ?? null; + if (!companyId || !issueId) return comments; + + const minCommentCreatedAtMs = candidates.reduce((min, comment) => { + const timestamp = toTimestampMs(comment.createdAt); + if (timestamp === null) return min; + return min === null ? timestamp : Math.min(min, timestamp); + }, null); + const maxCommentCreatedAtMs = candidates.reduce((max, comment) => { + const timestamp = toTimestampMs(comment.createdAt); + if (timestamp === null) return max; + return max === null ? timestamp : Math.max(max, timestamp); + }, null); + if (minCommentCreatedAtMs === null || maxCommentCreatedAtMs === null) return comments; + + const runs = await db + .select({ + runId: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + createdAt: heartbeatRuns.createdAt, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + logStore: heartbeatRuns.logStore, + logRef: heartbeatRuns.logRef, + logBytes: heartbeatRuns.logBytes, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + or( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, + sql`exists ( + select 1 + from ${activityLog} + where ${activityLog.companyId} = ${companyId} + and ${activityLog.entityType} = 'issue' + and ${activityLog.entityId} = ${issueId} + and ${activityLog.runId} = ${heartbeatRuns.id} + )`, + ), + sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${new Date(minCommentCreatedAtMs)}`, + sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${new Date(maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS)}`, + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)); + + if (runs.length === 0) return comments; + + const runsWithLogs: Array<(typeof runs)[number] & { logContent: string }> = []; + for (let index = 0; index < runs.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) { + const batch = runs.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS); + const batchWithLogs = await Promise.all(batch.map(async (run) => ({ + ...run, + logContent: await readRunLogText(run), + }))); + runsWithLogs.push(...batchWithLogs); + } + const derivedByCommentId = deriveIssueCommentRunLogAttribution(candidates, runsWithLogs); + if (derivedByCommentId.size === 0) return comments; + + return comments.map((comment) => { + const derived = derivedByCommentId.get(comment.id); + return derived ? { ...comment, ...derived } : comment; + }); + } + async function assertAssignableAgent(companyId: string, agentId: string) { const assignee = await db .select({ @@ -3778,7 +3981,8 @@ export function issueService(db: Db) { const comments = limit ? await query.limit(limit) : await query; const { censorUsernameInLogs } = await instanceSettings.getGeneral(); - return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs)); + const enrichedComments = await enrichCommentsWithDerivedAgentAttribution(comments); + return enrichedComments.map((comment) => redactIssueComment(comment, censorUsernameInLogs)); }, getCommentCursor: async (issueId: string) => { @@ -3809,16 +4013,17 @@ export function issueService(db: Db) { }; }, - getComment: (commentId: string) => - instanceSettings.getGeneral().then(({ censorUsernameInLogs }) => - db + getComment: async (commentId: string) => { + const { censorUsernameInLogs } = await instanceSettings.getGeneral(); + const comment = await db .select() .from(issueComments) .where(eq(issueComments.id, commentId)) - .then((rows) => { - const comment = rows[0] ?? null; - return comment ? redactIssueComment(comment, censorUsernameInLogs) : null; - })), + .then((rows) => rows[0] ?? null); + if (!comment) return null; + const [enrichedComment] = await enrichCommentsWithDerivedAgentAttribution([comment]); + return redactIssueComment(enrichedComment ?? comment, censorUsernameInLogs); + }, removeComment: async (commentId: string) => { const currentUserRedactionOptions = { diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index 0e2a6e1c..4aa62a71 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -324,6 +324,72 @@ describe("buildIssueChatMessages", () => { }); }); + it("prefers derived agent attribution when a board-authored comment is proven to come from a run", () => { + const agentMap = new Map([["agent-1", createAgent("agent-1", "Claude")]]); + const messages = buildIssueChatMessages({ + comments: [ + createComment({ + authorUserId: "user-1", + derivedAuthorAgentId: "agent-1", + derivedCreatedByRunId: "run-1", + }), + ], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + agentMap, + currentUserId: "user-1", + userLabelMap: new Map([["user-1", "Dotta"]]), + }); + + expect(messages[0]).toMatchObject({ + role: "assistant", + metadata: { + custom: { + authorName: "Claude", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: "user-1", + runId: "run-1", + runAgentId: "agent-1", + }, + }, + }); + }); + + it("renders a comment as agent-authored when runAgentId is set from activity log", () => { + const agentMap = new Map([["agent-1", createAgent("agent-1", "Claude")]]); + const messages = buildIssueChatMessages({ + comments: [ + createComment({ + authorUserId: "user-1", + runId: "run-1", + runAgentId: "agent-1", + }), + ], + timelineEvents: [], + linkedRuns: [], + liveRuns: [], + agentMap, + currentUserId: "user-1", + userLabelMap: new Map([["user-1", "Dotta"]]), + }); + + expect(messages[0]).toMatchObject({ + role: "assistant", + metadata: { + custom: { + authorName: "Claude", + authorType: "agent", + authorAgentId: "agent-1", + authorUserId: "user-1", + runId: "run-1", + runAgentId: "agent-1", + }, + }, + }); + }); + it("orders events before comments and appends active live runs as running assistant messages", () => { const agentMap = new Map([["agent-1", createAgent("agent-1", "CodexCoder")]]); const comments = [ diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 59d0ac90..2ff7cb8f 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -337,14 +337,32 @@ function createAssistantMetadata(custom: Record) { } as const; } +function effectiveCommentAuthorAgentId(comment: IssueChatComment) { + return comment.authorAgentId ?? comment.runAgentId ?? comment.derivedAuthorAgentId ?? null; +} + +function effectiveCommentRunId(comment: IssueChatComment) { + return comment.runId ?? comment.derivedCreatedByRunId ?? null; +} + +function effectiveCommentRunAgentId(comment: IssueChatComment) { + return comment.runAgentId ?? effectiveCommentAuthorAgentId(comment); +} + +function effectiveCommentAuthorType(comment: IssueChatComment) { + return effectiveCommentAuthorAgentId(comment) ? "agent" : comment.authorType; +} + function authorNameForComment( comment: IssueChatComment, agentMap?: Map, currentUserId?: string | null, userLabelMap?: ReadonlyMap | null, + options?: { isSystemNotice?: boolean }, ) { - if (comment.authorAgentId) { - return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8); + const authorAgentId = effectiveCommentAuthorAgentId(comment); + if (authorAgentId) { + return agentMap?.get(authorAgentId)?.name ?? (options?.isSystemNotice ? "Paperclip" : authorAgentId.slice(0, 8)); } const authorUserId = comment.authorUserId ?? null; if (!authorUserId) return "You"; @@ -367,20 +385,21 @@ function createCommentMessage(args: { }): ThreadMessage { const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args; const createdAt = toDate(comment.createdAt); - const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap); const isSystemNotice = comment.authorType === "system"; + const authorAgentId = effectiveCommentAuthorAgentId(comment); + const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, { isSystemNotice }); const custom = { kind: isSystemNotice ? "system_notice" : "comment", commentId: comment.id, anchorId: `comment-${comment.id}`, authorName, - authorType: comment.authorType, - authorAgentId: comment.authorAgentId, + authorType: effectiveCommentAuthorType(comment), + authorAgentId, authorUserId: comment.authorUserId, companyId: companyId ?? comment.companyId, projectId: projectId ?? null, - runId: comment.runId ?? null, - runAgentId: comment.runAgentId ?? null, + runId: effectiveCommentRunId(comment), + runAgentId: effectiveCommentRunAgentId(comment), clientStatus: comment.clientStatus ?? null, queueState: comment.queueState ?? null, queueTargetRunId: comment.queueTargetRunId ?? null, @@ -402,7 +421,7 @@ function createCommentMessage(args: { return message; } - if (comment.authorAgentId) { + if (authorAgentId) { const message: ThreadAssistantMessage = { id: comment.id, role: "assistant",