feat(DIS-105): codeblock-aware message splitting for Discord 2000-char limit #47

Merged
dev merged 3 commits from phase-1/split-for-discord into main 2026-04-10 08:15:38 +00:00
3 changed files with 190 additions and 49 deletions
Showing only changes of commit 00132c6d22 - Show all commits

84
src/discord/split.ts Normal file
View file

@ -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 ```<lang> 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;
}

View file

@ -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) {

View file

@ -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/);
});
});