From fcd3dcb4099dc526bed5dc776dd5f6eb9b13be51 Mon Sep 17 00:00:00 2001 From: luulam Date: Mon, 3 Aug 2026 17:06:32 +0700 Subject: [PATCH] feat(translator): add vision support for commandcode provider Map OpenAI image_url / Claude-style image blocks to the {type:"image", image:""} shape the command-code CLI sends to /alpha/generate instead of dropping them to "[image omitted]". Handles data URIs, raw base64 (with media_type / image/png fallback), and remote URLs. - Promote bugs-gemini-cursor-commandcode "image content is preserved" from it.fails to a real assertion (bug fixed) - Add vision unit tests to openai-to-commandcode.test.js - Add test-commandcode-vision.sh curl helper for live verification Co-authored-by: CommandCodeBot --- .../request/openai-to-commandcode.js | 294 +++++++----- test-commandcode-vision.sh | 41 ++ .../bugs-gemini-cursor-commandcode.test.js | 178 ++++--- tests/unit/openai-to-commandcode.test.js | 447 ++++++++++++------ 4 files changed, 637 insertions(+), 323 deletions(-) create mode 100755 test-commandcode-vision.sh diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index 9825048b..3479a0c8 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -13,160 +13,200 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { randomUUID } from "crypto"; import { ROLE, OPENAI_BLOCK } from "../schema/index.js"; +import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; +import { parseDataUri } from "../concerns/image.js"; import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; function flattenText(content) { - if (content == null) return ""; - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts = []; - for (const p of content) { - if (typeof p === "string") parts.push(p); - else if (p && typeof p === "object" && typeof p.text === "string") parts.push(p.text); - } - return parts.join("\n"); - } - return String(content); + if (content == null) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = []; + for (const p of content) { + if (typeof p === "string") parts.push(p); + else if (p && typeof p === "object" && typeof p.text === "string") + parts.push(p.text); + } + return parts.join("\n"); + } + return String(content); } function toContentBlocks(content) { - if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }]; - if (typeof content === "string") return [{ type: OPENAI_BLOCK.TEXT, text: content }]; - if (Array.isArray(content)) { - const blocks = []; - for (const part of content) { - if (typeof part === "string") { - blocks.push({ type: OPENAI_BLOCK.TEXT, text: part }); - } else if (part && typeof part === "object") { - if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") { - blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); - } else if (part.type === OPENAI_BLOCK.IMAGE_URL || part.type === OPENAI_BLOCK.IMAGE) { - blocks.push({ type: OPENAI_BLOCK.TEXT, text: "[image omitted]" }); - } else if (typeof part.text === "string") { - blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); - } - } - } - return blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }]; - } - return [{ type: OPENAI_BLOCK.TEXT, text: String(content) }]; + if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }]; + if (typeof content === "string") + return [{ type: OPENAI_BLOCK.TEXT, text: content }]; + if (Array.isArray(content)) { + const blocks = []; + for (const part of content) { + if (typeof part === "string") { + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part }); + } else if (part && typeof part === "object") { + if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") { + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } else if ( + part.type === OPENAI_BLOCK.IMAGE_URL || + part.type === OPENAI_BLOCK.IMAGE + ) { + // CommandCode `/alpha/generate` accepts {type:"image", image:""} — + // same shape the official command-code CLI sends (verified from CLI source). + const src = part.source; + let raw = part.image_url?.url || src?.data || src?.url || ""; + let parsed = parseDataUri(raw); + if (!parsed && src?.type === "base64" && src?.data) { + // Claude-style base64 source without a data-URI prefix → wrap it. + raw = `data:${src.media_type || DEFAULT_IMAGE_MIME};base64,${src.data}`; + parsed = parseDataUri(raw); + } + if (parsed) { + blocks.push({ + type: "image", + image: `data:${parsed.mimeType};base64,${parsed.base64}`, + }); + } else if (raw) { + blocks.push({ type: "image", image: raw }); + } + } else if (typeof part.text === "string") { + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } + } + } + return blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }]; + } + return [{ type: OPENAI_BLOCK.TEXT, text: String(content) }]; } function safeParseJson(s) { - if (s == null) return {}; - if (typeof s !== "string") return s; - try { return JSON.parse(s); } catch { return {}; } + if (s == null) return {}; + if (typeof s !== "string") return s; + try { + return JSON.parse(s); + } catch { + return {}; + } } function convertMessages(messages = []) { - const out = []; - const systemTexts = []; + const out = []; + const systemTexts = []; - for (const m of messages) { - if (!m) continue; - const role = m.role; + for (const m of messages) { + if (!m) continue; + const role = m.role; - if (role === ROLE.SYSTEM) { - const t = flattenText(m.content); - if (t) systemTexts.push(t); - continue; - } + if (role === ROLE.SYSTEM) { + const t = flattenText(m.content); + if (t) systemTexts.push(t); + continue; + } - if (role === ROLE.TOOL) { - const value = typeof m.content === "string" ? m.content : flattenText(m.content); - out.push({ - role: ROLE.TOOL, - content: [{ - type: "tool-result", - toolCallId: m.tool_call_id || "", - toolName: m.name || "", - output: { type: "text", value }, - }], - }); - continue; - } + if (role === ROLE.TOOL) { + const value = + typeof m.content === "string" ? m.content : flattenText(m.content); + out.push({ + role: ROLE.TOOL, + content: [ + { + type: "tool-result", + toolCallId: m.tool_call_id || "", + toolName: m.name || "", + output: { type: "text", value }, + }, + ], + }); + continue; + } - if (role === ROLE.ASSISTANT) { - const blocks = []; - const text = flattenText(m.content); - if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text }); - if (Array.isArray(m.tool_calls)) { - for (const tc of m.tool_calls) { - const fn = tc.function || {}; - blocks.push({ - type: "tool-call", - toolCallId: tc.id || "", - toolName: fn.name || "", - input: safeParseJson(fn.arguments), - }); - } - } - out.push({ role: ROLE.ASSISTANT, content: blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }] }); - continue; - } + if (role === ROLE.ASSISTANT) { + const blocks = []; + const text = flattenText(m.content); + if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text }); + if (Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) { + const fn = tc.function || {}; + blocks.push({ + type: "tool-call", + toolCallId: tc.id || "", + toolName: fn.name || "", + input: safeParseJson(fn.arguments), + }); + } + } + out.push({ + role: ROLE.ASSISTANT, + content: blocks.length + ? blocks + : [{ type: OPENAI_BLOCK.TEXT, text: "" }], + }); + continue; + } - out.push({ role: ROLE.USER, content: toContentBlocks(m.content) }); - } + out.push({ role: ROLE.USER, content: toContentBlocks(m.content) }); + } - return { messages: out, system: systemTexts.join("\n\n") }; + return { messages: out, system: systemTexts.join("\n\n") }; } function convertTools(tools) { - if (!Array.isArray(tools) || tools.length === 0) return undefined; - const result = []; - for (const t of tools) { - if (!t) continue; - if (t.type === OPENAI_BLOCK.FUNCTION && t.function) { - result.push({ - name: t.function.name, - description: t.function.description, - input_schema: t.function.parameters || { type: "object" }, - }); - } else if (t.name && (t.input_schema || t.parameters)) { - result.push({ - name: t.name, - description: t.description, - input_schema: t.input_schema || t.parameters, - }); - } - } - return result.length ? result : undefined; + if (!Array.isArray(tools) || tools.length === 0) return undefined; + const result = []; + for (const t of tools) { + if (!t) continue; + if (t.type === OPENAI_BLOCK.FUNCTION && t.function) { + result.push({ + name: t.function.name, + description: t.function.description, + input_schema: t.function.parameters || { type: "object" }, + }); + } else if (t.name && (t.input_schema || t.parameters)) { + result.push({ + name: t.name, + description: t.description, + input_schema: t.input_schema || t.parameters, + }); + } + } + return result.length ? result : undefined; } -export function openaiToCommandCodeRequest(model, body, stream /* , credentials */) { - const { messages, system } = convertMessages(body.messages); - const params = { - model, - messages, - stream: stream !== false, - max_tokens: body.max_tokens ?? body.max_output_tokens ?? DEFAULT_MAX_TOKENS, - temperature: body.temperature ?? 0.3, - }; +export function openaiToCommandCodeRequest( + model, + body, + stream /* , credentials */, +) { + const { messages, system } = convertMessages(body.messages); + const params = { + model, + messages, + stream: stream !== false, + max_tokens: body.max_tokens ?? body.max_output_tokens ?? DEFAULT_MAX_TOKENS, + temperature: body.temperature ?? 0.3, + }; - if (system) params.system = system; + if (system) params.system = system; - const tools = convertTools(body.tools); - if (tools) params.tools = tools; - if (body.top_p != null) params.top_p = body.top_p; + const tools = convertTools(body.tools); + if (tools) params.tools = tools; + if (body.top_p != null) params.top_p = body.top_p; - const today = new Date().toISOString().slice(0, 10); + const today = new Date().toISOString().slice(0, 10); - return { - threadId: randomUUID(), - memory: "", - config: { - workingDir: process.cwd(), - date: today, - environment: process.platform, - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], - }, - params, - }; + return { + threadId: randomUUID(), + memory: "", + config: { + workingDir: process.cwd(), + date: today, + environment: process.platform, + structure: [], + isGitRepo: false, + currentBranch: "", + mainBranch: "", + gitStatus: "", + recentCommits: [], + }, + params, + }; } register(FORMATS.OPENAI, FORMATS.COMMANDCODE, openaiToCommandCodeRequest, null); diff --git a/test-commandcode-vision.sh b/test-commandcode-vision.sh new file mode 100755 index 00000000..8b17d6db --- /dev/null +++ b/test-commandcode-vision.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Test vision support for the commandcode provider in 9router. +# +# Usage: +# ./test-commandcode-vision.sh [base_url] [model] +# BASE_URL=http://localhost:20128 ./test-commandcode-vision.sh +# +# Defaults: base_url=http://localhost:20128, model=commandcode/Qwen/Qwen3.6-Max-Preview + +set -euo pipefail + +BASE_URL="${1:-${BASE_URL:-http://localhost:20128}}" +MODEL="${2:-${MODEL:-commandcode/Qwen/Qwen3.6-Max-Preview}}" + +# 1x1 red pixel PNG (base64) — enough to prove the image reaches the model. +PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + +echo "==> POST $BASE_URL/v1/chat/completions" +echo "==> model: $MODEL" +echo + +curl -sS -N "$BASE_URL/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d "$( + cat < translateRequest(FORMATS.OPENAI, FORMATS.GEMINI, "m", body, true, null, "gemini"); -const O2C = (body) => translateRequest(FORMATS.OPENAI, FORMATS.CURSOR, "m", body, true, null, "cursor"); -const O2CC = (body) => translateRequest(FORMATS.OPENAI, FORMATS.COMMANDCODE, "m", body, true, null, "commandcode"); +const O2G = (body) => + translateRequest( + FORMATS.OPENAI, + FORMATS.GEMINI, + "m", + body, + true, + null, + "gemini", + ); +const O2C = (body) => + translateRequest( + FORMATS.OPENAI, + FORMATS.CURSOR, + "m", + body, + true, + null, + "cursor", + ); +const O2CC = (body) => + translateRequest( + FORMATS.OPENAI, + FORMATS.COMMANDCODE, + "m", + body, + true, + null, + "commandcode", + ); describe("OpenAI → Gemini", () => { - // openai-to-gemini.js:92-96 — each system message overwrites systemInstruction → only last kept - // KNOWN BUG - it.fails("multiple system messages are all kept", () => { - const out = O2G({ - messages: [ - { role: "system", content: "RULE_ONE" }, - { role: "system", content: "RULE_TWO" }, - { role: "user", content: "hi" }, - ], - }); - expect(JSON.stringify(out.systemInstruction), "earlier system lost").toContain("RULE_ONE"); - }); + // openai-to-gemini.js:92-96 — each system message overwrites systemInstruction → only last kept + // KNOWN BUG + it.fails("multiple system messages are all kept", () => { + const out = O2G({ + messages: [ + { role: "system", content: "RULE_ONE" }, + { role: "system", content: "RULE_TWO" }, + { role: "user", content: "hi" }, + ], + }); + expect( + JSON.stringify(out.systemInstruction), + "earlier system lost", + ).toContain("RULE_ONE"); + }); }); describe("OpenAI → Cursor", () => { - // openai-to-cursor.js:12-24 — image content fully dropped (text only) - // KNOWN BUG - it.fails("image content is preserved", () => { - const out = O2C({ - messages: [{ role: "user", content: [ - { type: "text", text: "look" }, - { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, - ] }], - }); - expect(JSON.stringify(out), "image dropped").toContain("AAAA"); - }); + // openai-to-cursor.js:12-24 — image content fully dropped (text only) + // KNOWN BUG + it.fails("image content is preserved", () => { + const out = O2C({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,AAAA" }, + }, + ], + }, + ], + }); + expect(JSON.stringify(out), "image dropped").toContain("AAAA"); + }); - // openai-to-cursor.js:179 — max_tokens hardcoded to 32000 - // KNOWN BUG - it.fails("respects client max_tokens", () => { - const out = O2C({ max_tokens: 200, messages: [{ role: "user", content: "hi" }] }); - expect(out.max_tokens).toBe(200); - }); + // openai-to-cursor.js:179 — max_tokens hardcoded to 32000 + // KNOWN BUG + it.fails("respects client max_tokens", () => { + const out = O2C({ + max_tokens: 200, + messages: [{ role: "user", content: "hi" }], + }); + expect(out.max_tokens).toBe(200); + }); }); describe("OpenAI → CommandCode", () => { - // openai-to-commandcode.js:53-57 — safeParseJson returns {} on bad JSON (args silently lost) - // KNOWN BUG - it.fails("malformed tool arguments are not silently emptied", () => { - const out = O2CC({ - messages: [ - { role: "user", content: "go" }, - { role: "assistant", content: "", tool_calls: [ - { id: "c1", type: "function", function: { name: "f", arguments: "{bad" } }, - ] }, - { role: "tool", tool_call_id: "c1", content: "r" }, - ], - }); - const asst = out.params.messages.find((m) => m.role === "assistant"); - const call = asst.content.find((b) => b.type === "tool-call"); - expect(Object.keys(call.input).length, "arguments silently dropped to {}").toBeGreaterThan(0); - }); + // openai-to-commandcode.js:53-57 — safeParseJson returns {} on bad JSON (args silently lost) + // KNOWN BUG + it.fails("malformed tool arguments are not silently emptied", () => { + const out = O2CC({ + messages: [ + { role: "user", content: "go" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "f", arguments: "{bad" }, + }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "r" }, + ], + }); + const asst = out.params.messages.find((m) => m.role === "assistant"); + const call = asst.content.find((b) => b.type === "tool-call"); + expect( + Object.keys(call.input).length, + "arguments silently dropped to {}", + ).toBeGreaterThan(0); + }); - // openai-to-commandcode.js:41-42 — image becomes "[image omitted]" - // KNOWN BUG - it.fails("image content is preserved", () => { - const out = O2CC({ - messages: [{ role: "user", content: [ - { type: "text", text: "look" }, - { type: "image_url", image_url: { url: "data:image/png;base64,BBBB" } }, - ] }], - }); - expect(JSON.stringify(out), "image omitted").toContain("BBBB"); - }); + // openai-to-commandcode.js — image blocks now map to {type:"image", image:"data:..."} + // FIXED: was "[image omitted]", now preserved as data URI + it("image content is preserved", () => { + const out = O2CC({ + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,BBBB" }, + }, + ], + }, + ], + }); + expect(JSON.stringify(out), "image omitted").toContain("BBBB"); + }); }); diff --git a/tests/unit/openai-to-commandcode.test.js b/tests/unit/openai-to-commandcode.test.js index 7f12dc85..0a1fcf97 100644 --- a/tests/unit/openai-to-commandcode.test.js +++ b/tests/unit/openai-to-commandcode.test.js @@ -14,168 +14,341 @@ import { openaiToCommandCodeRequest } from "../../open-sse/translator/request/op const MODEL = "moonshotai/Kimi-K2.6"; describe("openaiToCommandCodeRequest — basic envelope", () => { - it("returns the expected top-level envelope shape", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - }, true); + it("returns the expected top-level envelope shape", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + }, + true, + ); - expect(out).toHaveProperty("threadId"); - expect(out).toHaveProperty("memory"); - expect(out).toHaveProperty("config"); - expect(out).toHaveProperty("params"); - expect(out.params.model).toBe(MODEL); - expect(out.params.stream).toBe(true); - }); + expect(out).toHaveProperty("threadId"); + expect(out).toHaveProperty("memory"); + expect(out).toHaveProperty("config"); + expect(out).toHaveProperty("params"); + expect(out.params.model).toBe(MODEL); + expect(out.params.stream).toBe(true); + }); }); describe("openaiToCommandCodeRequest — system handling", () => { - it("hoists system messages to params.system (string), not messages[]", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "system", content: "You are concise." }, - { role: "user", content: "hi" }, - ], - }, true); + it("hoists system messages to params.system (string), not messages[]", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "hi" }, + ], + }, + true, + ); - expect(typeof out.params.system).toBe("string"); - expect(out.params.system).toBe("You are concise."); - const roles = out.params.messages.map((m) => m.role); - expect(roles).not.toContain("system"); - }); + expect(typeof out.params.system).toBe("string"); + expect(out.params.system).toBe("You are concise."); + const roles = out.params.messages.map((m) => m.role); + expect(roles).not.toContain("system"); + }); - it("joins multiple system messages with blank line", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "system", content: "A" }, - { role: "system", content: "B" }, - { role: "user", content: "hi" }, - ], - }, true); + it("joins multiple system messages with blank line", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "system", content: "A" }, + { role: "system", content: "B" }, + { role: "user", content: "hi" }, + ], + }, + true, + ); - expect(out.params.system).toBe("A\n\nB"); - }); + expect(out.params.system).toBe("A\n\nB"); + }); - it("omits params.system when no system messages", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - }, true); - expect(out.params.system).toBeUndefined(); - }); + it("omits params.system when no system messages", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + }, + true, + ); + expect(out.params.system).toBeUndefined(); + }); +}); + +describe("openaiToCommandCodeRequest — vision / image blocks", () => { + const PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + it('maps OpenAI image_url (data URI) → {type:"image", image:"data:..."}', () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What color?" }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${PNG}` }, + }, + ], + }, + ], + }, + true, + ); + + const blocks = out.params.messages[0].content; + expect(blocks[0]).toEqual({ type: "text", text: "What color?" }); + expect(blocks[1]).toEqual({ + type: "image", + image: `data:image/png;base64,${PNG}`, + }); + }); + + it("maps Claude-style image block (source.base64) → data URI with media_type", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { + type: "base64", + media_type: "image/jpeg", + data: "AAAA", + }, + }, + ], + }, + ], + }, + true, + ); + + expect(out.params.messages[0].content[0]).toEqual({ + type: "image", + image: "data:image/jpeg;base64,AAAA", + }); + }); + + it("passes raw URL through when image_url is a remote http(s) URL", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/a.png" }, + }, + ], + }, + ], + }, + true, + ); + + expect(out.params.messages[0].content[0]).toEqual({ + type: "image", + image: "https://example.com/a.png", + }); + }); + + it("skips image block when no usable image source", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "" } }], + }, + ], + }, + true, + ); + + const blocks = out.params.messages[0].content; + expect(blocks.every((b) => b.type !== "image")).toBe(true); + }); }); describe("openaiToCommandCodeRequest — content shape", () => { - it("MUST always emit content as Array (never string) for user", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hello" }], - }, true); + it("MUST always emit content as Array (never string) for user", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hello" }], + }, + true, + ); - const u = out.params.messages[0]; - expect(Array.isArray(u.content)).toBe(true); - expect(u.content[0]).toEqual({ type: "text", text: "hello" }); - }); + const u = out.params.messages[0]; + expect(Array.isArray(u.content)).toBe(true); + expect(u.content[0]).toEqual({ type: "text", text: "hello" }); + }); - it("MUST always emit content as Array for assistant", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "user", content: "a" }, - { role: "assistant", content: "b" }, - ], - }, true); - const a = out.params.messages[1]; - expect(Array.isArray(a.content)).toBe(true); - expect(a.content[0]).toEqual({ type: "text", text: "b" }); - }); + it("MUST always emit content as Array for assistant", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ], + }, + true, + ); + const a = out.params.messages[1]; + expect(Array.isArray(a.content)).toBe(true); + expect(a.content[0]).toEqual({ type: "text", text: "b" }); + }); }); describe("openaiToCommandCodeRequest — tool role / tool-result (AI SDK)", () => { - it("converts role:\"tool\" to role:\"tool\" with tool-result block; output is {type:\"text\",value}", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "user", content: "run X" }, - { - role: "assistant", - content: null, - tool_calls: [ - { id: "call_1", type: "function", function: { name: "do_x", arguments: "{\"a\":1}" } }, - ], - }, - { role: "tool", tool_call_id: "call_1", name: "do_x", content: "RESULT_OK" }, - ], - }, true); + it('converts role:"tool" to role:"tool" with tool-result block; output is {type:"text",value}', () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "user", content: "run X" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "do_x", arguments: '{"a":1}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + name: "do_x", + content: "RESULT_OK", + }, + ], + }, + true, + ); - const toolMsg = out.params.messages[out.params.messages.length - 1]; - expect(toolMsg.role).toBe("tool"); - const block = toolMsg.content[0]; - expect(block.type).toBe("tool-result"); - expect(block.toolCallId).toBe("call_1"); - expect(block.toolName).toBe("do_x"); - expect(block.output).toEqual({ type: "text", value: "RESULT_OK" }); - }); + const toolMsg = out.params.messages[out.params.messages.length - 1]; + expect(toolMsg.role).toBe("tool"); + const block = toolMsg.content[0]; + expect(block.type).toBe("tool-result"); + expect(block.toolCallId).toBe("call_1"); + expect(block.toolName).toBe("do_x"); + expect(block.output).toEqual({ type: "text", value: "RESULT_OK" }); + }); }); describe("openaiToCommandCodeRequest — assistant tool_calls / tool-call", () => { - it("converts assistant.tool_calls[] into content blocks of type tool-call", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [ - { role: "user", content: "go" }, - { - role: "assistant", - content: null, - tool_calls: [ - { id: "call_42", type: "function", function: { name: "search", arguments: "{\"q\":\"hi\"}" } }, - ], - }, - ], - }, true); + it("converts assistant.tool_calls[] into content blocks of type tool-call", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [ + { role: "user", content: "go" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_42", + type: "function", + function: { name: "search", arguments: '{"q":"hi"}' }, + }, + ], + }, + ], + }, + true, + ); - const asst = out.params.messages[1]; - expect(asst.role).toBe("assistant"); - const tc = asst.content.find((b) => b.type === "tool-call"); - expect(tc).toBeDefined(); - expect(tc.toolCallId).toBe("call_42"); - expect(tc.toolName).toBe("search"); - expect(tc.input).toEqual({ q: "hi" }); - }); + const asst = out.params.messages[1]; + expect(asst.role).toBe("assistant"); + const tc = asst.content.find((b) => b.type === "tool-call"); + expect(tc).toBeDefined(); + expect(tc.toolCallId).toBe("call_42"); + expect(tc.toolName).toBe("search"); + expect(tc.input).toEqual({ q: "hi" }); + }); }); describe("openaiToCommandCodeRequest — tools schema conversion", () => { - it("converts OpenAI {type:\"function\", function:{...}} to Anthropic plain {name, input_schema}", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - tools: [ - { - type: "function", - function: { - name: "weather", - description: "Get weather", - parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, - }, - }, - ], - }, true); + it('converts OpenAI {type:"function", function:{...}} to Anthropic plain {name, input_schema}', () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + }, + ], + }, + true, + ); - const t = out.params.tools[0]; - expect(t.name).toBe("weather"); - expect(t.input_schema).toBeDefined(); - expect(t.input_schema.type).toBe("object"); - expect(t.function).toBeUndefined(); - expect(t.parameters).toBeUndefined(); - }); + const t = out.params.tools[0]; + expect(t.name).toBe("weather"); + expect(t.input_schema).toBeDefined(); + expect(t.input_schema.type).toBe("object"); + expect(t.function).toBeUndefined(); + expect(t.parameters).toBeUndefined(); + }); - it("preserves description on converted tool", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - tools: [ - { type: "function", function: { name: "ping", description: "Ping the server", parameters: { type: "object" } } }, - ], - }, true); - expect(out.params.tools[0].description).toBe("Ping the server"); - }); + it("preserves description on converted tool", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "ping", + description: "Ping the server", + parameters: { type: "object" }, + }, + }, + ], + }, + true, + ); + expect(out.params.tools[0].description).toBe("Ping the server"); + }); - it("does not include tools field when input has none", () => { - const out = openaiToCommandCodeRequest(MODEL, { - messages: [{ role: "user", content: "hi" }], - }, true); - expect(out.params.tools).toBeUndefined(); - }); + it("does not include tools field when input has none", () => { + const out = openaiToCommandCodeRequest( + MODEL, + { + messages: [{ role: "user", content: "hi" }], + }, + true, + ); + expect(out.params.tools).toBeUndefined(); + }); });