Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion container/agent-runner/src/poll-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -379,6 +379,61 @@ describe('end-to-end with mock provider', () => {
});
});

describe('findClosingMessageTag', () => {
// Helper: simulate the dispatch parser by finding <message to="..."> open,
// then looking up the matching close starting from the body.
function bodyOf(text: string): string {
const openRe = /<message\s+to="([^"]+)"\s*>/;
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 '<UNCLOSED>';
return text.slice(bodyStart, close);
}

it('matches the close of a plain message', () => {
expect(bodyOf('<message to="mark">hello</message>')).toBe('hello');
});

it('ignores </message> inside inline backticks', () => {
// Agent writing about NanoClaw destinations in prose
const text = '<message to="mark">routing example: `<message to="x">…</message>` covered</message>';
expect(bodyOf(text)).toBe('routing example: `<message to="x">…</message>` covered');
});

it('ignores </message> inside fenced code blocks', () => {
const text = [
'<message to="mark">here is the format:',
'```',
'<message to="name">body</message>',
'```',
'end</message>',
].join('\n');
expect(bodyOf(text)).toBe(['here is the format:', '```', '<message to="name">body</message>', '```', 'end'].join('\n'));
});

it('returns -1 when the close tag is missing', () => {
expect(findClosingMessageTag('<message to="mark">no close here', 19)).toBe(-1);
});

it('treats inline-backtick close as unclosed when no other close follows', () => {
// Single `</message>` inside inline backticks with nothing after — outer is unclosed
const text = '<message to="mark">talking about `</message>` only';
expect(findClosingMessageTag(text, 19)).toBe(-1);
});

it('finds the outer close even when an inner close is inside a fenced block', () => {
// The inner </message> in the fence must NOT short-circuit the outer match.
const text = '<message to="mark">prefix\n```\n</message>\n```\nsuffix</message>';
const start = '<message to="mark">'.length;
const close = findClosingMessageTag(text, start);
// It should land on the outer </message>, not the fenced one
expect(close).toBeGreaterThan(text.indexOf('```\nsuffix'));
expect(text.slice(close)).toBe('</message>');
});
});

describe('isCorruptionError', () => {
it('matches the Docker Desktop macOS torn-read symptom', () => {
expect(isCorruptionError('database disk image is malformed')).toBe(true);
Expand Down
57 changes: 53 additions & 4 deletions container/agent-runner/src/poll-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
const OPEN_RE = /<message\s+to="([^"]+)"\s*>/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 </message>, 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 + '</message>'.length;
OPEN_RE.lastIndex = lastIndex;

const dest = findByName(toName);
if (!dest) {
Expand Down Expand Up @@ -533,6 +544,44 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
return { sent, hasUnwrapped };
}

/**
* Find the next `</message>` close tag starting from `start`, ignoring any
* that appear inside ``` fenced blocks or single-line `inline` code spans.
* Returns the index of the `<` of `</message>`, 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 = '</message>';
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';
Expand Down