diff --git a/CHANGELOG.md b/CHANGELOG.md index 651cade..3bb4054 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". + - Add display pricing for DeepSeek V4.1 Flash, Qwen 3.8 Max 0902, Gemini 3.8 Flash, Muse Spark 1.3 variants, LongCat 2.0 free, and Ling 3.0 Flash Sante free. Verify against the September 15 pricing page and live 69-model catalog; correct DeepSeek V4 Flash and Vision Exp off-peak prices to $0.15/$0.60 with $0.003 cache reads per million tokens. - Refresh model capabilities to `command-code@1.54.0`: add DeepSeek V4.1 Flash image input and `low`/`high`/`max` efforts, GPT-6 Astra capability metadata, Grok 4.6 image input, and MiniMax M3 efforts. Ling 3.0 Flash Sante is reasoning-capable with a 32K output limit but has no published selectable effort levels. Catalog metadata does not make models absent from the Provider API selectable. 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..1bbb209 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -712,6 +712,104 @@ 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("keeps mixed image, text, and error tool results before hoisted images", () => { + const result = messagesToCC( + [ + { + role: "assistant", + content: [ + { type: "toolCall", id: "a", name: "read", arguments: {} }, + { type: "toolCall", id: "b", name: "read", arguments: {} }, + { type: "toolCall", id: "c", name: "read", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: "a", + toolName: "read", + content: [{ type: "image", data: "YQ==", mimeType: "image/png" }], + }, + { + role: "toolResult", + toolCallId: "b", + toolName: "read", + content: [{ type: "text", text: "plain result" }], + }, + { + role: "toolResult", + toolCallId: "c", + toolName: "read", + isError: true, + content: [{ type: "text", text: "failed" }], + }, + ], + { allowImages: true }, + ) + assert.deepEqual( + result.map((entry) => objectAt(entry, ["role"])), + ["assistant", "tool", "tool", "tool", "user"], + ) + assert.deepEqual(objectAt(result, ["3", "content", "0", "output"]), { + type: "error-text", + value: "failed", + }) + assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "plain result") + }) + it("drops previous assistant reasoning while preserving text and tool calls", () => { const result = messagesToCC([ { role: "user", content: "first question" },