diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 7b85faaaa9..9fba21ab34 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -4,7 +4,7 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from ' import { getPendingMessages, markCompleted } from './db/messages-in.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { formatMessages, extractRouting } from './formatter.js'; -import { isCorruptionError } from './poll-loop.js'; +import { findClosingMessageTag, isCorruptionError } from './poll-loop.js'; import { MockProvider } from './providers/mock.js'; beforeEach(() => { @@ -379,6 +379,61 @@ describe('end-to-end with mock provider', () => { }); }); +describe('findClosingMessageTag', () => { + // Helper: simulate the dispatch parser by finding open, + // then looking up the matching close starting from the body. + function bodyOf(text: string): string { + const openRe = //; + const m = openRe.exec(text); + if (!m) throw new Error('no open tag'); + const bodyStart = m.index + m[0].length; + const close = findClosingMessageTag(text, bodyStart); + if (close === -1) return ''; + return text.slice(bodyStart, close); + } + + it('matches the close of a plain message', () => { + expect(bodyOf('hello')).toBe('hello'); + }); + + it('ignores inside inline backticks', () => { + // Agent writing about NanoClaw destinations in prose + const text = 'routing example: `` covered'; + expect(bodyOf(text)).toBe('routing example: `` covered'); + }); + + it('ignores inside fenced code blocks', () => { + const text = [ + 'here is the format:', + '```', + 'body', + '```', + 'end', + ].join('\n'); + expect(bodyOf(text)).toBe(['here is the format:', '```', 'body', '```', 'end'].join('\n')); + }); + + it('returns -1 when the close tag is missing', () => { + expect(findClosingMessageTag('no close here', 19)).toBe(-1); + }); + + it('treats inline-backtick close as unclosed when no other close follows', () => { + // Single `` inside inline backticks with nothing after — outer is unclosed + const text = 'talking about `` only'; + expect(findClosingMessageTag(text, 19)).toBe(-1); + }); + + it('finds the outer close even when an inner close is inside a fenced block', () => { + // The inner in the fence must NOT short-circuit the outer match. + const text = 'prefix\n```\n\n```\nsuffix'; + const start = ''.length; + const close = findClosingMessageTag(text, start); + // It should land on the outer , not the fenced one + expect(close).toBeGreaterThan(text.indexOf('```\nsuffix')); + expect(text.slice(close)).toBe(''); + }); +}); + describe('isCorruptionError', () => { it('matches the Docker Desktop macOS torn-read symptom', () => { expect(isCorruptionError('database disk image is malformed')).toBe(true); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 1b7d181a3c..338932e61d 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -492,20 +492,31 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { * blocks, even with a single destination. Bare text is scratchpad only. */ function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } { - const MESSAGE_RE = /([\s\S]*?)<\/message>/g; + const OPEN_RE = //g; let match: RegExpExecArray | null; let sent = 0; let lastIndex = 0; const scratchpadParts: string[] = []; - while ((match = MESSAGE_RE.exec(text)) !== null) { + while ((match = OPEN_RE.exec(text)) !== null) { if (match.index > lastIndex) { scratchpadParts.push(text.slice(lastIndex, match.index)); } const toName = match[1]; - const body = match[2].trim(); - lastIndex = MESSAGE_RE.lastIndex; + const bodyStart = OPEN_RE.lastIndex; + // Find the matching , ignoring any inside ``` fences or `inline` + // code spans so example tags in proposals/code don't truncate the body. + const closeIdx = findClosingMessageTag(text, bodyStart); + if (closeIdx === -1) { + // Unclosed — treat remainder as scratchpad and stop scanning + scratchpadParts.push(text.slice(match.index)); + lastIndex = text.length; + break; + } + const body = text.slice(bodyStart, closeIdx).trim(); + lastIndex = closeIdx + ''.length; + OPEN_RE.lastIndex = lastIndex; const dest = findByName(toName); if (!dest) { @@ -533,6 +544,44 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb return { sent, hasUnwrapped }; } +/** + * Find the next `` close tag starting from `start`, ignoring any + * that appear inside ``` fenced blocks or single-line `inline` code spans. + * Returns the index of the `<` of ``, or -1 if none found. + * + * Jumps via `indexOf` between candidate close-tags and backticks so the cost + * is dominated by the (sparse) backtick spans rather than walking each char. + * + * Exported for unit testing. + */ +export function findClosingMessageTag(text: string, start: number): number { + const CLOSE = ''; + let i = start; + while (i < text.length) { + const close = text.indexOf(CLOSE, i); + if (close === -1) return -1; + const tick = text.indexOf('`', i); + if (tick === -1 || close < tick) return close; + // Tick comes first — figure out if it's a triple-backtick fence or an + // inline single-tick span, then skip past the matching close marker. + if (text.startsWith('```', tick)) { + const end = text.indexOf('```', tick + 3); + if (end === -1) return -1; // unterminated fence — give up + i = end + 3; + continue; + } + const nl = text.indexOf('\n', tick + 1); + const inlineEnd = text.indexOf('`', tick + 1); + if (inlineEnd !== -1 && (nl === -1 || inlineEnd < nl)) { + i = inlineEnd + 1; + } else { + // Lone backtick with no closer on the same line — treat as plain text + i = tick + 1; + } + } + return -1; +} + function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void { const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';