From 7a9a4298ec40d7b274c9312b269b48366868e250 Mon Sep 17 00:00:00 2001 From: Star-233 <64454088+Star-233@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:48:44 +0800 Subject: [PATCH] fix(converters): batch consecutive tool results before emitting images When an assistant turn contains multiple parallel tool calls (such as reading multiple images), messagesToCC previously emitted a user message with the image immediately after each individual tool result. This interleaved a user message into the middle of the tool-result sequence. The Command Code generate transport closes the tool turn upon seeing a user message and rejects the request with "Tool result is missing for tool call ." Batch consecutive tool results first, then emit the attached images in a single following user message. --- CHANGELOG.md | 2 ++ src/converters.ts | 58 ++++++++++++++++++++++-------------- tests/test-pure-functions.ts | 54 +++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 879148f..2c6316d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Batch consecutive tool-result images after all tool results on the generate transport, preventing interleaved user messages from breaking multi-tool turns with "Tool result is missing". + ## 0.6.4 - 2026-09-03 - Refresh the generated Command Code capability catalog from `command-code@1.40.1` to `command-code@1.44.0`, adding current image-input, reasoning, effort, and output-limit metadata for newly published models. diff --git a/src/converters.ts b/src/converters.ts index d5e7d91..7a1a1eb 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -245,7 +245,9 @@ export function messagesToCC( const out: unknown[] = [] const { callIds, resultIds } = toolCallState(messages) - for (const message of messages ?? []) { + const rawMessages = messages ?? [] + for (let i = 0; i < rawMessages.length; i++) { + const message = rawMessages[i] if (message.role === "user" || message.role === "developer") { // Hosts such as OMP steer the agent by injecting developer-role messages // (advisor notes, reminders, nudges) mid-conversation. /alpha/generate @@ -288,30 +290,42 @@ export function messagesToCC( if (parts.length > 0) out.push({ role: "assistant", content: parts }) if (missingResults.length > 0) out.push({ role: "tool", content: missingResults }) } else if (message.role === "toolResult") { - if (!message.toolCallId || !callIds.has(message.toolCallId)) continue - const images = imageParts(message.content) - const text = textContent(message) - const outputText = - text || - (images.length > 0 && !allowImages ? "[Image omitted: model does not support images]" : "") - out.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: message.toolCallId, - toolName: message.toolName, - output: message.isError - ? { type: "error-text", value: outputText } - : { type: "text", value: outputText }, - }, - ], - }) + const pendingImages: Record[] = [] + let j = i + for (; j < rawMessages.length && rawMessages[j].role === "toolResult"; j++) { + const toolMsg = rawMessages[j] + if (!toolMsg.toolCallId || !callIds.has(toolMsg.toolCallId)) continue + const images = imageParts(toolMsg.content) + const text = textContent(toolMsg) + const outputText = + text || + (images.length > 0 && !allowImages + ? "[Image omitted: model does not support images]" + : "") + out.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: toolMsg.toolCallId, + toolName: toolMsg.toolName, + output: toolMsg.isError + ? { type: "error-text", value: outputText } + : { type: "text", value: outputText }, + }, + ], + }) + + if (images.length > 0 && allowImages) { + pendingImages.push(...images.map(imageToCommandCode)) + } + } + i = j - 1 - if (images.length > 0 && allowImages) { + if (pendingImages.length > 0) { out.push({ role: "user", - content: images.map(imageToCommandCode), + content: pendingImages, }) } } diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 6a1a650..efa766b 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -712,6 +712,60 @@ describe("messagesToCC()", () => { }) }) + it("batches consecutive tool-result images after all tool results instead of interleaving user messages", () => { + const result = messagesToCC( + [ + { role: "user", content: "read two images" }, + { + role: "assistant", + content: [ + { type: "toolCall", id: "c1", name: "read", arguments: { path: "a.png" } }, + { type: "toolCall", id: "c2", name: "read", arguments: { path: "b.png" } }, + ], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: [ + { type: "text", text: "image a" }, + { type: "image", data: "YWFh", mimeType: "image/png" }, + ], + }, + { + role: "toolResult", + toolCallId: "c2", + toolName: "read", + content: [ + { type: "text", text: "image b" }, + { type: "image", data: "YmJi", mimeType: "image/png" }, + ], + }, + ], + { allowImages: true }, + ) + + assert.equal(objectAt(result, ["2", "role"]), "tool") + assert.equal(objectAt(result, ["2", "content", "0", "toolCallId"]), "c1") + assert.equal(objectAt(result, ["3", "role"]), "tool") + assert.equal(objectAt(result, ["3", "content", "0", "toolCallId"]), "c2") + assert.deepEqual(objectAt(result, ["4"]), { + role: "user", + content: [ + { + type: "image", + image: "data:image/png;base64,YWFh", + mimeType: "image/png", + }, + { + type: "image", + image: "data:image/png;base64,YmJi", + mimeType: "image/png", + }, + ], + }) + }) + it("drops previous assistant reasoning while preserving text and tool calls", () => { const result = messagesToCC([ { role: "user", content: "first question" },