diff --git a/src/discord/split.ts b/src/discord/split.ts new file mode 100644 index 0000000..154b325 --- /dev/null +++ b/src/discord/split.ts @@ -0,0 +1,84 @@ +const DISCORD_HARD_LIMIT = 2000; + +/** + * Splits text into chunks that fit within Discord's character limit while + * keeping Markdown fenced code blocks intact. When a chunk boundary falls + * inside a fence, the current chunk is closed with ``` and the next chunk + * reopens with ``` so the fence state is preserved across chunks. + * + * @param text The full text to split. + * @param limit Soft target size per chunk (default 1900). Must be <= 2000. + */ +export function splitForDiscord(text: string, limit = 1900): string[] { + if (text.length === 0) { + return [""]; + } + + if (text.length <= limit) { + return [text]; + } + + const effectiveLimit = Math.min(limit, DISCORD_HARD_LIMIT); + + const chunks: string[] = []; + const lines = text.split("\n"); + + let current = ""; + let inFence = false; + let fenceLang = ""; + + const flush = () => { + if (current.length > 0) { + chunks.push(current); + current = ""; + } + }; + + for (const line of lines) { + // Detect fence toggle: line starts with ``` + const fenceMatch = line.match(/^```(\S*)/); + const isFenceLine = fenceMatch !== null; + + // Calculate the length this line would add to current chunk + const separator = current.length > 0 ? "\n" : ""; + const addition = separator + line; + + if (current.length + addition.length > effectiveLimit) { + // Need to flush before adding this line + if (inFence) { + // Close the open fence before flushing + current += "\n```"; + } + flush(); + + // Reopen fence in new chunk if we were inside one + if (inFence) { + current = "```" + fenceLang; + } + } + + // Toggle fence state after deciding to flush (so reopening uses correct lang) + if (isFenceLine) { + if (!inFence) { + inFence = true; + fenceLang = fenceMatch[1] ?? ""; + } else { + inFence = false; + fenceLang = ""; + } + } + + const sep = current.length > 0 ? "\n" : ""; + current += sep + line; + + // Safety: if a single line alone exceeds the hard limit, hard-split it + while (current.length > DISCORD_HARD_LIMIT) { + chunks.push(current.slice(0, DISCORD_HARD_LIMIT)); + current = current.slice(DISCORD_HARD_LIMIT); + } + } + + flush(); + + return chunks; +} diff --git a/src/router.ts b/src/router.ts index fa84b7d..fb75823 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,54 +2,7 @@ import { Message, TextChannel } from "discord.js"; import { DisclawDatabase } from "./db/database"; import { DisclawConfig } from "./config/loader"; import { runAgent } from "./agent/runner"; - -const DISCORD_MAX_LENGTH = 2000; - -/** - * Splits a message at line breaks so each chunk stays under the Discord - * character limit. If a single line exceeds the limit, it is split at - * the character boundary as a last resort. - */ -function splitMessage(text: string): string[] { - if (text.length <= DISCORD_MAX_LENGTH) { - return [text]; - } - - const chunks: string[] = []; - const lines = text.split("\n"); - let current = ""; - - for (const line of lines) { - // If adding this line would exceed the limit, flush current chunk - if (current.length + line.length + 1 > DISCORD_MAX_LENGTH) { - if (current.length > 0) { - chunks.push(current); - current = ""; - } - - // Handle lines that are themselves too long - if (line.length > DISCORD_MAX_LENGTH) { - let remaining = line; - while (remaining.length > DISCORD_MAX_LENGTH) { - chunks.push(remaining.slice(0, DISCORD_MAX_LENGTH)); - remaining = remaining.slice(DISCORD_MAX_LENGTH); - } - if (remaining.length > 0) { - current = remaining; - } - continue; - } - } - - current += (current.length > 0 ? "\n" : "") + line; - } - - if (current.length > 0) { - chunks.push(current); - } - - return chunks; -} +import { splitForDiscord } from "./discord/split"; /** * Routes a Discord message to the correct agent based on channel_id. @@ -106,7 +59,7 @@ export async function routeMessage( ); // Split and send response - const chunks = splitMessage(response); + const chunks = splitForDiscord(response); let firstMsgId: string | null = null; for (const chunk of chunks) { diff --git a/tests/unit/split-for-discord.test.ts b/tests/unit/split-for-discord.test.ts new file mode 100644 index 0000000..c7baeed --- /dev/null +++ b/tests/unit/split-for-discord.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { splitForDiscord } from "../../src/discord/split"; + +const DISCORD_HARD_LIMIT = 2000; + +describe("splitForDiscord", () => { + // (a) Short message below limit → returned as single unchanged chunk + it("returns a single chunk for short messages", () => { + const text = "Hello, world!"; + const result = splitForDiscord(text); + expect(result).toHaveLength(1); + expect(result[0]).toBe(text); + }); + + // (b) Text exactly at the limit → single chunk + it("returns a single chunk when text is exactly at the limit", () => { + const text = "a".repeat(1900); + const result = splitForDiscord(text, 1900); + expect(result).toHaveLength(1); + expect(result[0]).toBe(text); + }); + + // (c) Long plain text → split at line boundaries + it("splits long plain text at line boundaries", () => { + const line = "x".repeat(100); + // 25 lines = 25*100 + 24 newlines = 2524 chars → must split with limit 1900 + const text = Array.from({ length: 25 }, () => line).join("\n"); + const result = splitForDiscord(text, 1900); + expect(result.length).toBeGreaterThan(1); + // Reassembled text must equal original + expect(result.join("\n")).toBe(text); + }); + + // (d) Code block spanning a split → fence is closed and reopened with lang tag + it("closes and reopens fenced code blocks across chunk boundaries", () => { + const preamble = "Here is some code:\n"; + const fenceOpen = "```typescript\n"; + // Fill enough code lines to force a split inside the fence + const codeLine = "const x = 1; // " + "y".repeat(80) + "\n"; + const codeLines = Array.from({ length: 20 }, () => codeLine).join(""); + const fenceClose = "```\n"; + const epilogue = "End of message."; + + const text = preamble + fenceOpen + codeLines + fenceClose + epilogue; + const result = splitForDiscord(text, 1900); + + expect(result.length).toBeGreaterThan(1); + + // Every chunk that starts mid-fence must open with ```typescript + // Every chunk that ends mid-fence must close with ``` + for (let i = 0; i < result.length - 1; i++) { + const chunk = result[i]; + // Count fence toggles to determine if we end inside a fence + const fenceToggles = (chunk.match(/^```/gm) ?? []).length; + if (fenceToggles % 2 === 1) { + // Odd number means we opened but didn't close — the splitter must close it + expect(chunk.trimEnd()).toMatch(/```$/); + // And the next chunk must reopen with the same language + expect(result[i + 1]).toMatch(/^```typescript/); + } + } + }); + + // (e) No chunk exceeds 2000 characters + it("never produces a chunk exceeding 2000 characters", () => { + const line = "z".repeat(150); + const text = Array.from({ length: 50 }, () => line).join("\n"); + const result = splitForDiscord(text, 1900); + for (const chunk of result) { + expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT); + } + }); + + // (f) Empty input → no crash, returns consistent value + it("handles empty input without crashing", () => { + expect(() => splitForDiscord("")).not.toThrow(); + const result = splitForDiscord(""); + expect(Array.isArray(result)).toBe(true); + }); + + // Additional: fence language tag is preserved correctly + it("preserves the fence language tag when reopening after a split", () => { + const limit = 200; + const header = "Intro\n"; + const fenceOpen = "```python\n"; + // Enough lines to exceed 200 chars inside the fence + const codeLine = "print('hello')\n"; + const codeLines = Array.from({ length: 20 }, () => codeLine).join(""); + const fenceClose = "```"; + + const text = header + fenceOpen + codeLines + fenceClose; + const result = splitForDiscord(text, limit); + + // Find the first chunk that ends with ``` (closed fence) + const closingChunk = result.find( + (c, i) => i < result.length - 1 && c.trimEnd().endsWith("```") + ); + expect(closingChunk).toBeDefined(); + + // The chunk following a mid-fence close should start with ```python + const idx = result.indexOf(closingChunk!); + expect(result[idx + 1]).toMatch(/^```python/); + }); +});