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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 36 additions & 22 deletions src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string>[] = []
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,
})
}
}
Expand Down
98 changes: 98 additions & 0 deletions tests/test-pure-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading