diff --git a/server/src/__tests__/issue-comment-redaction.test.ts b/server/src/__tests__/issue-comment-redaction.test.ts index d53392056c..423f1ab345 100644 --- a/server/src/__tests__/issue-comment-redaction.test.ts +++ b/server/src/__tests__/issue-comment-redaction.test.ts @@ -187,6 +187,27 @@ describeEmbeddedPostgres("deleted issue comment redaction", () => { expect(JSON.stringify(wakePayload)).not.toContain("secret metadata"); }); + it("serializes comment timestamps as ISO strings through the redacted comments route (PAP-16607)", async () => { + const { companyId, issueId } = await seedIssue(); + const commentId = randomUUID(); + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorUserId: "board-user-1", + body: "ordinary comment", + }); + + const response = await request(createApp(companyId)).get(`/api/issues/${issueId}/comments`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body).toHaveLength(1); + // Secret redaction must not collapse Date instances to `{}` — the chat + // renderer needs parseable timestamps. + expect(typeof response.body[0].createdAt).toBe("string"); + expect(Number.isNaN(new Date(response.body[0].createdAt).getTime())).toBe(false); + expect(typeof response.body[0].updatedAt).toBe("string"); + }); + it("excludes deleted comment bodies from company search", async () => { const { companyId, issueId } = await seedIssue(); await db.insert(issueComments).values({ diff --git a/server/src/__tests__/run-secret-redaction.test.ts b/server/src/__tests__/run-secret-redaction.test.ts index 5bff578f14..fa2dd60a06 100644 --- a/server/src/__tests__/run-secret-redaction.test.ts +++ b/server/src/__tests__/run-secret-redaction.test.ts @@ -54,4 +54,25 @@ describe("registered run secret redaction", () => { expect(redactRegisteredSecretValues("token-extended token", ["token-extended", "token"])) .toBe(`${REDACTED_EVENT_VALUE} ${REDACTED_EVENT_VALUE}`); }); + + it("preserves Date instances instead of collapsing them to empty objects (PAP-16607)", () => { + const createdAt = new Date("2026-08-06T12:00:00.000Z"); + const result = redactRegisteredSecretValues({ + comment: { body: `agent pasted ${secret}`, createdAt, updatedAt: createdAt }, + nested: [{ finishedAt: createdAt }], + }, [secret]); + + expect(result.comment.createdAt).toBeInstanceOf(Date); + expect(result.comment.createdAt.toISOString()).toBe("2026-08-06T12:00:00.000Z"); + expect(result.comment.updatedAt).toBeInstanceOf(Date); + expect(result.nested[0]?.finishedAt).toBeInstanceOf(Date); + expect(result.comment.body).toBe(`agent pasted ${REDACTED_EVENT_VALUE}`); + }); + + it("preserves Date instances when no secret values are registered", () => { + const createdAt = new Date("2026-08-06T12:00:00.000Z"); + const result = redactRegisteredSecretValues({ createdAt }, []); + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt.toISOString()).toBe("2026-08-06T12:00:00.000Z"); + }); }); diff --git a/server/src/services/run-secret-redaction.ts b/server/src/services/run-secret-redaction.ts index 4604d54ada..4ca7963e95 100644 --- a/server/src/services/run-secret-redaction.ts +++ b/server/src/services/run-secret-redaction.ts @@ -42,6 +42,9 @@ function redactText(input: string, values: string[]) { export function redactRegisteredSecretValues(input: T, values: string[]): T { if (typeof input === "string") return redactText(input, values) as T; if (Array.isArray(input)) return input.map((value) => redactRegisteredSecretValues(value, values)) as T; + // Dates carry no redactable text; rebuilding them via Object.entries would + // collapse them to `{}` and break every timestamp in redacted responses. + if (input instanceof Date) return input; const record = asRecord(input); if (!record) return input; return Object.fromEntries( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index c540cab21a..c6af475e50 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -773,6 +773,18 @@ function toIsoString(value: string | Date | null | undefined): string | null { return typeof value === "string" ? value : value.toISOString(); } +/** + * ISO timestamp for display, or undefined when the value does not parse as a + * real date. Comment timestamps can arrive malformed (e.g. a server + * serialization bug turning Dates into `{}`); formatting must degrade to "no + * timestamp" instead of throwing mid-render (PAP-16607). + */ +function toValidIsoString(value: Date | string | number | undefined): string | undefined { + if (value === undefined || value === null) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + function loadDraft(draftKey: string): string { try { return localStorage.getItem(draftKey) ?? ""; @@ -2737,7 +2749,7 @@ function SystemNoticeCommentRow({ {bodyText} ), - timestamp: message.createdAt ? new Date(message.createdAt).toISOString() : undefined, + timestamp: toValidIsoString(message.createdAt), source, runAgentId, }); diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index 410d6b3068..8419c54ebe 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -150,6 +150,34 @@ describe("IssueChatThread system notice routing", () => { expect(container.querySelectorAll('[data-message-role="user"]').length).toBe(0); }); + it("renders a system notice with a malformed createdAt without tripping the error boundary (PAP-16607)", () => { + const comment: IssueChatComment = { + id: "comment-system-bad-date", + companyId: "company-1", + issueId: "issue-1", + authorType: "system", + authorAgentId: null, + authorUserId: null, + body: "Workspace ready.", + presentation: { + kind: "system_notice", + tone: "info", + title: "Workspace ready", + detailsDefaultOpen: false, + }, + metadata: { version: 1, sections: [] }, + // A server serialization bug (Dates collapsed to `{}` by secret + // redaction) shipped comments whose timestamps parse to Invalid Date. + createdAt: {} as unknown as Date, + updatedAt: {} as unknown as Date, + }; + + renderThread([comment]); + + expect(container.textContent).not.toContain("Chat renderer hit an internal state error."); + expect(container.textContent).toContain("Workspace ready"); + }); + it("expands metadata when detailsDefaultOpen is true", () => { const comment: IssueChatComment = { id: "comment-system-open",