Skip to content
Merged
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
150 changes: 112 additions & 38 deletions src/handlers/tool-emulation.js
Original file line number Diff line number Diff line change
Expand Up @@ -1142,62 +1142,136 @@ export function stripOrphanedToolResults(messages) {
return dropped ? out : messages;
}

// Fold-safety for stray (text-only) assistant entries inside a parallel-call
// run. Folding keeps only `content`, so a stray carrying any other own field
// would silently lose it — reasoning_content/signature feed the native #11/#12
// reasoning + sealed-blob frames (responses.js attaches them for Codex's
// reasoning items), and name/annotations/refusal/audio have no generic merge
// semantics either. Likewise an array content folds losslessly only when every
// part is text — non-text parts encode as distinct wire types and must survive
// as objects (the same rule isMergeableText enforces upstream in
// devin-connect.js). Anything outside this envelope is emitted verbatim after
// the interleaved block instead of being folded.
const STRAY_FOLDABLE_KEYS = new Set(['role', 'content', 'tool_calls']);
function isStrayMergeable(msg) {
if (!msg || typeof msg !== 'object') return false;
for (const key of Object.keys(msg)) {
if (!STRAY_FOLDABLE_KEYS.has(key)) return false;
}
if (msg.tool_calls != null && (!Array.isArray(msg.tool_calls) || msg.tool_calls.length)) {
return false;
}
return !Array.isArray(msg.content) || msg.content.every((part) => part?.type === 'text');
}

