fix(agent-runner): drop <messages> envelope so claude-agent-sdk calls API

When 2+ pending messages were bundled into <messages>...</messages> at
container/agent-runner/src/formatter.ts:162-167, the Claude Agent SDK
responded with a synthetic stub (model="<synthetic>", stop_reason=
"stop_sequence", content="No response requested.") instead of calling
the real API. The poll loop never yielded a `result` event, so the
inbound message was never marked completed; the container exited; the
next sweep tick respawned it with the same batch; same synthetic; the
transcript file ballooned with each retry until tries=5 → failed.

Single-message turns (which skipped the wrapper) worked normally — the
SDK's heuristic appears to treat the wrapped envelope as a context dump
rather than a real user turn. Each `<message id=... from=...>...</message>`
block is already self-contained, so dropping the outer wrapper lets the
N>1 case work the same way the N=1 case always has.

Fix:

  function formatChatMessages(messages: MessageInRow[]): string {
    return messages.map(formatSingleChat).join('\n');
  }

Updates one existing test that asserted on the envelope, and adds two
regression tests: one negative (no `<messages>` wrapper), one positive
(each inbound row produces a `<message>` block in order).

Confirmed working in a real install: two stuck lanes recovered after
reducing their pending queue to 1 message, and both produced normal
replies from claude after the wipe + this fix were both applied (the
wipe alone wasn't enough — a fresh session given the same batch shape
hit the same synthetic loop).

Refs nanocoai/nanoclaw#2555 for full repro + transcript evidence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adam
2026-05-19 23:47:41 +10:00
parent 0683c6ec58
commit fe2e881b37
2 changed files with 41 additions and 13 deletions
+32 -3
View File
@@ -51,14 +51,43 @@ describe('context timezone header', () => {
expect(result).toContain(`<context timezone="${TIMEZONE}"`); expect(result).toContain(`<context timezone="${TIMEZONE}"`);
}); });
it('header comes before the <messages> block', () => { it('header comes before the first <message> block when multiple are present', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' }); insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' }); insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
const result = formatMessages(getPendingMessages()); const result = formatMessages(getPendingMessages());
const ctxIdx = result.indexOf('<context'); const ctxIdx = result.indexOf('<context');
const msgsIdx = result.indexOf('<messages>'); const firstMsgIdx = result.indexOf('<message ');
expect(ctxIdx).toBeGreaterThanOrEqual(0); expect(ctxIdx).toBeGreaterThanOrEqual(0);
expect(msgsIdx).toBeGreaterThan(ctxIdx); expect(firstMsgIdx).toBeGreaterThan(ctxIdx);
});
});
describe('multi-message chat batches', () => {
// Regression guard for #2555: an outer `<messages>` envelope around
// multiple chat messages caused the Claude Agent SDK to emit a synthetic
// `No response requested.` stub instead of calling the API. Each
// `<message>` block is self-contained; concatenating them is enough.
it('does NOT wrap multiple chat messages in an outer <messages> envelope', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
const result = formatMessages(getPendingMessages());
expect(result).not.toContain('<messages>');
expect(result).not.toContain('</messages>');
});
it('emits one <message> block per inbound row, in order', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'first' });
insertMessage('m2', 'chat', { sender: 'Bob', text: 'second' });
insertMessage('m3', 'chat', { sender: 'Carol', text: 'third' });
const result = formatMessages(getPendingMessages());
const matches = result.match(/<message [^>]*>/g) ?? [];
expect(matches.length).toBe(3);
const firstIdx = result.indexOf('first');
const secondIdx = result.indexOf('second');
const thirdIdx = result.indexOf('third');
expect(firstIdx).toBeGreaterThan(0);
expect(secondIdx).toBeGreaterThan(firstIdx);
expect(thirdIdx).toBeGreaterThan(secondIdx);
}); });
}); });
+9 -10
View File
@@ -155,16 +155,15 @@ export function formatMessages(messages: MessageInRow[]): string {
} }
function formatChatMessages(messages: MessageInRow[]): string { function formatChatMessages(messages: MessageInRow[]): string {
if (messages.length === 1) { // Each `<message id="..." from="...">...</message>` block is self-contained;
return formatSingleChat(messages[0]); // concatenating them reads to the agent as a sequence of distinct messages.
} // Earlier revisions wrapped multi-message batches in an outer `<messages>`
// envelope, but the Claude Agent SDK responded to that shape with a
const lines = ['<messages>']; // synthetic stub (`model: "<synthetic>"`, `content: "No response
for (const msg of messages) { // requested."`) instead of calling the API — see #2555 for the full trace.
lines.push(formatSingleChat(msg)); // The fix is simply to drop the wrapper; the single-message path (which
} // already worked) is now just the N=1 case of the same code.
lines.push('</messages>'); return messages.map(formatSingleChat).join('\n');
return lines.join('\n');
} }
function formatSingleChat(msg: MessageInRow): string { function formatSingleChat(msg: MessageInRow): string {