diff --git a/CHANGELOG.md b/CHANGELOG.md index 6daffcea1..7a2539939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Models: normalize `${ENV_VAR}` apiKey config values and auto-fill missing provider `apiKey` from env/auth when custom provider models are configured (fixes MiniMax “Unknown model” on fresh installs). - Models/Tools: include `MiniMax-VL-01` in implicit MiniMax provider so image pairing uses a real vision model. - Telegram: show typing indicator in General forum topics. (#779) — thanks @azade-c. +- Discord: keep reasoning italics intact when messages are chunked, so reasoning stays italic across multi-part sends. - Models: keep explicit GitHub Copilot provider config and honor agent-dir auth profiles for auto-injection. (#705) — thanks @TAGOOZ. - Auto-reply: restore 300-char heartbeat ack limit and keep >300 char replies instead of dropping them; adjust long heartbeat test content accordingly. - Gateway: `agents.list` now honors explicit `agents.list` config without pulling stray agents from disk; GitHub Copilot CLI auth path uses the updated provider build. diff --git a/src/discord/chunk.test.ts b/src/discord/chunk.test.ts index 79eca1ddf..188aa92b0 100644 --- a/src/discord/chunk.test.ts +++ b/src/discord/chunk.test.ts @@ -67,4 +67,64 @@ describe("chunkDiscordText", () => { expect(hasBalancedFences(chunk)).toBe(true); } }); + + it("keeps reasoning italics balanced across chunks", () => { + const body = Array.from({ length: 25 }, (_, i) => `${i + 1}. line`).join( + "\n", + ); + const text = `Reasoning:\n_${body}_`; + + const chunks = chunkDiscordText(text, { maxLines: 10, maxChars: 2000 }); + expect(chunks.length).toBeGreaterThan(1); + + for (const chunk of chunks) { + // Each chunk should have balanced italics markers (even count). + const count = (chunk.match(/_/g) || []).length; + expect(count % 2).toBe(0); + } + + // Ensure italics reopen on subsequent chunks + expect(chunks[0]).toContain("_1. line"); + // Second chunk should reopen italics at the start + expect(chunks[1].trimStart().startsWith("_")).toBe(true); + }); + + it("keeps reasoning italics balanced when chunks split by char limit", () => { + const longLine = "This is a very long reasoning line that forces char splits."; + const body = Array.from({ length: 5 }, () => longLine).join("\n"); + const text = `Reasoning:\n_${body}_`; + + const chunks = chunkDiscordText(text, { maxChars: 80, maxLines: 50 }); + expect(chunks.length).toBeGreaterThan(1); + + for (const chunk of chunks) { + const underscoreCount = (chunk.match(/_/g) || []).length; + expect(underscoreCount % 2).toBe(0); + } + }); + + it("reopens italics while preserving leading whitespace on following chunk", () => { + const body = [ + "1. line", + "2. line", + "3. line", + "4. line", + "5. line", + "6. line", + "7. line", + "8. line", + "9. line", + "10. line", + " 11. indented line", + "12. line", + ].join("\n"); + const text = `Reasoning:\n_${body}_`; + + const chunks = chunkDiscordText(text, { maxLines: 10, maxChars: 2000 }); + expect(chunks.length).toBeGreaterThan(1); + + const second = chunks[1]; + expect(second.startsWith("_")).toBe(true); + expect(second).toContain(" 11. indented line"); + }); }); diff --git a/src/discord/chunk.ts b/src/discord/chunk.ts index 372704c2f..18a93311e 100644 --- a/src/discord/chunk.ts +++ b/src/discord/chunk.ts @@ -187,5 +187,42 @@ export function chunkDiscordText( if (payload.trim().length) chunks.push(payload); } - return chunks; + return rebalanceReasoningItalics(text, chunks); +} + +// Keep italics intact for reasoning payloads that are wrapped once with `_…_`. +// When Discord chunking splits the message, we close italics at the end of +// each chunk and reopen at the start of the next so every chunk renders +// consistently. +function rebalanceReasoningItalics(source: string, chunks: string[]): string[] { + if (chunks.length <= 1) return chunks; + + const opensWithReasoningItalics = + source.startsWith("Reasoning:\n_") && source.trimEnd().endsWith("_"); + if (!opensWithReasoningItalics) return chunks; + + const adjusted = [...chunks]; + for (let i = 0; i < adjusted.length; i++) { + const isLast = i === adjusted.length - 1; + const current = adjusted[i]; + + // Ensure current chunk closes italics so Discord renders it italicized. + const needsClosing = !current.trimEnd().endsWith("_"); + if (needsClosing) { + adjusted[i] = `${current}_`; + } + + if (isLast) break; + + // Re-open italics on the next chunk if needed. + const next = adjusted[i + 1]; + const leadingWhitespaceLen = next.length - next.trimStart().length; + const leadingWhitespace = next.slice(0, leadingWhitespaceLen); + const nextBody = next.slice(leadingWhitespaceLen); + if (!nextBody.startsWith("_")) { + adjusted[i + 1] = `${leadingWhitespace}_${nextBody}`; + } + } + + return adjusted; }