export function interleaveParallelToolMessages(messages) {
if (!Array.isArray(messages)) return messages;
const out = [];
let i = 0;
while (i < messages.length) {
const m = messages[i];
if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 1) {
let j = i + 1;
const toolMsgs = [];
while (j < messages.length && messages[j]?.role === 'tool') {
toolMsgs.push(messages[j]);
if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
let j = i;
const assistantMsgs = [];
const strayTexts = [];
while (j < messages.length && messages[j]?.role === 'assistant') {
if (Array.isArray(messages[j].tool_calls) && messages[j].tool_calls.length) {
assistantMsgs.push(messages[j]);
} else {
strayTexts.push(messages[j]);
}
j++;
}

let hasMatches = false;
const usedIndices = new Set();
for (const tc of m.tool_calls) {
const tcid = String(tc?.id ?? '');
if (tcid && toolMsgs.some((tm) => String(tm?.tool_call_id ?? '') === tcid)) {
hasMatches = true;
break;
}
let k = j;
const toolMsgs = [];
while (k < messages.length && messages[k]?.role === 'tool') {
toolMsgs.push(messages[k]);
k++;
}

if (hasMatches) {
let first = true;
for (const tc of m.tool_calls) {
const singleAssistant = {
...m,
content: first ? (m.content || null) : null,
tool_calls: [tc],
};
if (!first) {
delete singleAssistant.reasoning_content;
delete singleAssistant.reasoning;
}
out.push(singleAssistant);
const toolCalls = assistantMsgs.flatMap((assistant) => assistant.tool_calls);
// An empty/missing call id must never pair: on the native path a missing
// call id gets a fresh UUID while an empty result id never reaches the
// role=4 branch — two empty strings comparing equal is not evidence of
// call ownership. Keep the `tcid &&` gate in the entry check here and in
// the per-call consume below.
const hasMatches = toolCalls.some((tc) => {
const tcid = String(tc?.id ?? '');
return tcid && toolMsgs.some((tm) => String(tm?.tool_call_id ?? '') === tcid);
});

const tcid = String(tc?.id ?? '');
const matchIdx = toolMsgs.findIndex(
(tm, idx) => !usedIndices.has(idx) && String(tm?.tool_call_id ?? '') === tcid,
);
if (matchIdx !== -1) {
usedIndices.add(matchIdx);
out.push(toolMsgs[matchIdx]);
if (toolCalls.length > 1 && hasMatches) {
const verbatimStrays = [];
if (strayTexts.length) {
const foldableStrays = [];
for (const s of strayTexts) {
(isStrayMergeable(s) ? foldableStrays : verbatimStrays).push(s);
}
const strayText = foldableStrays
.map((s) => (s?.content == null ? '' : contentTextForPreambleCheck(s.content)))
.filter(Boolean)
.join('\n\n');
if (strayText) {
const first = assistantMsgs[0];
if (Array.isArray(first.content)) {
// Append a text part — never flatten the array into a string:
// non-text parts (image_url …) encode as different wire types
// and extractInlineImages (Array.isArray gate) would lose them.
const hasTextPart = first.content.some((part) => part?.type === 'text');
assistantMsgs[0] = {
...first,
content: [...first.content, { type: 'text', text: `${hasTextPart ? '\n' : ''}${strayText}` }],
};
} else {
const cur = first.content == null ? '' : contentTextForPreambleCheck(first.content);
assistantMsgs[0] = { ...first, content: cur ? `${cur}\n\n${strayText}` : strayText };
}
}
first = false;
}
for (let idx = 0; idx < toolMsgs.length; idx++) {
if (!usedIndices.has(idx)) {
out.push(toolMsgs[idx]);
const usedIndices = new Set();
for (const assistant of assistantMsgs) {
let first = true;
for (const tc of assistant.tool_calls) {
const singleAssistant = {
...assistant,
content: first ? (assistant.content || null) : null,
tool_calls: [tc],
};
if (!first) {
delete singleAssistant.reasoning_content;
delete singleAssistant.reasoning;
}
out.push(singleAssistant);

const tcid = String(tc?.id ?? '');
const matchIdx = toolMsgs.findIndex(
(tm, idx) => tcid && !usedIndices.has(idx) && String(tm?.tool_call_id ?? '') === tcid,
);
if (matchIdx !== -1) {
usedIndices.add(matchIdx);
out.push(toolMsgs[matchIdx]);
}
first = false;
}
}
i = j;
// Unmergeable strays go out whole, after the interleaved pairs: appending
// them at the tail keeps the alternating source pattern (a same-source
// run of 3 needs ≥3 unmergeable strays — rare next to the single
// reasoning+text item Codex replays per turn), and placing them between
// pairs could split a call from its result.
for (const s of verbatimStrays) out.push(s);
for (let idx = 0; idx < toolMsgs.length; idx++) {
if (!usedIndices.has(idx)) out.push(toolMsgs[idx]);
}
i = k;
continue;
}
// No interleave applied: emit the whole assistant run verbatim (calls and
// stray text alike) so nothing is silently dropped.
for (let x = i; x < j; x++) out.push(messages[x]);
i = j;
continue;
}
out.push(m);
i++;
Expand Down
128 changes: 128 additions & 0 deletions test/tool-emulation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,21 @@ describe('interleaveParallelToolMessages', () => {
assert.equal(out[5].content, 'next');
});

it('interleaves consecutive single-tool assistant turns from Responses clients', () => {
const messages = [
{ role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f1', arguments: '{}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', type: 'function', function: { name: 'f2', arguments: '{}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'c3', type: 'function', function: { name: 'f3', arguments: '{}' } }] },
{ role: 'tool', tool_call_id: 'c1', content: 'r1' },
{ role: 'tool', tool_call_id: 'c2', content: 'r2' },
{ role: 'tool', tool_call_id: 'c3', content: 'r3' },
];

const out = interleaveParallelToolMessages(messages);
assert.deepEqual(out.map((m) => m.role), ['assistant', 'tool', 'assistant', 'tool', 'assistant', 'tool']);
assert.deepEqual(out.map((m) => m.tool_calls?.[0]?.id || m.tool_call_id), ['c1', 'c1', 'c2', 'c2', 'c3', 'c3']);
});

it('preserves unmatched tool results and non-parallel messages intact', () => {
const messages = [
{
Expand Down Expand Up @@ -1024,6 +1039,119 @@ describe('interleaveParallelToolMessages', () => {
];
assert.deepEqual(interleaveParallelToolMessages(pending), pending);
});

it('absorbs a text-only assistant inside a parallel-call run instead of breaking it', () => {
// Codex emits parallel function_calls as separate items, then an assistant
// text item, then the results — i.e. [tc, tc, text, tool, tool]. Letting
// that through verbatim encodes as three consecutive ASSISTANT wire
// messages (call, call, text), which the upstream validator rejects with
// invalid_argument ("an internal error occurred"). The stray text belongs
// to the same turn, so fold it into the first call's assistant entry.
const messages = [
{ role: 'user', content: 'list resources' },
{ role: 'assistant', tool_calls: [{ id: 'a:0#x1', type: 'function', function: { name: 'list_mcp_resources', arguments: '{"server":"a"}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'a:1#x2', type: 'function', function: { name: 'list_mcp_resources', arguments: '{"server":"b"}' } }] },
{ role: 'assistant', content: 'checking both servers' },
{ role: 'tool', tool_call_id: 'a:0#x1', content: 'err1' },
{ role: 'tool', tool_call_id: 'a:1#x2', content: 'ok1' },
{ role: 'user', content: 'go on' },
];

const out = interleaveParallelToolMessages(messages);
assert.deepEqual(out.map((m) => m.role), ['user', 'assistant', 'tool', 'assistant', 'tool', 'user']);
assert.equal(out[1].content, 'checking both servers', 'stray text folded into first call turn');
assert.deepEqual(out[1].tool_calls.map((t) => t.id), ['a:0#x1']);
assert.deepEqual(out[3].tool_calls.map((t) => t.id), ['a:1#x2']);
});

it('preserves the full run verbatim when no tool results match', () => {
const messages = [
{ role: 'assistant', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f1', arguments: '{}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', type: 'function', function: { name: 'f2', arguments: '{}' } }] },
{ role: 'assistant', content: 'no results yet' },
{ role: 'user', content: 'next' },
];
assert.deepEqual(interleaveParallelToolMessages(messages), messages);
});

it('keeps every original content part when absorbing stray text', () => {
const image = { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' } };
const content = [{ type: 'text', text: 'see' }, image];
const messages = [
{ role: 'assistant', content, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', type: 'function', function: { name: 'f', arguments: '{}' } }] },
{ role: 'assistant', content: 'stray note' },
{ role: 'tool', tool_call_id: 'c1', content: 'r1' },
{ role: 'tool', tool_call_id: 'c2', content: 'r2' },
];
const out = interleaveParallelToolMessages(messages);
assert.equal(out[0].content[1], image, 'image part must survive as an object');
assert.deepEqual(content, [{ type: 'text', text: 'see' }, image], 'caller array untouched');
});

it('never pairs calls with results when the id is missing on both sides', () => {
// Two empty ids comparing equal is not evidence of call ownership: on the
// native path a missing call id gets a fresh UUID while an empty result id
// never reaches the role=4 branch. Pre-regression this shape interleaved;
// it must pass through verbatim.
const messages = [
{
role: 'assistant',
tool_calls: [
{ type: 'function', function: { name: 'f', arguments: '{}' } },
{ type: 'function', function: { name: 'f', arguments: '{}' } },
],
},
{ role: 'tool', content: 'r1' },
{ role: 'tool', content: 'r2' },
];
assert.deepEqual(interleaveParallelToolMessages(messages), messages);
});

it('emits strays carrying unmergeable fields verbatim instead of folding them', () => {
// Codex replays a reasoning item as an assistant entry carrying
// reasoning_content + signature (native #11/#12 reasoning + sealed blob).
// Folding keeps only `content`, so those fields would be silently dropped —
// the stray goes out whole, after the interleaved pairs, keeping the
// alternating call/result source pattern (2,4,2,4,2).
const stray = {
role: 'assistant',
content: 'checking both servers',
reasoning_content: 'thinking it through',
signature: 'sealed.v1.abc',
};
const messages = [
{ role: 'assistant', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', type: 'function', function: { name: 'f', arguments: '{}' } }] },
stray,
{ role: 'tool', tool_call_id: 'c1', content: 'r1' },
{ role: 'tool', tool_call_id: 'c2', content: 'r2' },
];

const out = interleaveParallelToolMessages(messages);
assert.deepEqual(out.map((m) => m.role), ['assistant', 'tool', 'assistant', 'tool', 'assistant']);
assert.equal(out[4], stray, 'unmergeable stray emitted verbatim — same object, fields intact');
assert.equal(out[0].content, null, 'stray text must NOT fold into the first call turn');
assert.equal(out[0].signature, undefined, 'sealed signature must not leak onto the call turn');
});

it('emits a stray whose content array carries non-text parts verbatim', () => {
const image = { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } };
const strayContent = [{ type: 'text', text: 'look' }, image];
const messages = [
{ role: 'assistant', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', type: 'function', function: { name: 'f', arguments: '{}' } }] },
{ role: 'assistant', content: strayContent },
{ role: 'tool', tool_call_id: 'c1', content: 'r1' },
{ role: 'tool', tool_call_id: 'c2', content: 'r2' },
];

const out = interleaveParallelToolMessages(messages);
const tail = out[out.length - 1];
assert.equal(tail.role, 'assistant');
assert.equal(tail.content[1], image, 'image part survives as an object on the verbatim stray');
assert.deepEqual(strayContent, [{ type: 'text', text: 'look' }, image], 'caller array untouched');
});
});

// ─── The call site: normalizeMessagesForCascade must interleave on the native
Expand Down