feat(DIS-105): codeblock-aware message splitting for Discord 2000-char limit
Add splitForDiscord() in src/discord/split.ts. Fences are properly closed and reopened across chunk boundaries. Router uses splitForDiscord instead of internal splitMessage(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6039e992bd
commit
00132c6d22
3 changed files with 190 additions and 49 deletions
84
src/discord/split.ts
Normal file
84
src/discord/split.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -2,54 +2,7 @@ import { Message, TextChannel } from "discord.js";
|
||||||
import { DisclawDatabase } from "./db/database";
|
import { DisclawDatabase } from "./db/database";
|
||||||
import { DisclawConfig } from "./config/loader";
|
import { DisclawConfig } from "./config/loader";
|
||||||
import { runAgent } from "./agent/runner";
|
import { runAgent } from "./agent/runner";
|
||||||
|
import { splitForDiscord } from "./discord/split";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes a Discord message to the correct agent based on channel_id.
|
* Routes a Discord message to the correct agent based on channel_id.
|
||||||
|
|
@ -106,7 +59,7 @@ export async function routeMessage(
|
||||||
);
|
);
|
||||||
|
|
||||||
// Split and send response
|
// Split and send response
|
||||||
const chunks = splitMessage(response);
|
const chunks = splitForDiscord(response);
|
||||||
let firstMsgId: string | null = null;
|
let firstMsgId: string | null = null;
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
|
|
|
||||||
104
tests/unit/split-for-discord.test.ts
Normal file
104
tests/unit/split-for-discord.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue