fix(ui): fix message attribution for agent-posted comments with user author IDs (#5780)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-05-12 01:20:49 -07:00 committed by GitHub
parent 9746dab4e8
commit c445e59256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 379 additions and 17 deletions

View file

@ -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<string, Agent>([["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<string, Agent>([["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<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const comments = [

View file

@ -337,14 +337,32 @@ function createAssistantMetadata(custom: Record<string, unknown>) {
} 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<string, Agent>,
currentUserId?: string | null,
userLabelMap?: ReadonlyMap<string, string> | 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",