diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc4d81..c2030d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Allow switching from a vision-capable model to a text-only model by omitting historical image tool results while preserving their text output; direct image prompts still fail clearly. - Stream incremental tool-call arguments from the `/alpha/generate` transport instead of waiting for the final complete tool-call event. - Add a daily GitHub Actions synchronization job that opens or updates a pull request for CLI version, image capability, reasoning, effort, and output-limit changes in the latest published Command Code catalog. - Refresh static model capabilities from `command-code@1.32.2`, separating reasoning support from selectable effort levels and honoring model-specific output limits. diff --git a/src/converters.ts b/src/converters.ts index 1e7ea97..6cf71ac 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -66,9 +66,8 @@ function imageContentError(role: string): Error { export function assertTextOnlyMessages(messages?: readonly MessageLike[]): void { for (const message of messages ?? []) { - if (imageParts(message.content).length > 0) { - const role = message.role === "toolResult" ? "tool results" : `${message.role} messages` - throw imageContentError(role) + if (message.role !== "toolResult" && imageParts(message.content).length > 0) { + throw imageContentError(`${message.role} messages`) } } } @@ -268,6 +267,11 @@ export function messagesToCC( 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: [ @@ -276,15 +280,13 @@ export function messagesToCC( toolCallId: message.toolCallId, toolName: message.toolName, output: message.isError - ? { type: "error-text", value: textContent(message) } - : { type: "text", value: textContent(message) }, + ? { type: "error-text", value: outputText } + : { type: "text", value: outputText }, }, ], }) - const images = imageParts(message.content) - if (images.length > 0) { - if (!allowImages) throw imageContentError("tool results") + if (images.length > 0 && allowImages) { out.push({ role: "user", content: images.map(imageToCommandCode), diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 17a4c78..96e742a 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -161,7 +161,7 @@ describe("projectSlugFromPath()", () => { }) describe("text-only image handling", () => { - it("rejects image content for models without image support", () => { + it("rejects direct image input for models without image support", () => { assert.throws( () => assertTextOnlyMessages([ @@ -172,16 +172,17 @@ describe("text-only image handling", () => { ]), /does not support image content/i, ) - assert.throws( - () => - assertTextOnlyMessages([ - { - role: "toolResult", - toolCallId: "c1", - content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], - }, - ]), - /does not support image content/i, + }) + + it("allows historical tool-result images to be omitted for text-only models", () => { + assert.doesNotThrow(() => + assertTextOnlyMessages([ + { + role: "toolResult", + toolCallId: "c1", + content: [{ type: "image", data: "base64-data", mimeType: "image/png" }], + }, + ]), ) }) }) @@ -597,6 +598,48 @@ describe("messagesToCC()", () => { ) }) + it("omits tool-result images for text-only models while preserving their text", () => { + const result = messagesToCC([ + { role: "user", content: "read image" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: [ + { type: "text", text: "image attached" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }, + ], + }, + ]) + + assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached") + assert.equal(objectAt(result, ["3"]), undefined) + }) + + it("describes an omitted image-only tool result for text-only models", () => { + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }], + }, + ]) + + assert.equal( + objectAt(result, ["1", "content", "0", "output", "value"]), + "[Image omitted: model does not support images]", + ) + }) + it("preserves tool-result images as a following user image message", () => { const result = messagesToCC( [ diff --git a/tests/test-stream.ts b/tests/test-stream.ts index ea92508..a639980 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -240,12 +240,16 @@ describe("streamCommandCode — successful streams", () => { ) }) - it("rejects a tool-result image before network access for text-only models", async () => { + it("omits a historical tool-result image after switching to a text-only model", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) const events = await collectEvents( streamCommandCode( - makeModel({ id: "deepseek/deepseek-v4-pro" }), + makeModel({ id: "deepseek/deepseek-v4-flash" }), makeContext({ messages: [ { role: "user", content: "read the image" }, @@ -257,19 +261,29 @@ describe("streamCommandCode — successful streams", () => { role: "toolResult", toolCallId: "c1", toolName: "read", - content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + content: [ + { type: "text", text: "image attached" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], }, + { role: "user", content: "continue without the image" }, ], }), { apiKey: "mock-key" }, ), ) - const lastEvent = events.at(-1) - assert.equal(lastEvent?.type, "error") - if (lastEvent?.type !== "error") throw new Error("expected error event") - assert.match(lastEvent.error.errorMessage ?? "", /does not support image content/i) - assert.equal(server.requestCount(), 0) + assert.equal(events.at(-1)?.type, "done") + assert.equal(server.requestCount(), 1) + const body = server.lastRequestBody() + assert.equal( + objectAt(body, ["params", "messages", "2", "content", "0", "output", "value"]), + "image attached", + ) + assert.equal( + objectAt(body, ["params", "messages", "3", "content"]), + "continue without the image", + ) }) it("rejects images before network access for text-only models", async () => {