diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js index aad40439..e047ad57 100644 --- a/open-sse/executors/commandcode.js +++ b/open-sse/executors/commandcode.js @@ -1,6 +1,7 @@ import { randomUUID } from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { commandCodeToOpenAIResponse } from "../translator/response/commandcode-to-openai.js"; import { SSE_DONE } from "../utils/sseConstants.js"; @@ -14,81 +15,268 @@ import { SSE_DONE } from "../utils/sseConstants.js"; * We translate each event to an OpenAI chat.completion.chunk and emit it as SSE so * both the streaming and non-streaming (forced SSE → JSON) downstream handlers in * 9router can consume it without further format translation. + * + * Terminal upstream failures arrive as `{"type":"error"}` events inside the HTTP + * 200 stream, so a plain `response.ok` check cannot see them. We peek the first + * events before committing the response (see peekForUpstreamError) so a stream + * that starts with an error fails fast — the normal `!response.ok` path then + * triggers account/model fallback instead of streaming fake success content. */ export class CommandCodeExecutor extends BaseExecutor { - constructor() { - super("commandcode", PROVIDERS.commandcode); - } + constructor() { + super("commandcode", PROVIDERS.commandcode); + } - transformRequest(model, body, stream, credentials) { - body.stream = true; - return body; - } + transformRequest(model, body, stream, credentials) { + body.stream = true; + return body; + } - buildHeaders(credentials, stream = true) { - const headers = { - "Content-Type": "application/json", - ...(this.config.headers || {}), - "x-session-id": randomUUID(), - }; + buildHeaders(credentials, stream = true) { + const headers = { + "Content-Type": "application/json", + ...(this.config.headers || {}), + "x-session-id": randomUUID(), + }; - const token = credentials?.apiKey || credentials?.accessToken; - if (token) headers["Authorization"] = `Bearer ${token}`; + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers["Authorization"] = `Bearer ${token}`; - if (stream) headers["Accept"] = "text/event-stream"; - return headers; - } + if (stream) headers["Accept"] = "text/event-stream"; + return headers; + } - async execute(opts) { - const result = await super.execute(opts); - if (!result?.response?.ok || !result.response.body) return result; - result.response = wrapNdjsonAsOpenAISse(result.response, opts.model); - return result; - } + async execute(opts) { + const result = await super.execute(opts); + if (!result?.response?.ok || !result.response.body) return result; + result.response = await peekForUpstreamError(result.response, opts.model, { + signal: opts.signal, + }); + return result; + } +} + +// How long to hold the response open while peeking the first upstream events. +// An upstream error event ("Network connection lost") is emitted at stream +// start, so the peek is fast; the bound just prevents a slow-started stream +// from being held hostage. Env: COMMANDCODE_PEEK_TIMEOUT_MS. +const PEEK_TIMEOUT_MS = (() => { + const raw = process.env.COMMANDCODE_PEEK_TIMEOUT_MS; + const n = raw ? parseInt(raw, 10) : NaN; + return Number.isFinite(n) && n > 0 ? n : 10 * 1000; +})(); + +// Event types that count as "the stream has started producing". Everything +// else (start, start-step, reasoning-start, text-start, ...) is metadata and +// does not end the peek. +const MEANINGFUL_EVENT_TYPES = new Set([ + "text-delta", + "reasoning-delta", + "tool-input-start", + "tool-input-delta", + "tool-input-end", + "tool-call", + "finish-step", + "finish", +]); + +function makeAbortError(reason) { + const error = new Error(reason?.message || reason || "Request aborted"); + error.name = "AbortError"; + return error; +} + +function tryParseEvent(line) { + const trimmed = line.trim(); + if (!trimmed) return null; + const json = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed; + if (!json || json === "[DONE]") return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function formatErrorValue(errVal) { + const errStr = + typeof errVal === "string" + ? errVal + : typeof errVal?.message === "string" + ? errVal.message + : JSON.stringify(errVal); + const errType = + typeof errVal === "string" + ? "upstream_error" + : errVal?.type || "upstream_error"; + return { message: errStr, type: errType }; +} + +/** + * Read the first upstream events before committing the response. + * + * - `{"type":"error"}` as the first meaningful event → return a 502 Response so + * chatCore's `!response.ok` path parses the error and triggers fallback. + * - Otherwise → re-emit the buffered bytes + the rest of the stream through the + * normal NDJSON → OpenAI SSE wrapper and return it untouched in spirit. + * + * Bounded by `timeoutMs` (default PEEK_TIMEOUT_MS): if no meaningful event + * arrives in time, or the request signal aborts, we commit whatever we have and + * let the regular stream pipeline (stall detection, abort handling) take over. + */ +export async function peekForUpstreamError( + originalResponse, + model, + { signal = null, timeoutMs = PEEK_TIMEOUT_MS } = {}, +) { + const reader = originalResponse.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const abortController = new AbortController(); + const forwardAbort = () => abortController.abort(signal?.reason); + if (signal?.aborted) abortController.abort(signal?.reason); + else if (signal) + signal.addEventListener("abort", forwardAbort, { once: true }); + + let peeked = ""; + let errorEvent = null; + let committed = false; + + const readWithTimeout = (ms) => { + if (abortController.signal.aborted) { + return Promise.reject(makeAbortError(abortController.signal.reason)); + } + const timeoutPromise = new Promise((_, reject) => { + const t = setTimeout(() => reject(new Error("peek timeout")), ms); + t.unref?.(); + }); + const abortPromise = new Promise((_, reject) => { + abortController.signal.addEventListener( + "abort", + () => reject(makeAbortError(abortController.signal.reason)), + { once: true }, + ); + }); + return Promise.race([reader.read(), timeoutPromise, abortPromise]); + }; + + try { + const deadline = Date.now() + timeoutMs; + while (!errorEvent && !committed && Date.now() < deadline) { + const { done, value } = await readWithTimeout( + Math.max(deadline - Date.now(), 1), + ); + if (done) break; + peeked += decoder.decode(value, { stream: true }); + const lines = peeked.split("\n"); + // The last segment may be a partial line — only parse complete ones. + for (const line of lines.slice(0, -1)) { + const event = tryParseEvent(line); + if (!event?.type) continue; + if (event.type === "error") { + errorEvent = event; + break; + } + if (MEANINGFUL_EVENT_TYPES.has(event.type)) { + committed = true; + break; + } + } + } + } catch { + // timeout / abort / read failure during the peek → commit whatever we have; + // the downstream stream pipeline (stall detection, abort handling) takes over. + } + + // Flush any partial multi-byte UTF-8 sequence held by the decoder so the + // re-encoded peeked bytes round-trip losslessly. + peeked += decoder.decode(); + + if (signal) signal.removeEventListener("abort", forwardAbort); + + if (errorEvent) { + await reader.cancel("commandcode early error detected").catch(() => {}); + const { message, type } = formatErrorValue( + errorEvent.error ?? errorEvent.message ?? "unknown", + ); + return new Response(JSON.stringify({ error: { message, type } }), { + status: HTTP_STATUS.BAD_GATEWAY, + statusText: message.slice(0, 200), + headers: { "Content-Type": "application/json" }, + }); + } + + const remaining = new ReadableStream({ + start(controller) { + (async () => { + try { + if (peeked) controller.enqueue(encoder.encode(peeked)); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + controller.close(); + } catch (err) { + controller.error(err); + } + })(); + }, + cancel() { + reader.cancel("commandcode stream cancelled").catch(() => {}); + }, + }); + + const combined = new Response(remaining, { + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: originalResponse.headers, + }); + return wrapNdjsonAsOpenAISse(combined, model); } function wrapNdjsonAsOpenAISse(originalResponse, model) { - const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let buffer = ""; - const state = { model }; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + const state = { model }; - const emitChunks = (chunks, controller) => { - if (!chunks) return; - const list = Array.isArray(chunks) ? chunks : [chunks]; - for (const c of list) { - if (c == null) continue; - controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`)); - } - }; + const emitChunks = (chunks, controller) => { + if (!chunks) return; + const list = Array.isArray(chunks) ? chunks : [chunks]; + for (const c of list) { + if (c == null) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`)); + } + }; - const transform = new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - // Translate AI SDK v5 NDJSON line to one or more OpenAI chunks - emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); - } - }, - flush(controller) { - const trimmed = buffer.trim(); - if (trimmed) { - emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); - } - controller.enqueue(encoder.encode(SSE_DONE)); - }, - }); + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + // Translate AI SDK v5 NDJSON line to one or more OpenAI chunks + emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); + } + }, + flush(controller) { + const trimmed = buffer.trim(); + if (trimmed) { + emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); + } + controller.enqueue(encoder.encode(SSE_DONE)); + }, + }); - const newBody = originalResponse.body.pipeThrough(transform); - return new Response(newBody, { - status: originalResponse.status, - statusText: originalResponse.statusText, - headers: originalResponse.headers, - }); + const newBody = originalResponse.body.pipeThrough(transform); + return new Response(newBody, { + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: originalResponse.headers, + }); } export default CommandCodeExecutor; diff --git a/open-sse/translator/response/commandcode-to-openai.js b/open-sse/translator/response/commandcode-to-openai.js index ab3d7d7b..1c8c35c7 100644 --- a/open-sse/translator/response/commandcode-to-openai.js +++ b/open-sse/translator/response/commandcode-to-openai.js @@ -25,160 +25,198 @@ import { fallbackToolCallId } from "../concerns/toolCall.js"; import { toOpenAIFinish } from "../concerns/finishReason.js"; function ensureState(state, model) { - if (!state.responseId) { - state.responseId = `chatcmpl-${Date.now()}`; - state.created = Math.floor(Date.now() / 1000); - state.model = state.model || model || "commandcode"; - state.chunkIndex = 0; - state.toolIndex = 0; - state.toolIndexById = new Map(); - state.openTools = new Set(); - state.openText = false; - state.finishReason = null; - state.usage = null; - } + if (!state.responseId) { + state.responseId = `chatcmpl-${Date.now()}`; + state.created = Math.floor(Date.now() / 1000); + state.model = state.model || model || "commandcode"; + state.chunkIndex = 0; + state.toolIndex = 0; + state.toolIndexById = new Map(); + state.openTools = new Set(); + state.openText = false; + state.finishReason = null; + state.usage = null; + } } function makeChunk(state, delta, finishReason = null) { - return buildChunk( - { id: state.responseId, created: state.created, model: state.model }, - delta, - finishReason - ); + return buildChunk( + { id: state.responseId, created: state.created, model: state.model }, + delta, + finishReason, + ); } const mapFinishReason = (reason) => toOpenAIFinish(reason, "commandcode"); export function commandCodeToOpenAIResponse(chunk, state) { - if (!chunk) return null; + if (!chunk) return null; - // Already-OpenAI chunk: pass through - if (chunk && typeof chunk === "object" && chunk.object === "chat.completion.chunk") { - return chunk; - } + // Already-OpenAI chunk: pass through + if ( + chunk && + typeof chunk === "object" && + chunk.object === "chat.completion.chunk" + ) { + return chunk; + } - // Parse string lines coming out of upstream - let event = chunk; - if (typeof chunk === "string") { - const line = chunk.trim(); - if (!line) return null; - // Tolerate raw "data: {...}" framing if the upstream wrapper inserts it - const json = line.startsWith("data:") ? line.slice(5).trim() : line; - if (!json || json === "[DONE]") return null; - try { - event = JSON.parse(json); - } catch { - return null; - } - } + // Parse string lines coming out of upstream + let event = chunk; + if (typeof chunk === "string") { + const line = chunk.trim(); + if (!line) return null; + // Tolerate raw "data: {...}" framing if the upstream wrapper inserts it + const json = line.startsWith("data:") ? line.slice(5).trim() : line; + if (!json || json === "[DONE]") return null; + try { + event = JSON.parse(json); + } catch { + return null; + } + } - if (!event || typeof event !== "object" || !event.type) return null; + if (!event || typeof event !== "object" || !event.type) return null; - ensureState(state, event.model); - const out = []; + ensureState(state, event.model); + const out = []; - switch (event.type) { - case "text-delta": { - const text = event.text || event.delta || ""; - if (!text) break; - const delta = state.chunkIndex === 0 ? { role: ROLE.ASSISTANT, content: text } : { content: text }; - state.chunkIndex++; - state.openText = true; - out.push(makeChunk(state, delta)); - break; - } - case "reasoning-delta": { - const text = event.text || ""; - if (!text) break; - // Map reasoning to OpenAI "reasoning_content" field (used by deepseek-reasoner-style clients). - const delta = reasoningDelta(text, state.chunkIndex === 0); - state.chunkIndex++; - out.push(makeChunk(state, delta)); - break; - } - case "tool-input-start": { - const id = event.id || event.toolCallId || fallbackToolCallId(state.toolIndex); - let idx = state.toolIndexById.get(id); - if (idx == null) { - idx = state.toolIndex++; - state.toolIndexById.set(id, idx); - } - state.openTools.add(id); - const delta = { - ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), - tool_calls: [{ - index: idx, - id, - type: OPENAI_BLOCK.FUNCTION, - function: { name: event.toolName || "", arguments: "" }, - }], - }; - state.chunkIndex++; - out.push(makeChunk(state, delta)); - break; - } - case "tool-input-delta": { - const id = event.id || event.toolCallId; - const idx = state.toolIndexById.get(id); - if (idx == null) break; - const delta = { - tool_calls: [{ - index: idx, - function: { arguments: event.delta || event.inputTextDelta || "" }, - }], - }; - out.push(makeChunk(state, delta)); - break; - } - case "tool-call": { - // Final consolidated tool call — only emit if we never saw tool-input-* deltas. - const id = event.toolCallId; - if (state.toolIndexById.has(id)) break; - const idx = state.toolIndex++; - state.toolIndexById.set(id, idx); - const argsStr = typeof event.input === "string" ? event.input : JSON.stringify(event.input ?? {}); - const delta = { - ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), - tool_calls: [{ - index: idx, - id, - type: OPENAI_BLOCK.FUNCTION, - function: { name: event.toolName || "", arguments: argsStr }, - }], - }; - state.chunkIndex++; - out.push(makeChunk(state, delta)); - break; - } - case "finish-step": { - state.finishReason = mapFinishReason(event.finishReason); - if (event.usage) state.usage = event.usage; - break; - } - case "finish": { - const finishReason = state.finishReason || mapFinishReason(event.finishReason || "stop"); - const finalChunk = makeChunk(state, {}, finishReason); - const totalUsage = event.totalUsage || state.usage; - const usage = toOpenAIUsage(totalUsage, "commandcode"); - if (usage) finalChunk.usage = usage; - out.push(finalChunk); - break; - } - case "error": { - state.finishReason = OPENAI_FINISH.STOP; - const errVal = event.error ?? event.message ?? "unknown"; - const errStr = typeof errVal === "string" ? errVal : JSON.stringify(errVal); - out.push(makeChunk(state, { content: `\n\n[CommandCode error: ${errStr}]` })); - out.push(makeChunk(state, {}, OPENAI_FINISH.STOP)); - break; - } - // Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end, - // provider-metadata, message-metadata, etc. They carry no client-visible content. - default: - break; - } + switch (event.type) { + case "text-delta": { + const text = event.text || event.delta || ""; + if (!text) break; + const delta = + state.chunkIndex === 0 + ? { role: ROLE.ASSISTANT, content: text } + : { content: text }; + state.chunkIndex++; + state.openText = true; + out.push(makeChunk(state, delta)); + break; + } + case "reasoning-delta": { + const text = event.text || ""; + if (!text) break; + // Map reasoning to OpenAI "reasoning_content" field (used by deepseek-reasoner-style clients). + const delta = reasoningDelta(text, state.chunkIndex === 0); + state.chunkIndex++; + out.push(makeChunk(state, delta)); + break; + } + case "tool-input-start": { + const id = + event.id || event.toolCallId || fallbackToolCallId(state.toolIndex); + let idx = state.toolIndexById.get(id); + if (idx == null) { + idx = state.toolIndex++; + state.toolIndexById.set(id, idx); + } + state.openTools.add(id); + const delta = { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + tool_calls: [ + { + index: idx, + id, + type: OPENAI_BLOCK.FUNCTION, + function: { name: event.toolName || "", arguments: "" }, + }, + ], + }; + state.chunkIndex++; + out.push(makeChunk(state, delta)); + break; + } + case "tool-input-delta": { + const id = event.id || event.toolCallId; + const idx = state.toolIndexById.get(id); + if (idx == null) break; + const delta = { + tool_calls: [ + { + index: idx, + function: { arguments: event.delta || event.inputTextDelta || "" }, + }, + ], + }; + out.push(makeChunk(state, delta)); + break; + } + case "tool-call": { + // Final consolidated tool call — only emit if we never saw tool-input-* deltas. + const id = event.toolCallId; + if (state.toolIndexById.has(id)) break; + const idx = state.toolIndex++; + state.toolIndexById.set(id, idx); + const argsStr = + typeof event.input === "string" + ? event.input + : JSON.stringify(event.input ?? {}); + const delta = { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + tool_calls: [ + { + index: idx, + id, + type: OPENAI_BLOCK.FUNCTION, + function: { name: event.toolName || "", arguments: argsStr }, + }, + ], + }; + state.chunkIndex++; + out.push(makeChunk(state, delta)); + break; + } + case "finish-step": { + state.finishReason = mapFinishReason(event.finishReason); + if (event.usage) state.usage = event.usage; + break; + } + case "finish": { + const finishReason = + state.finishReason || mapFinishReason(event.finishReason || "stop"); + const finalChunk = makeChunk(state, {}, finishReason); + const totalUsage = event.totalUsage || state.usage; + const usage = toOpenAIUsage(totalUsage, "commandcode"); + if (usage) finalChunk.usage = usage; + out.push(finalChunk); + break; + } + case "error": { + // Terminal upstream failure (AI SDK v5 error event) — NOT content. Emit an + // OpenAI-shaped error chunk (chunk.error) so downstream — parseSSEToOpenAIResponse + // for non-streaming, OpenAI SDK clients for streaming — treats the request as + // failed instead of surfacing fake success content like "[CommandCode error: ...]". + state.finishReason = OPENAI_FINISH.STOP; + const errVal = event.error ?? event.message ?? "unknown"; + const errStr = + typeof errVal === "string" + ? errVal + : typeof errVal?.message === "string" + ? errVal.message + : JSON.stringify(errVal); + const errType = + typeof errVal === "string" + ? "upstream_error" + : errVal?.type || "upstream_error"; + const errChunk = makeChunk(state, {}); + errChunk.error = { message: errStr, type: errType }; + out.push(errChunk); + out.push(makeChunk(state, {}, OPENAI_FINISH.STOP)); + break; + } + // Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end, + // provider-metadata, message-metadata, etc. They carry no client-visible content. + default: + break; + } - return out.length ? out : null; + return out.length ? out : null; } -register(FORMATS.COMMANDCODE, FORMATS.OPENAI, null, commandCodeToOpenAIResponse); +register( + FORMATS.COMMANDCODE, + FORMATS.OPENAI, + null, + commandCodeToOpenAIResponse, +); diff --git a/tests/unit/commandcode-executor.test.js b/tests/unit/commandcode-executor.test.js new file mode 100644 index 00000000..1ad7a376 --- /dev/null +++ b/tests/unit/commandcode-executor.test.js @@ -0,0 +1,101 @@ +/** + * Unit tests for the CommandCode executor early-error peek. + * + * The upstream emits AI SDK v5 NDJSON over an HTTP 200 stream, so a terminal + * `{"type":"error"}` event is invisible to the normal `response.ok` success + * check. `peekForUpstreamError` reads the first events before committing the + * response: an error event → non-ok Response (fallback can kick in); otherwise + * the buffered bytes are re-emitted and streaming proceeds as before. + */ +import { describe, it, expect } from "vitest"; +import { peekForUpstreamError } from "../../open-sse/executors/commandcode.js"; + +const encoder = new TextEncoder(); + +function ndjsonResponse(lines) { + const body = new ReadableStream({ + start(controller) { + for (const line of lines) controller.enqueue(encoder.encode(line + "\n")); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +describe("commandcode executor — early-error peek", () => { + it("returns 502 when the first meaningful event is an error", async () => { + const res = await peekForUpstreamError( + ndjsonResponse([ + '{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}', + ]), + "model", + ); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error.message).toBe("Network connection lost."); + expect(body.error.type).toBe("server_error"); + }); + + it("detects an error event even when metadata events arrive first", async () => { + const res = await peekForUpstreamError( + ndjsonResponse([ + '{"type":"start"}', + '{"type":"start-step"}', + '{"type":"error","error":{"type":"server_error","message":"Network connection lost."}}', + ]), + "model", + ); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error.message).toContain("Network connection lost"); + }); + + it("commits and streams normally when the first meaningful event is content", async () => { + const res = await peekForUpstreamError( + ndjsonResponse([ + '{"type":"start"}', + '{"type":"text-delta","text":"hi there"}', + '{"type":"finish"}', + ]), + "model", + ); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain('"content":"hi there"'); + expect(text).not.toContain("[CommandCode error:"); + }); + + it("commits when the stream ends without any event", async () => { + const res = await peekForUpstreamError(ndjsonResponse([]), "model"); + expect(res.status).toBe(200); + await res.body.cancel(); + }); + + it("commits (does not hang) when no event arrives before the peek timeout", async () => { + const stalled = new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const res = await peekForUpstreamError(stalled, "model", { timeoutMs: 50 }); + expect(res.status).toBe(200); + await res.body.cancel(); + }); + + it("does not hang when the request signal aborts during the peek", async () => { + const controller = new AbortController(); + const stalled = new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + setTimeout(() => controller.abort(new Error("client gone")), 10); + const res = await peekForUpstreamError(stalled, "model", { + signal: controller.signal, + timeoutMs: 2000, + }); + expect(res.status).toBe(200); + await res.body.cancel(); + }); +}); diff --git a/tests/unit/commandcode-to-openai.test.js b/tests/unit/commandcode-to-openai.test.js index 36c5f75f..108d8074 100644 --- a/tests/unit/commandcode-to-openai.test.js +++ b/tests/unit/commandcode-to-openai.test.js @@ -12,116 +12,152 @@ import { describe, it, expect } from "vitest"; import { commandCodeToOpenAIResponse } from "../../open-sse/translator/response/commandcode-to-openai.js"; function feed(events) { - const state = {}; - const all = []; - for (const e of events) { - const out = commandCodeToOpenAIResponse(JSON.stringify(e), state); - if (out) for (const c of out) all.push(c); - } - return { state, chunks: all }; + const state = {}; + const all = []; + for (const e of events) { + const out = commandCodeToOpenAIResponse(JSON.stringify(e), state); + if (out) for (const c of out) all.push(c); + } + return { state, chunks: all }; } describe("commandcode-to-openai — text-delta", () => { - it("emits assistant role on first delta then content-only", () => { - const { chunks } = feed([ - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: " world" }, - ]); - expect(chunks[0].choices[0].delta.role).toBe("assistant"); - expect(chunks[0].choices[0].delta.content).toBe("Hello"); - expect(chunks[1].choices[0].delta.role).toBeUndefined(); - expect(chunks[1].choices[0].delta.content).toBe(" world"); - }); + it("emits assistant role on first delta then content-only", () => { + const { chunks } = feed([ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: " world" }, + ]); + expect(chunks[0].choices[0].delta.role).toBe("assistant"); + expect(chunks[0].choices[0].delta.content).toBe("Hello"); + expect(chunks[1].choices[0].delta.role).toBeUndefined(); + expect(chunks[1].choices[0].delta.content).toBe(" world"); + }); }); describe("commandcode-to-openai — reasoning-delta", () => { - it("maps reasoning-delta to reasoning_content delta", () => { - const { chunks } = feed([ - { type: "reasoning-delta", text: "thinking..." }, - ]); - expect(chunks[0].choices[0].delta.reasoning_content).toBe("thinking..."); - }); + it("maps reasoning-delta to reasoning_content delta", () => { + const { chunks } = feed([{ type: "reasoning-delta", text: "thinking..." }]); + expect(chunks[0].choices[0].delta.reasoning_content).toBe("thinking..."); + }); }); describe("commandcode-to-openai — tool-input-* with id field (live schema)", () => { - it("registers tool index using event.id (NOT toolCallId)", () => { - const { chunks } = feed([ - { type: "tool-input-start", id: "call_X", toolName: "Bash" }, - { type: "tool-input-delta", id: "call_X", delta: "{\"cmd" }, - { type: "tool-input-delta", id: "call_X", delta: "\":\"ls\"}" }, - ]); + it("registers tool index using event.id (NOT toolCallId)", () => { + const { chunks } = feed([ + { type: "tool-input-start", id: "call_X", toolName: "Bash" }, + { type: "tool-input-delta", id: "call_X", delta: '{"cmd' }, + { type: "tool-input-delta", id: "call_X", delta: '":"ls"}' }, + ]); - // First chunk emits tool_calls with id - const startChunk = chunks[0].choices[0].delta.tool_calls[0]; - expect(startChunk.id).toBe("call_X"); - expect(startChunk.function.name).toBe("Bash"); + // First chunk emits tool_calls with id + const startChunk = chunks[0].choices[0].delta.tool_calls[0]; + expect(startChunk.id).toBe("call_X"); + expect(startChunk.function.name).toBe("Bash"); - // Subsequent deltas accumulate arguments - expect(chunks[1].choices[0].delta.tool_calls[0].function.arguments).toBe("{\"cmd"); - expect(chunks[2].choices[0].delta.tool_calls[0].function.arguments).toBe("\":\"ls\"}"); - }); + // Subsequent deltas accumulate arguments + expect(chunks[1].choices[0].delta.tool_calls[0].function.arguments).toBe( + '{"cmd', + ); + expect(chunks[2].choices[0].delta.tool_calls[0].function.arguments).toBe( + '":"ls"}', + ); + }); - it("ignores tool-input-delta when id is unknown (no prior start)", () => { - const { chunks } = feed([ - { type: "tool-input-delta", id: "unknown", delta: "x" }, - ]); - expect(chunks.length).toBe(0); - }); + it("ignores tool-input-delta when id is unknown (no prior start)", () => { + const { chunks } = feed([ + { type: "tool-input-delta", id: "unknown", delta: "x" }, + ]); + expect(chunks.length).toBe(0); + }); }); describe("commandcode-to-openai — final tool-call event", () => { - it("does NOT re-emit tool_calls when tool-input-* deltas already fired", () => { - const { chunks } = feed([ - { type: "tool-input-start", id: "call_Y", toolName: "Write" }, - { type: "tool-input-delta", id: "call_Y", delta: "{\"file\":\"a\"}" }, - { type: "tool-call", toolCallId: "call_Y", toolName: "Write", input: { file: "a" } }, - ]); - // Should be exactly 2 chunks (start + delta), no duplicate from final tool-call - expect(chunks.length).toBe(2); - }); + it("does NOT re-emit tool_calls when tool-input-* deltas already fired", () => { + const { chunks } = feed([ + { type: "tool-input-start", id: "call_Y", toolName: "Write" }, + { type: "tool-input-delta", id: "call_Y", delta: '{"file":"a"}' }, + { + type: "tool-call", + toolCallId: "call_Y", + toolName: "Write", + input: { file: "a" }, + }, + ]); + // Should be exactly 2 chunks (start + delta), no duplicate from final tool-call + expect(chunks.length).toBe(2); + }); - it("emits a consolidated tool_calls when only the final tool-call event arrives", () => { - const { chunks } = feed([ - { type: "tool-call", toolCallId: "call_Z", toolName: "Read", input: { path: "/x" } }, - ]); - expect(chunks.length).toBe(1); - const tc = chunks[0].choices[0].delta.tool_calls[0]; - expect(tc.id).toBe("call_Z"); - expect(tc.function.name).toBe("Read"); - expect(tc.function.arguments).toBe(JSON.stringify({ path: "/x" })); - }); + it("emits a consolidated tool_calls when only the final tool-call event arrives", () => { + const { chunks } = feed([ + { + type: "tool-call", + toolCallId: "call_Z", + toolName: "Read", + input: { path: "/x" }, + }, + ]); + expect(chunks.length).toBe(1); + const tc = chunks[0].choices[0].delta.tool_calls[0]; + expect(tc.id).toBe("call_Z"); + expect(tc.function.name).toBe("Read"); + expect(tc.function.arguments).toBe(JSON.stringify({ path: "/x" })); + }); }); describe("commandcode-to-openai — finish", () => { - it("emits a final chunk with finish_reason=tool_calls when finishReason is tool-calls", () => { - const { chunks } = feed([ - { type: "tool-input-start", id: "call_F", toolName: "Bash" }, - { type: "tool-input-delta", id: "call_F", delta: "{}" }, - { type: "finish-step", finishReason: "tool-calls" }, - { type: "finish" }, - ]); - const last = chunks[chunks.length - 1]; - expect(last.choices[0].finish_reason).toBe("tool_calls"); - }); + it("emits a final chunk with finish_reason=tool_calls when finishReason is tool-calls", () => { + const { chunks } = feed([ + { type: "tool-input-start", id: "call_F", toolName: "Bash" }, + { type: "tool-input-delta", id: "call_F", delta: "{}" }, + { type: "finish-step", finishReason: "tool-calls" }, + { type: "finish" }, + ]); + const last = chunks[chunks.length - 1]; + expect(last.choices[0].finish_reason).toBe("tool_calls"); + }); - it("includes usage on the final chunk when totalUsage provided", () => { - const { chunks } = feed([ - { type: "text-delta", text: "hi" }, - { type: "finish-step", finishReason: "stop", usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } }, - { type: "finish", totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } }, - ]); - const last = chunks[chunks.length - 1]; - expect(last.usage).toEqual({ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }); - }); + it("includes usage on the final chunk when totalUsage provided", () => { + const { chunks } = feed([ + { type: "text-delta", text: "hi" }, + { + type: "finish-step", + finishReason: "stop", + usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + }, + { + type: "finish", + totalUsage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, + }, + ]); + const last = chunks[chunks.length - 1]; + expect(last.usage).toEqual({ + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }); + }); }); describe("commandcode-to-openai — error event", () => { - it("stringifies object errors so client sees readable message", () => { - const { chunks } = feed([ - { type: "error", error: { type: "server_error", message: "Boom" } }, - ]); - const text = chunks[0].choices[0].delta.content; - expect(text).toContain("Boom"); - expect(text).not.toContain("[object Object]"); - }); + it("emits an OpenAI-shaped error chunk instead of fake success content", () => { + const { chunks } = feed([ + { type: "error", error: { type: "server_error", message: "Boom" } }, + ]); + expect(chunks[0].error).toEqual({ message: "Boom", type: "server_error" }); + expect(chunks[0].choices[0].delta.content).toBeUndefined(); + expect(chunks[1].choices[0].finish_reason).toBe("stop"); + expect(JSON.stringify(chunks)).not.toContain("[CommandCode error:"); + }); + + it("keeps the stream terminal so clients do not hang waiting for more", () => { + const { chunks } = feed([ + { type: "start" }, + { + type: "error", + error: { type: "server_error", message: "Network connection lost." }, + }, + ]); + expect(chunks[0].error.message).toBe("Network connection lost."); + expect(chunks[1].choices[0].finish_reason).toBe("stop"); + }); });