From 31aac7c0de9255fbbc429fc91f3bd80079d50fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 24 Apr 2026 10:35:11 -0400 Subject: [PATCH] fix(messages): normalize escaped markdown newlines --- src/main/database/activity.ts | 6 ++-- src/main/database/messages.ts | 4 ++- src/main/messaging/dispatch.ts | 3 +- .../communication/SearchResults.tsx | 4 ++- .../components/communication/ThreadPanel.tsx | 5 ++- src/renderer/components/ui/MarkdownBody.tsx | 5 ++- src/shared/markdown-text.ts | 12 +++++++ tests/unit/markdown-text.test.ts | 12 +++++++ tests/unit/message-dispatch.test.ts | 34 +++++++++++++++++++ 9 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 src/shared/markdown-text.ts create mode 100644 tests/unit/markdown-text.test.ts diff --git a/src/main/database/activity.ts b/src/main/database/activity.ts index 22d286f7..2a8239ca 100644 --- a/src/main/database/activity.ts +++ b/src/main/database/activity.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; import type { CommentRecord } from "@shared/types"; +import { normalizeMarkdownText } from "@shared/markdown-text"; import type { ReadAll } from "./helpers"; import { nowIso } from "./helpers"; @@ -19,12 +20,13 @@ export function buildActivityMethods( const taskRow = getDb().prepare("select company_id from tasks where id = ?").get(input.taskId) as { company_id: string } | undefined; if (!taskRow) throw new Error("Task not found."); if (taskRow.company_id !== input.companyId) throw new Error("Task does not belong to this company."); + const body = normalizeMarkdownText(input.body); const id = randomUUID(); const ts = nowIso(); getDb().prepare( "insert into task_comments (id, company_id, task_id, author_agent_id, author_name, body, created_at) values (?, ?, ?, ?, ?, ?, ?)", - ).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, input.body, ts); - return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body: input.body, createdAt: ts }; + ).run(id, input.companyId, input.taskId, input.authorAgentId, input.authorName, body, ts); + return { id, companyId: input.companyId, taskId: input.taskId, authorAgentId: input.authorAgentId, authorName: input.authorName, body, createdAt: ts }; }, listComments(taskId: string): CommentRecord[] { diff --git a/src/main/database/messages.ts b/src/main/database/messages.ts index 73243bcb..f17606d7 100644 --- a/src/main/database/messages.ts +++ b/src/main/database/messages.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { DatabaseSync, SQLInputValue } from "node:sqlite"; import log from "electron-log/main.js"; import type { AgentMessageRecord, AgentRecord } from "@shared/types"; +import { normalizeMarkdownText } from "@shared/markdown-text"; import { BOARD_READER_ID, type ReadAll, @@ -66,6 +67,7 @@ export function buildMessageMethods( } const senderKind = input.senderKind ?? (input.fromAgentId === BOARD_READER_ID ? "human" : "agent"); + const body = normalizeMarkdownText(input.body); const id = randomUUID(); const createdAt = nowIso(); // Single transaction: the row and its mention fan-out must land @@ -86,7 +88,7 @@ export function buildMessageMethods( channel, input.channelTargetId ?? null, input.subject, - input.body, + body, input.priority ?? "normal", input.parentMessageId ?? null, input.attachmentsJson ?? "[]", diff --git a/src/main/messaging/dispatch.ts b/src/main/messaging/dispatch.ts index e637d8ed..6db68932 100644 --- a/src/main/messaging/dispatch.ts +++ b/src/main/messaging/dispatch.ts @@ -1,5 +1,6 @@ import type { AppDatabase } from "../database"; import type { AgentMessageRecord, DesktopEvent, MessageChannel, MentionResult, SectionId } from "@shared/types"; +import { normalizeMarkdownText } from "@shared/markdown-text"; /** * The board reader / human participant ID. @@ -120,7 +121,7 @@ export function normalizeAgentMessageInput(input: AgentMessageDispatchInput): No channel, channelTargetId, subject, - body: input.body, + body: normalizeMarkdownText(input.body), priority, parentMessageId, threadId, diff --git a/src/renderer/components/communication/SearchResults.tsx b/src/renderer/components/communication/SearchResults.tsx index 46606b7b..22519e0d 100644 --- a/src/renderer/components/communication/SearchResults.tsx +++ b/src/renderer/components/communication/SearchResults.tsx @@ -8,6 +8,7 @@ import { formatMessageTime } from "../../lib/formatters" import { EmptyState, useReducedMotion } from "../ui" import { useRovingTabIndex } from "../../lib/keyboard" import { useT } from "../../i18n" +import { normalizeMarkdownText } from "@shared/markdown-text" type ChannelConfig = { labelKey: string; icon: React.ElementType; color: string } @@ -121,6 +122,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes const message = results[virtualRow.index] const agent = agentMap.get(message.fromAgentId) const config = channelConfig[message.channel] + const normalizedBody = normalizeMarkdownText(message.body) const ChannelIcon = config.icon return ( @@ -175,7 +177,7 @@ export function SearchResults({ results, query, loading, agents, onNavigateToRes {/* Row 3: Body snippet */}
- {highlightMatch(message.body, query)} + {highlightMatch(normalizedBody, query)}
diff --git a/src/renderer/components/communication/ThreadPanel.tsx b/src/renderer/components/communication/ThreadPanel.tsx index 8e0eb08f..98490cab 100644 --- a/src/renderer/components/communication/ThreadPanel.tsx +++ b/src/renderer/components/communication/ThreadPanel.tsx @@ -14,6 +14,7 @@ import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator" import { MessageComposer } from "./MessageComposer" import { formatMessageTime } from "../../lib/formatters" import { useT, type TranslationFn } from "../../i18n" +import { MarkdownBody } from "../ui/MarkdownBody" interface ThreadPanelProps { threadId: string @@ -51,7 +52,9 @@ function EventItem({ event, agents, t }: { event: ConversationEventRecord; agent {actorName} {time} -

{event.body}

+
+ +
) diff --git a/src/renderer/components/ui/MarkdownBody.tsx b/src/renderer/components/ui/MarkdownBody.tsx index c7e5ac8c..47c7bf64 100644 --- a/src/renderer/components/ui/MarkdownBody.tsx +++ b/src/renderer/components/ui/MarkdownBody.tsx @@ -1,6 +1,7 @@ import { memo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { normalizeMarkdownText } from "@shared/markdown-text"; /** * Read-only markdown renderer used by message bubbles, comment bodies, and @@ -21,6 +22,8 @@ import remarkGfm from "remark-gfm"; * wrapping container. */ export const MarkdownBody = memo(function MarkdownBody({ body }: { body: string }) { + const normalizedBody = normalizeMarkdownText(body); + return (
, }} > - {body} + {normalizedBody}
); }); diff --git a/src/shared/markdown-text.ts b/src/shared/markdown-text.ts new file mode 100644 index 00000000..ea572c5d --- /dev/null +++ b/src/shared/markdown-text.ts @@ -0,0 +1,12 @@ +const ACTUAL_LINE_BREAKS = /\r\n?/g; +const ESCAPED_CRLF = /\\r\\n/g; +const ESCAPED_LF = /\\n/g; +const ESCAPED_CR = /\\r/g; + +export function normalizeMarkdownText(text: string): string { + return text + .replace(ACTUAL_LINE_BREAKS, "\n") + .replace(ESCAPED_CRLF, "\n") + .replace(ESCAPED_LF, "\n") + .replace(ESCAPED_CR, "\n"); +} diff --git a/tests/unit/markdown-text.test.ts b/tests/unit/markdown-text.test.ts new file mode 100644 index 00000000..f3d5d4bf --- /dev/null +++ b/tests/unit/markdown-text.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { normalizeMarkdownText } from "../../src/shared/markdown-text"; + +describe("normalizeMarkdownText", () => { + it("converts escaped markdown line breaks into renderable markdown", () => { + expect(normalizeMarkdownText("Intro\\n\\n## Plan\\n- First\\n- Second")).toBe("Intro\n\n## Plan\n- First\n- Second"); + }); + + it("normalizes actual CRLF input to LF", () => { + expect(normalizeMarkdownText("One\r\nTwo\rThree")).toBe("One\nTwo\nThree"); + }); +}); diff --git a/tests/unit/message-dispatch.test.ts b/tests/unit/message-dispatch.test.ts index ad85c21c..245eea30 100644 --- a/tests/unit/message-dispatch.test.ts +++ b/tests/unit/message-dispatch.test.ts @@ -200,6 +200,40 @@ describe("message dispatch", () => { db.close(); }); + it("normalizes escaped markdown line breaks before persisting messages and comments", async () => { + const profileDir = await createTempDir("agentcompany-message-markdown-"); + const db = new AppDatabase(profileDir); + db.init(); + + const { companyId, ceoId, ctoId, taskId } = createScaffold(db); + + const result = dispatchAgentMessage({ + db, + }, { + companyId, + fromAgentId: ctoId, + channel: "direct", + toAgentId: ceoId, + subject: "Markdown update", + body: "CEO,\\n\\n## Plan\\n- Confirm scope\\n- Ship cleanly", + }); + + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("Expected dispatch to succeed."); + expect(result.data.message?.body).toBe("CEO,\n\n## Plan\n- Confirm scope\n- Ship cleanly"); + + const comment = db.core.activity.addComment({ + companyId, + taskId, + authorAgentId: ctoId, + authorName: "CTO", + body: "Progress:\\n- Scope confirmed\\n- Review queued", + }); + expect(comment.body).toBe("Progress:\n- Scope confirmed\n- Review queued"); + + db.close(); + }); + it("redirects a DM's wake to the @mentioned agent instead of the DM recipient (chatroom semantics)", async () => { const profileDir = await createTempDir("agentcompany-message-redirect-"); const db = new AppDatabase(profileDir);