diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index a52377d..cdf4fc6 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -129,11 +129,7 @@ vi.mock('discord.js', () => { }; }); -import { - DiscordChannel, - DiscordChannelOpts, - chunkForDiscord, -} from './discord.js'; +import { DiscordChannel, DiscordChannelOpts } from './discord.js'; import { logger } from '../logger.js'; // --- Test helpers --- @@ -1164,127 +1160,3 @@ describe('channel properties', () => { expect(channel.name).toBe('discord'); }); }); - -// --- chunkForDiscord --- - -describe('chunkForDiscord', () => { - it('returns the input unchanged when within the limit', () => { - const text = 'short message'; - expect(chunkForDiscord(text, 2000)).toEqual([text]); - }); - - it('returns empty array for empty input', () => { - expect(chunkForDiscord('', 2000)).toEqual([]); - }); - - it('splits on newline boundaries when possible', () => { - const text = 'aaa\nbbb\nccc\nddd'; - const chunks = chunkForDiscord(text, 8); - // Every chunk must be ≤ 8 chars and joining them must rebuild the text - for (const c of chunks) expect(c.length).toBeLessThanOrEqual(8); - expect(chunks.join('')).toBe(text); - }); - - it('reopens a fenced code block across chunks (with language)', () => { - const code = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n'); - const text = 'intro paragraph.\n\n```ts\n' + code + '\n```\nafter'; - const chunks = chunkForDiscord(text, 500); - expect(chunks.length).toBeGreaterThan(1); - for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500); - // Every chunk must have a balanced number of ``` fences (so it renders - // as valid markdown on its own). - for (const c of chunks) { - const fenceMatches = c.match(/^```/gm) ?? []; - expect(fenceMatches.length % 2).toBe(0); - } - // The middle chunks must reopen the same language. - for (let i = 1; i < chunks.length; i++) { - // It should either start with the reopened fence, or never have been - // inside the fence at this point. - if (chunks[i].startsWith('```')) { - expect(chunks[i].startsWith('```ts\n')).toBe(true); - } - } - }); - - it('reopens a language-less fenced code block', () => { - const code = Array.from({ length: 200 }, (_, i) => `payload-${i}`).join( - '\n', - ); - const text = '```\n' + code + '\n```'; - const chunks = chunkForDiscord(text, 400); - expect(chunks.length).toBeGreaterThan(1); - for (const c of chunks) { - const fenceMatches = c.match(/^```/gm) ?? []; - expect(fenceMatches.length % 2).toBe(0); - } - // Joining back should rebuild a string whose un-fenced content equals - // the original code (allowing for extra fence pairs in the middle). - const reconstructed = chunks - .join('\n') - .replace(/```\n```\n/g, '') // drop reopen-close pairs at chunk seams - .replace(/```\n```$/g, ''); - expect(reconstructed.includes('payload-0')).toBe(true); - expect(reconstructed.includes('payload-199')).toBe(true); - }); - - it('handles multiple fenced blocks in the same document', () => { - const blockA = Array.from({ length: 50 }, () => 'AAAA').join('\n'); - const blockB = Array.from({ length: 50 }, () => 'BBBB').join('\n'); - const text = - 'intro\n```js\n' + blockA + '\n```\nbetween\n```py\n' + blockB + '\n```'; - const chunks = chunkForDiscord(text, 200); - for (const c of chunks) expect(c.length).toBeLessThanOrEqual(200); - for (const c of chunks) { - const fenceMatches = c.match(/^```/gm) ?? []; - expect(fenceMatches.length % 2).toBe(0); - } - }); - - it('does not split a surrogate pair', () => { - // Emoji 🦄 is a surrogate pair (length 2 in JS). - const emoji = '🦄'; - const text = emoji.repeat(50); - const chunks = chunkForDiscord(text, 9); - for (const c of chunks) { - // No chunk should start or end mid surrogate pair. - expect(c.charCodeAt(0) >= 0xdc00 && c.charCodeAt(0) <= 0xdfff).toBe( - false, - ); - const last = c.charCodeAt(c.length - 1); - expect(last >= 0xd800 && last <= 0xdbff).toBe(false); - } - expect(chunks.join('')).toBe(text); - }); - - it('falls back gracefully when a single line exceeds maxLength', () => { - const longLine = 'x'.repeat(5000); - const chunks = chunkForDiscord(longLine, 2000); - for (const c of chunks) expect(c.length).toBeLessThanOrEqual(2000); - expect(chunks.join('')).toBe(longLine); - }); - - it('keeps a closing fence with its block when there is room', () => { - const text = '```ts\nshort code\n```'; - const chunks = chunkForDiscord(text, 2000); - expect(chunks).toEqual([text]); - }); - - it('preserves total content across chunk seams (modulo reopen overhead)', () => { - const code = Array.from( - { length: 80 }, - (_, i) => `console.log(${i});`, - ).join('\n'); - const text = '```js\n' + code + '\n```'; - const chunks = chunkForDiscord(text, 300); - // After removing the reopen-close fence pairs that the chunker inserts - // between chunks, the result should match the original text. - const stripped = chunks.join('\n').replace(/```\n```js\n/g, ''); - // The first chunk still has the original opener and the last still has - // the original closer. - expect(stripped.startsWith('```js\n')).toBe(true); - expect(stripped.endsWith('```')).toBe(true); - expect(stripped.includes('console.log(0);')).toBe(true); - expect(stripped.includes('console.log(79);')).toBe(true); - }); -}); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 91c1e73..356d8f6 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -18,7 +18,7 @@ import { appendRejectionNotice, validateOutboundAttachments, } from '../outbound-attachments.js'; -import { formatOutbound } from '../router.js'; +import { sanitizeForOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; import type { DeleteRecentMessagesByContentOptions, @@ -419,7 +419,10 @@ export class DiscordChannel implements Channel { // Convert @username mentions to Discord mention format cleaned = cleaned.replaceAll('@눈쟁이', '<@216851709744513024>'); - cleaned = formatOutbound(cleaned); + // Markdown escaping already happened in prepareDiscordOutbound (prose + // escaped, structured output / fenced code preserved). Here we only + // re-sanitize so we don't double-escape the delimiters. + cleaned = sanitizeForOutbound(cleaned); // Surface rejected attachments in the visible message. The MEDIA: // directive was already stripped from the text, so without this the user diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 37b62f7..fb85777 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -164,15 +164,15 @@ describe('initializeDatabaseSchema', () => { expect(columns).toContain('progress_updated_at'); // The pre-existing version-15 row is left untouched; the compat migration - // (version 20) is what reconciles the schema. + // (version 22 after merge-renumbering) is what reconciles the schema. const version15 = database .prepare('SELECT name FROM schema_migrations WHERE version = 15') .get() as { name: string }; expect(version15.name).toBe('reviewer_failure_count'); - const version20 = database - .prepare('SELECT name FROM schema_migrations WHERE version = 20') + const version22 = database + .prepare('SELECT name FROM schema_migrations WHERE version = 22') .get() as { name: string } | null; - expect(version20?.name).toBe('turn_progress_text_compat'); + expect(version22?.name).toBe('turn_progress_text_compat'); } finally { database.close(); } diff --git a/src/db/paired-state.ts b/src/db/paired-state.ts index 12197b1..197c38b 100644 --- a/src/db/paired-state.ts +++ b/src/db/paired-state.ts @@ -191,7 +191,7 @@ export function createPairedTaskInDatabase( created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( diff --git a/src/index.ts b/src/index.ts index 7a0dd62..a30d750 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,7 +55,6 @@ import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js'; import { startUnifiedDashboard } from './unified-dashboard.js'; import { startUsagePrimer } from './usage-primer.js'; import { startWebDashboardServer } from './web-dashboard-server.js'; -import { startUsagePrimer } from './usage-primer.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; import { initCodexTokenRotation } from './codex-token-rotation.js';