forked from farhoodlabs/paperclip
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:
@@ -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<typeof createDb>) {
|
||||
await db.execute(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS "issue_relations" (
|
||||
|
||||
@@ -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<string, {
|
||||
derivedAuthorAgentId: string;
|
||||
derivedCreatedByRunId: string;
|
||||
derivedAuthorSource: "run_log_comment_post";
|
||||
}>();
|
||||
|
||||
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<number | null>((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<number | null>((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 = {
|
||||
|
||||
Reference in New Issue
Block a user