diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index ad7853f138d3..08a53986bb73 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -21,9 +21,6 @@ type CodexStatusProbes = { skills: SafeValue; }; -// Status rows are intentional layout, not GFM soft-wrapped prose. -const CODEX_STATUS_LINE_BREAK = " \n"; - /** Formats the combined `/codex status` probe result. */ export function formatCodexStatus(probes: CodexStatusProbes): string { const connected = @@ -69,7 +66,7 @@ export function formatCodexStatus(probes: CodexStatusProbes): string { : formatCodexDisplayText(probes.skills.error) }`, ); - return lines.flatMap((line) => line.split("\n")).join(CODEX_STATUS_LINE_BREAK); + return lines.join("\n"); } /** Formats Codex model-list results for `/codex models`. */ diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 99eac5cfe58b..9881857cf02e 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -878,7 +878,7 @@ describe("codex command", () => { "Rate limits: offline", "MCP servers: offline", "Skills: offline", - ].join(" \n"), + ].join("\n"), }); expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config); }); diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index 48a9386fd0c8..6bda77842d00 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -239,6 +239,22 @@ function isSafeRichMarkdownBlockBreak(spans: readonly RichMarkdownFenceSpan[], i return !spans.some((span) => index > span.start && index < span.end); } +function isRichMarkdownFenceMarker(line: string): boolean { + return /^( {0,3})(`{3,}|~{3,})/.test(line); +} + +function isRichMarkdownBlockLine(line: string, isTableLine: boolean): boolean { + const trimmed = line.trimStart(); + return ( + isTableLine || + isRichMarkdownFenceMarker(line) || + /^#{1,6}\s+\S/.test(trimmed) || + trimmed.startsWith(">") || + /^(?:[-+*]|\d+[.)])\s+\S/.test(trimmed) || + /^[-*_][\s-*_-]{2,}$/.test(trimmed) + ); +} + function splitMarkdownTableRow(row: string): string[] { const trimmed = row.trim(); const body = trimmed.startsWith("|") && trimmed.endsWith("|") ? trimmed.slice(1, -1) : trimmed; @@ -280,7 +296,73 @@ function markdownTableColumnCount(row: string): number { return splitMarkdownTableRow(row).length; } -function normalizeTelegramRichMarkdown(markdown: string): string { +function findRichMarkdownTableLineIndexes( + markdown: string, + lines: readonly string[], + fenceSpans: readonly RichMarkdownFenceSpan[], +): Set { + const tableLineIndexes = new Set(); + let offset = 0; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const nextLine = lines[index + 1]; + if ( + nextLine !== undefined && + isSafeRichMarkdownBlockBreak(fenceSpans, offset) && + isMarkdownTableRow(line) && + isMarkdownTableSeparator(nextLine) + ) { + tableLineIndexes.add(index); + tableLineIndexes.add(index + 1); + offset += line.length + 1 + nextLine.length + 1; + index += 2; + while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) { + tableLineIndexes.add(index); + offset += (lines[index] ?? "").length + 1; + index += 1; + } + index -= 1; + continue; + } + offset += line.length + 1; + } + return tableLineIndexes; +} + +function preserveTelegramRichMarkdownLineBreaks(markdown: string): string { + if (!markdown.includes("\n")) { + return markdown; + } + + const fenceSpans = parseRichMarkdownFenceSpans(markdown); + const lines = markdown.split("\n"); + const tableLineIndexes = findRichMarkdownTableLineIndexes(markdown, lines, fenceSpans); + const out: string[] = []; + let offset = 0; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + const nextLine = lines[index + 1]; + if (nextLine === undefined) { + out.push(line); + break; + } + + const newlineIndex = offset + line.length; + const shouldPreserveBreak = + line.length > 0 && + nextLine.length > 0 && + !line.endsWith(" ") && + !line.endsWith("\\") && + !isRichMarkdownBlockLine(line, tableLineIndexes.has(index)) && + !isRichMarkdownBlockLine(nextLine, tableLineIndexes.has(index + 1)) && + isSafeRichMarkdownBlockBreak(fenceSpans, newlineIndex); + out.push(`${line}${shouldPreserveBreak ? " " : ""}\n`); + offset = newlineIndex + 1; + } + return out.join(""); +} + +function normalizeTelegramRichMarkdownTables(markdown: string): string { if (!markdown.includes("|")) { return markdown; } @@ -320,6 +402,10 @@ function normalizeTelegramRichMarkdown(markdown: string): string { return out.join("\n"); } +function normalizeTelegramRichMarkdown(markdown: string): string { + return preserveTelegramRichMarkdownLineBreaks(normalizeTelegramRichMarkdownTables(markdown)); +} + type RichMarkdownBlockBreak = { start: number; end: number; diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index b3789e37eba6..59b8a5535a8a 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -947,8 +947,42 @@ describe("sendMessageTelegram", () => { }); }); - it("keeps markdown media syntax on the text-only rich path", async () => { + it("preserves rich markdown line breaks outside fenced code", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } }); + const markdown = [ + "Status: ok | mode", + "Models: ready", + "", + "```", + "a", + "b", + "```", + "Tail", + ].join("\n"); + const expectedMarkdown = [ + "Status: ok | mode ", + "Models: ready", + "", + "```", + "a", + "b", + "```", + "Tail", + ].join("\n"); + + await sendMessageTelegram("123", markdown, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ + chat_id: "123", + rich_message: { markdown: expectedMarkdown }, + }); + }); + + it("keeps markdown media syntax on the text-only rich path", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } }); await sendMessageTelegram("123", "See ![diagram](https://example.com/diagram.png)", { cfg: TELEGRAM_TEST_CFG, @@ -962,7 +996,7 @@ describe("sendMessageTelegram", () => { }); it("escapes HTML media tags on the text-only rich path", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } }); + botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } }); await sendMessageTelegram("123", 'See', { cfg: TELEGRAM_TEST_CFG, @@ -979,7 +1013,7 @@ describe("sendMessageTelegram", () => { }); it("keeps native rich markdown tables within Telegram's column limit", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } }); + botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } }); const markdown = markdownTable(20); await sendMessageTelegram("123", markdown, { @@ -994,7 +1028,7 @@ describe("sendMessageTelegram", () => { }); it("wraps wide rich markdown tables that exceed Telegram's column limit", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } }); + botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } }); const markdown = markdownTable(21); await sendMessageTelegram("123", markdown, { @@ -1009,7 +1043,7 @@ describe("sendMessageTelegram", () => { }); it("leaves wide rich markdown tables alone inside fences", async () => { - botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } }); + botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } }); const markdown = `~~~\n${markdownTable(25)}\n~~~`; await sendMessageTelegram("123", markdown, { @@ -1046,7 +1080,9 @@ describe("sendMessageTelegram", () => { it("sends long rich markdown as one message", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } }); - const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(800)}`; + const line = "**section** with _style_ and `code`"; + const markdown = `# Long\n\n${`${line}\n`.repeat(800)}`; + const expectedMarkdown = `# Long\n\n${`${line} \n`.repeat(799)}${line}\n`; await sendMessageTelegram("123", markdown, { cfg: TELEGRAM_TEST_CFG, @@ -1056,7 +1092,7 @@ describe("sendMessageTelegram", () => { expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({ chat_id: "123", - rich_message: { markdown }, + rich_message: { markdown: expectedMarkdown }, }); }); diff --git a/extensions/telegram/src/telegram-outbound.test.ts b/extensions/telegram/src/telegram-outbound.test.ts index c80e08506fcd..b71a06359077 100644 --- a/extensions/telegram/src/telegram-outbound.test.ts +++ b/extensions/telegram/src/telegram-outbound.test.ts @@ -19,7 +19,7 @@ describe("telegramPlugin outbound", () => { it("uses static outbound contract when Telegram runtime is uninitialized", () => { clearTelegramRuntime(); const text = `${"hello\n".repeat(1200)}tail`; - const expected = chunkMarkdownTextWithMode(text, 32_768, "length"); + const expected = chunkMarkdownTextWithMode(`${"hello \n".repeat(1200)}tail`, 32_768, "length"); expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected); expect(telegramOutbound.deliveryMode).toBe("direct"); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 675287302b6e..0e3570ea100b 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -130,12 +130,6 @@ describe("buildStatusMessage", () => { }); const normalized = normalizeTestText(text); - expect( - text - .split("\n") - .slice(0, -1) - .every((line) => line.endsWith(" ")), - ).toBe(true); expect(normalized).toContain("OpenClaw"); expect(normalized).toContain("Model: anthropic/test:opus"); expect(normalized).toContain("api-key"); diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 617768d83f27..529fff6028ed 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -69,9 +69,6 @@ type AgentConfig = Partial & { model?: AgentDefaults["model"] | string; }; -// Status rows are intentional layout, not GFM soft-wrapped prose. -const STATUS_MARKDOWN_LINE_BREAK = " \n"; - export const formatTokenCount = formatTokenCountShared; type QueueStatus = { @@ -1053,6 +1050,5 @@ export function buildStatusMessage(args: StatusArgs): string { activationLine, ] .filter((line): line is string => Boolean(line)) - .flatMap((line) => line.split("\n")) - .join(STATUS_MARKDOWN_LINE_BREAK); + .join("\n"); }