fix(agents): wrap each reasoning line separately in italics for Telegram

Fixes #2060

Reasoning blocks display raw underscores instead of italic text on Telegram
because underscore markdown (_..._) cannot span multiple lines.

This change wraps each non-empty line separately in underscores, which
Telegram correctly renders as italic text.

Example output:
  Before: Reasoning:\n_line one\nline two\nline three_
  After:  Reasoning:\n_line one_\n_line two_\n_line three_

Added 7 tests to verify the new behavior.
This commit is contained in:
Yurii Chukhlib 2026-01-26 11:52:48 +01:00 committed by Ayaan Zaidi
parent 961b4adc1c
commit 29c5b0c756
2 changed files with 46 additions and 2 deletions

View File

@ -1,6 +1,6 @@
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { extractAssistantText } from "./pi-embedded-utils.js"; import { extractAssistantText, formatReasoningMessage } from "./pi-embedded-utils.js";
describe("extractAssistantText", () => { describe("extractAssistantText", () => {
it("strips Minimax tool invocation XML from text", () => { it("strips Minimax tool invocation XML from text", () => {
@ -508,3 +508,41 @@ File contents here`,
expect(result).toBe("StartMiddleEnd"); expect(result).toBe("StartMiddleEnd");
}); });
}); });
describe("formatReasoningMessage", () => {
it("returns empty string for empty input", () => {
expect(formatReasoningMessage("")).toBe("");
});
it("returns empty string for whitespace-only input", () => {
expect(formatReasoningMessage(" \n \t ")).toBe("");
});
it("wraps single line in italics", () => {
expect(formatReasoningMessage("Single line of reasoning")).toBe(
"Reasoning:\n_Single line of reasoning_",
);
});
it("wraps each line separately for multiline text (Telegram fix)", () => {
expect(formatReasoningMessage("Line one\nLine two\nLine three")).toBe(
"Reasoning:\n_Line one_\n_Line two_\n_Line three_",
);
});
it("preserves empty lines between reasoning text", () => {
expect(formatReasoningMessage("First block\n\nSecond block")).toBe(
"Reasoning:\n_First block_\n\n_Second block_",
);
});
it("handles mixed empty and non-empty lines", () => {
expect(formatReasoningMessage("A\n\nB\nC")).toBe("Reasoning:\n_A_\n\n_B_\n_C_");
});
it("trims leading/trailing whitespace", () => {
expect(formatReasoningMessage(" \n Reasoning here \n ")).toBe(
"Reasoning:\n_Reasoning here_",
);
});
});

View File

@ -211,7 +211,13 @@ export function formatReasoningMessage(text: string): string {
if (!trimmed) return ""; if (!trimmed) return "";
// Show reasoning in italics (cursive) for markdown-friendly surfaces (Discord, etc.). // Show reasoning in italics (cursive) for markdown-friendly surfaces (Discord, etc.).
// Keep the plain "Reasoning:" prefix so existing parsing/detection keeps working. // Keep the plain "Reasoning:" prefix so existing parsing/detection keeps working.
return `Reasoning:\n_${trimmed}_`; // Note: Underscore markdown cannot span multiple lines on Telegram, so we wrap
// each non-empty line separately.
const italicLines = trimmed
.split("\n")
.map((line) => (line ? `_${line}_` : line))
.join("\n");
return `Reasoning:\n${italicLines}`;
} }
type ThinkTaggedSplitBlock = type ThinkTaggedSplitBlock =