From c51a7905301cf1ce5b10b7dac09718631f2d09f4 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 25 Aug 2026 15:54:18 +0200 Subject: [PATCH] fix(stream): match Command Code CLI transport behavior --- index.ts | 7 +- src/auth-server.ts | 7 ++ src/converters.ts | 53 +++++++++--- src/core.ts | 71 ++++++++++++---- src/oauth.ts | 43 +++++++--- src/types.ts | 2 + tests/test-oauth.ts | 158 ++++++++++++++++++++++++++--------- tests/test-pure-functions.ts | 49 ++++++++++- tests/test-stream.ts | 121 ++++++++++++++++++++++++++- 9 files changed, 426 insertions(+), 85 deletions(-) diff --git a/index.ts b/index.ts index d3005d4..09222bd 100644 --- a/index.ts +++ b/index.ts @@ -26,6 +26,7 @@ import { getModelsTimeoutMs, inputModalitiesForModel, loadCommandCodeModels, + MODEL_EFFORTS, thinkingMetadataForModel, type CommandCodeModel, } from "./src/models.ts" @@ -37,7 +38,7 @@ import { createCommandCodeRuntime } from "./src/runtime.ts" import { createCommandCodeTransportRouter } from "./src/transport.ts" function commandCodeHeaders(): Record | undefined { - if (process.env.COMMANDCODE_ZDR === "1") { + if (process.env.CMD_ZDR === "1" || process.env.COMMANDCODE_ZDR === "1") { return { "x-cmd-zdr": "1" } } return undefined @@ -52,7 +53,7 @@ function createProviderConfig( return { name: "Command Code", baseUrl: apiBase, - apiKey: getConfiguredApiKey() ?? "$COMMANDCODE_API_KEY", + apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY", api: "commandcode-custom", streamSimple: streamCommandCode, headers, @@ -79,7 +80,7 @@ function createProviderConfig( ? { supportsStore: false, supportsDeveloperRole: false, - supportsReasoningEffort: true, + supportsReasoningEffort: MODEL_EFFORTS[model.id] !== undefined, maxTokensField: "max_tokens", } : { diff --git a/src/auth-server.ts b/src/auth-server.ts index e815c10..ee9e6de 100644 --- a/src/auth-server.ts +++ b/src/auth-server.ts @@ -28,6 +28,7 @@ export interface AuthServer { export interface AuthServerOptions { startPort?: number portRange?: number + expectedState?: string } function listenOnAvailablePort( @@ -181,6 +182,12 @@ export async function startAuthServer(options: AuthServerOptions = {}): Promise< return } + if (options.expectedState !== undefined && state !== options.expectedState) { + res.writeHead(403) + res.end(JSON.stringify({ success: false, error: "Invalid state token" })) + return + } + res.writeHead(200) res.end(JSON.stringify({ success: true })) diff --git a/src/converters.ts b/src/converters.ts index bb60df2..1e7ea97 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -107,6 +107,7 @@ export function getApiKey( } = {}, ): string | undefined { const env = options.env ?? process.env + if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY const home = options.homeDir?.() ?? homedir() @@ -138,10 +139,11 @@ export function getApiKey( return undefined } -// Hosts such as OMP may pass the literal env-var name "$COMMANDCODE_API_KEY" -// (or "COMMANDCODE_API_KEY") as the "resolved" registry key instead of the -// actual credential. Treat those as unresolved. +// Hosts such as OMP may pass a literal env-var name as the "resolved" registry +// key instead of the actual credential. Treat those as unresolved. export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([ + "$COMMAND_CODE_API_KEY", + "COMMAND_CODE_API_KEY", "$COMMANDCODE_API_KEY", "COMMANDCODE_API_KEY", ]) @@ -162,6 +164,16 @@ export function pickCommandCodeApiKey( } export function textContent(message: { content?: unknown }): string { + if (typeof message.content === "string") return message.content + if (message.content === null || message.content === undefined) return "" + if (!Array.isArray(message.content)) { + try { + return JSON.stringify(message.content) ?? String(message.content) + } catch { + return String(message.content) + } + } + return recordArray(message.content) .filter((part) => part.type === "text") .map((part) => stringValue(part.text) ?? "") @@ -182,7 +194,12 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { })) } -function completeToolCallIds(messages?: readonly MessageLike[]): Set { +interface ToolCallState { + callIds: ReadonlySet + resultIds: ReadonlySet +} + +function toolCallState(messages?: readonly MessageLike[]): ToolCallState { const callIds = new Set() const resultIds = new Set() @@ -194,12 +211,12 @@ function completeToolCallIds(messages?: readonly MessageLike[]): Set { if (id) callIds.add(id) } } - } else if (message.role === "toolResult") { - if (message.toolCallId) resultIds.add(message.toolCallId) + } else if (message.role === "toolResult" && message.toolCallId) { + resultIds.add(message.toolCallId) } } - return new Set([...callIds].filter((id) => resultIds.has(id))) + return { callIds, resultIds } } export function messagesToCC( @@ -210,7 +227,7 @@ export function messagesToCC( if (!allowImages) assertTextOnlyMessages(messages) const out: unknown[] = [] - const pairedToolCallIds = completeToolCallIds(messages) + const { callIds, resultIds } = toolCallState(messages) for (const message of messages ?? []) { if (message.role === "user") { @@ -220,23 +237,37 @@ export function messagesToCC( }) } else if (message.role === "assistant") { const parts: unknown[] = [] + const missingResults: unknown[] = [] for (const content of recordArray(message.content)) { if (content.type === "text") { parts.push({ type: "text", text: stringValue(content.text) ?? "" }) } else if (content.type === "toolCall") { const toolCallId = stringValue(content.id) ?? "" - if (!pairedToolCallIds.has(toolCallId)) continue + const toolName = stringValue(content.name) ?? "" + if (!toolCallId) continue parts.push({ type: "tool-call", toolCallId, - toolName: stringValue(content.name) ?? "", + toolName, input: recordOrEmpty(content.arguments), }) + if (!resultIds.has(toolCallId)) { + missingResults.push({ + type: "tool-result", + toolCallId, + toolName, + output: { + type: "error-text", + value: "No result — the tool call did not complete (interrupted or lost).", + }, + }) + } } } 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 || !pairedToolCallIds.has(message.toolCallId)) continue + if (!message.toolCallId || !callIds.has(message.toolCallId)) continue out.push({ role: "tool", content: [ diff --git a/src/core.ts b/src/core.ts index 57ed455..c88cef4 100644 --- a/src/core.ts +++ b/src/core.ts @@ -148,6 +148,10 @@ function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): strin return typeof mapped === "string" && mapped !== "off" ? mapped : undefined } +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) +} + export function projectSlugFromPath(pathName: string): string { const slug = pathName .toLowerCase() @@ -232,17 +236,15 @@ export function createStreamCommandCode(deps: CoreDependencies) { const stream = deps.createStream() async function run() { - // OMP may pass the legacy env-var name "COMMANDCODE_API_KEY" (old pi) - // or "$COMMANDCODE_API_KEY" (new pi) as the apiKey value instead of - // resolving it. Filter out these specific strings. - const LEGACY_API_KEY_REF = "$COMMANDCODE_API_KEY" - const OLD_API_KEY_REF = "COMMANDCODE_API_KEY" + // Some hosts pass a literal env-var reference instead of resolving it. + const PLACEHOLDER_API_KEYS = new Set([ + "$COMMAND_CODE_API_KEY", + "COMMAND_CODE_API_KEY", + "$COMMANDCODE_API_KEY", + "COMMANDCODE_API_KEY", + ]) const hostKey = - options?.apiKey && - options.apiKey !== LEGACY_API_KEY_REF && - options.apiKey !== OLD_API_KEY_REF - ? options.apiKey - : undefined + options?.apiKey && !PLACEHOLDER_API_KEYS.has(options.apiKey) ? options.apiKey : undefined const apiKey = hostKey ?? @@ -262,7 +264,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { usage: defaultUsage(), stopReason: "error", errorMessage: - "No Command Code API key. Run /login and select Command Code, set the COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json", + "No Command Code API key. Run /login and select Command Code, set COMMAND_CODE_API_KEY (or legacy COMMANDCODE_API_KEY), or configure ~/.commandcode/auth.json, ~/.pi/agent/auth.json or ~/.omp/agent/auth.json", timestamp: now(), } stream.push({ type: "error", reason: "error", error: msg }) @@ -424,6 +426,15 @@ export function createStreamCommandCode(deps: CoreDependencies) { } case "finish": { + const rawFinishReason = stringValue(event.rawFinishReason) + if ( + rawFinishReason && + /^(?:network|connection|upstream)[-_\s]?error$/i.test(rawFinishReason) + ) { + throw new Error( + `Provider finished with reason "${rawFinishReason}" — upstream connection failed mid-stream`, + ) + } const usage = commandCodeUsage(event) if (usage) { const details = commandCodeInputTokenDetails(usage) @@ -447,6 +458,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { break } + case "abort": { + throw abortError("Request aborted") + } + case "error": { const message = commandCodeErrorMessage(event.error) ?? @@ -464,7 +479,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { if (controller.signal.aborted) throw abortError("Aborted") const workingDir = cwd() - const threadId = uuid() + const threadId = options?.sessionId + ? isUuid(options.sessionId) + ? options.sessionId + : undefined + : uuid() const reasoningEffort = mappedReasoningEffort(model, options) const timeoutMs = options?.timeoutMs @@ -492,8 +511,8 @@ export function createStreamCommandCode(deps: CoreDependencies) { tools: toolsToJson(context.tools), system: systemPromptToText(context.systemPrompt), max_tokens: generateMaxTokens(model, options), - temperature: 0.3, stream: true, + ...(options?.temperature !== undefined ? { temperature: options.temperature } : {}), ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), }, threadId, @@ -523,7 +542,8 @@ export function createStreamCommandCode(deps: CoreDependencies) { "x-cli-environment": "production", "x-project-slug": projectSlugFromPath(workingDir), "x-taste-learning": "true", - "x-co-flag": "false", + ...(options?.sessionId ? { "x-session-id": options.sessionId } : {}), + "User-Agent": "cli", ...options?.headers, } const bodyStr = JSON.stringify(body) @@ -636,6 +656,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { const { done, value } = await raceAbort(reader.read(), attemptController.signal) if (done) { if (buffer.trim()) handleEvent(parseStreamEventLine(buffer)) + if (!finished) { + throw new Error( + "Stream ended unexpectedly before completion (no finish event) — response was truncated", + ) + } break } if (controller.signal.aborted) throw abortError("Aborted") @@ -659,7 +684,12 @@ export function createStreamCommandCode(deps: CoreDependencies) { } catch {} reader = undefined - if (controller.signal.aborted) throw streamError + if ( + controller.signal.aborted || + (streamError instanceof Error && streamError.name === "AbortError") + ) { + throw streamError + } // Never retry after visible content was emitted (including timeout mid-stream). const canRetry = output.content.length === 0 && attempt < maxRetries @@ -679,6 +709,12 @@ export function createStreamCommandCode(deps: CoreDependencies) { throw streamError } + if (!finished) { + throw new Error( + "Stream ended unexpectedly before completion (no finish event) — response was truncated", + ) + } + // Stream completed successfully. endTextBlock() endThinking() @@ -696,7 +732,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { } } } catch (error: unknown) { - const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error" + const reason: ErrorReason = + controller.signal.aborted || (error instanceof Error && error.name === "AbortError") + ? "aborted" + : "error" output.stopReason = reason output.errorMessage = reason === "aborted" diff --git a/src/oauth.ts b/src/oauth.ts index 0a68ac5..470ebd3 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -18,7 +18,8 @@ import { startAuthServer } from "./auth-server.ts" const STUDIO_BASE_URL = "https://commandcode.ai" const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire -const DEFAULT_AUTH_TIMEOUT_MS = 15_000 +const DEFAULT_AUTH_TIMEOUT_MS = 120_000 +const DEFAULT_API_BASE = "https://api.commandcode.ai" export interface OAuthLoginCallbacks { onAuth(params: { url: string }): void @@ -95,9 +96,34 @@ export function sanitizeApiKey(input: string): string { .trim() } +export async function validateApiKey( + apiKey: string, + options: { fetchImpl?: typeof fetch; apiBase?: string } = {}, +): Promise { + let response: Response + try { + response = await (options.fetchImpl ?? fetch)( + `${options.apiBase ?? DEFAULT_API_BASE}/alpha/whoami`, + { + headers: { Authorization: `Bearer ${apiKey}` }, + }, + ) + } catch (error) { + throw new Error( + `Could not validate the Command Code API key: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + if (response.status === 401) throw new Error("Invalid Command Code API key") + if (!response.ok) { + throw new Error(`Could not validate the Command Code API key (${response.status})`) + } +} + async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) { const apiKey = sanitizeApiKey(await callbacks.onPrompt({ message })) if (!apiKey) throw new Error("No Command Code API key provided") + await validateApiKey(apiKey) return credentialsFromApiKey(apiKey) } @@ -130,9 +156,10 @@ async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + const stateToken = generateStateToken() let authServer try { - authServer = await startAuthServer() + authServer = await startAuthServer({ expectedState: stateToken }) } catch { return promptForApiKey( callbacks, @@ -140,7 +167,6 @@ async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { const choice = await chooseLoginFlow(callbacks) - if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey) + if (choice.type === "apiKey") { + await validateApiKey(choice.apiKey) + return credentialsFromApiKey(choice.apiKey) + } if (choice.type === "prompt") { return promptForApiKey(callbacks, "Paste your Command Code API key:") } diff --git a/src/types.ts b/src/types.ts index dc680ea..50066ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,6 +112,8 @@ export interface StreamOptions { headers?: Record fetch?: typeof fetch maxTokens?: number + temperature?: number + sessionId?: string /** Resolved pi thinking level; forwarded only through the model's map. */ reasoning?: string onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 00d94a0..6a64381 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -9,7 +9,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { startAuthServer, type AuthCallback } from "../src/auth-server.ts" -import { getApiKey, login, refreshToken, sanitizeApiKey } from "../src/oauth.ts" +import { getApiKey, login, refreshToken, sanitizeApiKey, validateApiKey } from "../src/oauth.ts" /** * Helper: wait for an HTTP server to close, or resolve immediately if already closed. @@ -24,9 +24,27 @@ function waitForClose(server: { }) } +async function withValidApiKeyFetch(run: () => Promise): Promise { + const originalFetch = globalThis.fetch + globalThis.fetch = (input, init) => { + if (String(input).endsWith("/alpha/whoami")) { + return Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 })) + } + return originalFetch(input, init) + } + try { + return await run() + } finally { + globalThis.fetch = originalFetch + } +} + describe("startAuthServer()", () => { it("starts on a localhost port and accepts a valid callback POST", async () => { - const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 }) + const { server, port, waitForCallback } = await startAuthServer({ + startPort: 0, + expectedState: "test-state-token", + }) const callbackData: AuthCallback = { apiKey: "user_testKey123", @@ -57,6 +75,42 @@ describe("startAuthServer()", () => { await waitForClose(server) }) + it("rejects a mismatched state without closing the callback server", async () => { + const { server, port, waitForCallback } = await startAuthServer({ + startPort: 0, + expectedState: "correct-state", + }) + + const invalidResponse = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ + apiKey: "user_badState", + state: "wrong-state", + userId: "user_789", + userName: "Attacker", + keyName: "evil-key", + }), + }) + assert.equal(invalidResponse.status, 403) + assert.equal(server.listening, true) + + const validResponse = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ + apiKey: "user_valid", + state: "correct-state", + userId: "user_123", + userName: "Valid User", + keyName: "valid-key", + }), + }) + assert.equal(validResponse.status, 200) + assert.equal((await waitForCallback).apiKey, "user_valid") + await waitForClose(server) + }) + it("rejects when the callback indicates access_denied", async () => { const { server, port, waitForCallback } = await startAuthServer({ startPort: 0 }) @@ -176,6 +230,18 @@ describe("OAuth functions", () => { it("sanitizeApiKey removes paste markers, control chars, and whitespace", () => { assert.equal(sanitizeApiKey("\u001b[200~ user_manualKey\n\u001b[201~"), "user_manualKey") }) + + it("validates manual API keys through whoami", async () => { + await validateApiKey("valid-key", { + fetchImpl: () => Promise.resolve(new Response(JSON.stringify({ user: {} }), { status: 200 })), + }) + await assert.rejects( + validateApiKey("invalid-key", { + fetchImpl: () => Promise.resolve(new Response("unauthorized", { status: 401 })), + }), + /Invalid Command Code API key/, + ) + }) }) describe("login()", () => { @@ -242,15 +308,17 @@ describe("login()", () => { const promptMessages: string[] = [] try { - const result = await login({ - onAuth(params: { url: string }) { - authUrl = params.url - }, - async onPrompt(params: { message: string }): Promise { - promptMessages.push(params.message) - return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~" - }, - }) + const result = await withValidApiKeyFetch(() => + login({ + onAuth(params: { url: string }) { + authUrl = params.url + }, + async onPrompt(params: { message: string }): Promise { + promptMessages.push(params.message) + return promptMessages.length === 1 ? "" : "\u001b[200~ user_manualApiKey\n\u001b[201~" + }, + }), + ) assert.match(authUrl, /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?/) assert.match(promptMessages[1] ?? "", /Paste your Command Code API key/) @@ -265,14 +333,16 @@ describe("login()", () => { it("accepts a directly pasted API key", async () => { let authOpened = false - const result = await login({ - onAuth() { - authOpened = true - }, - onPrompt(): Promise { - return Promise.resolve("user_directApiKey") - }, - }) + const result = await withValidApiKeyFetch(() => + login({ + onAuth() { + authOpened = true + }, + onPrompt(): Promise { + return Promise.resolve("user_directApiKey") + }, + }), + ) assert.equal(authOpened, false) assert.equal(result.access, "user_directApiKey") @@ -280,21 +350,23 @@ describe("login()", () => { it("offers an explicit API key prompt", async () => { let promptCount = 0 - const result = await login({ - onAuth() { - throw new Error("browser should not open") - }, - onPrompt(): Promise { - promptCount += 1 - return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey") - }, - }) + const result = await withValidApiKeyFetch(() => + login({ + onAuth() { + throw new Error("browser should not open") + }, + onPrompt(): Promise { + promptCount += 1 + return Promise.resolve(promptCount === 1 ? "key" : "user_promptedApiKey") + }, + }), + ) assert.equal(result.access, "user_promptedApiKey") assert.equal(promptCount, 2) }) - it("rejects on state token mismatch", async () => { + it("keeps waiting after a state mismatch and accepts the legitimate callback", async () => { let authUrl = "" const callbacks = { onAuth(params: { url: string }) { @@ -305,12 +377,7 @@ describe("login()", () => { }, } - const loginPromise: Promise = login(callbacks).then( - () => { - throw new Error("Expected login to reject") - }, - (e: Error) => e.message, - ) + const loginPromise = login(callbacks) // Wait for onAuth to be called asynchronously while (!authUrl) await new Promise((resolve) => setTimeout(resolve, 10)) @@ -318,8 +385,8 @@ describe("login()", () => { const url = new URL(authUrl) const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0") - // Post back with a wrong state token - await fetch(`http://127.0.0.1:${port}/callback`, { + // Post back with a wrong state token. + const invalidResponse = await fetch(`http://127.0.0.1:${port}/callback`, { method: "POST", headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, body: JSON.stringify({ @@ -331,7 +398,20 @@ describe("login()", () => { }), }) - const errorMsg = await loginPromise - assert.match(errorMsg, /State token mismatch/) + assert.equal(invalidResponse.status, 403) + + const validResponse = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://commandcode.ai" }, + body: JSON.stringify({ + apiKey: "user_goodState", + state: url.searchParams.get("state"), + userId: "user_123", + userName: "Real User", + keyName: "real-key", + }), + }) + assert.equal(validResponse.status, 200) + assert.equal((await loginPromise).access, "user_goodState") }) }) diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index d71dba0..17a4c78 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -27,8 +27,18 @@ import { redactCommandCodeErrorText } from "../src/overflow.ts" import { objectAt } from "./helpers.ts" describe("getApiKey()", () => { - it("uses COMMANDCODE_API_KEY from provided env", () => { - assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key") + it("uses the official API key env var before the legacy alias", () => { + assert.equal( + getApiKey({ + env: { COMMAND_CODE_API_KEY: "official-key", COMMANDCODE_API_KEY: "legacy-key" }, + authPaths: [], + }), + "official-key", + ) + assert.equal( + getApiKey({ env: { COMMANDCODE_API_KEY: "legacy-key" }, authPaths: [] }), + "legacy-key", + ) }) it("reads apiKey, commandcode, pi OAuth, and official CLI credential fields", () => { @@ -109,11 +119,14 @@ describe("error redaction", () => { describe("pickCommandCodeApiKey()", () => { it("falls back to the host key for a placeholder registry value", () => { + assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", "file-key"), "file-key") + assert.equal(pickCommandCodeApiKey("COMMAND_CODE_API_KEY", "file-key"), "file-key") assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", "file-key"), "file-key") assert.equal(pickCommandCodeApiKey("COMMANDCODE_API_KEY", "file-key"), "file-key") }) it("returns undefined when only a placeholder is provided (no fallback)", () => { + assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", undefined), undefined) assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", undefined), undefined) }) @@ -199,6 +212,12 @@ describe("textContent()", () => { ) }) + it("normalizes malformed string and object content", () => { + assert.equal(textContent({ content: "raw result" }), "raw result") + assert.equal(textContent({ content: { ok: true } }), '{"ok":true}') + assert.equal(textContent({ content: null }), "") + }) + it("handles empty or missing content", () => { assert.equal(textContent({ content: [] }), "") assert.equal(textContent({}), "") @@ -531,6 +550,23 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") }) + it("preserves malformed string tool results instead of sending empty output", () => { + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: "raw result", + }, + ]) + + assert.equal(objectAt(result, ["1", "content", "0", "output", "value"]), "raw result") + }) + it("serializes image inputs in the current Command Code wire format", () => { assert.deepEqual( messagesToCC( @@ -632,7 +668,7 @@ describe("messagesToCC()", () => { ]) }) - it("drops orphaned tool calls that have no matching tool result", () => { + it("synthesizes missing results for orphaned tool calls", () => { const result = messagesToCC([ { role: "user", content: "edit a file" }, { @@ -651,7 +687,12 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["1", "role"]), "assistant") assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text") - assert.equal(objectAt(result, ["1", "content", "1"]), undefined) + assert.equal(objectAt(result, ["1", "content", "1", "type"]), "tool-call") + assert.equal(objectAt(result, ["2", "role"]), "tool") + assert.match( + String(objectAt(result, ["2", "content", "0", "output", "value"])), + /did not complete/, + ) }) it("handles empty conversations", () => { diff --git a/tests/test-stream.ts b/tests/test-stream.ts index a015e75..fdca1c4 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -77,6 +77,23 @@ describe("streamCommandCode — auth", () => { ) }) + it("accepts the official CLI API key environment variable", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + env: { COMMAND_CODE_API_KEY: "official-env-key" }, + }) + + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "$COMMAND_CODE_API_KEY" }), + ) + + assert.equal(server.lastRequestHeaders().authorization, "Bearer official-env-key") + }) + it("uses options.apiKey in the Authorization header", async () => { server.mockResponse({ type: "success", @@ -555,7 +572,7 @@ describe("streamCommandCode — request serialization", () => { assert.equal(objectAt(body, ["params", "stream"]), true) assert.equal(objectAt(body, ["params", "max_tokens"]), 500) assert.equal(objectAt(body, ["params", "reasoning_effort"]), undefined) - assert.equal(objectAt(body, ["params", "temperature"]), 0.3) + assert.equal(objectAt(body, ["params", "temperature"]), undefined) assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.") assert.equal(objectAt(body, ["memory"]), null) assert.equal(objectAt(body, ["taste"]), null) @@ -573,10 +590,53 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers["x-command-code-version"], COMMAND_CODE_CLI_VERSION) assert.equal(headers["x-project-slug"], "repo") assert.equal(headers["x-taste-learning"], "true") - assert.equal(headers["x-co-flag"], "false") + assert.equal(headers["user-agent"], "cli") + assert.equal(headers["x-co-flag"], undefined) assert.equal(headers["x-session-id"], undefined) }) + it("forwards explicit temperature and stable session metadata", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + temperature: 0.7, + sessionId: "11111111-1111-4111-8111-111111111111", + }), + ) + + const body = server.lastRequestBody() + assert.equal(objectAt(body, ["params", "temperature"]), 0.7) + assert.equal(objectAt(body, ["threadId"]), "11111111-1111-4111-8111-111111111111") + assert.equal( + server.lastRequestHeaders()["x-session-id"], + "11111111-1111-4111-8111-111111111111", + ) + }) + + it("omits non-UUID session ids from the generate thread id", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + sessionId: "human-readable-session", + }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["threadId"]), undefined) + assert.equal(server.lastRequestHeaders()["x-session-id"], "human-readable-session") + }) + it("accepts the legacy OMP nested reasoning map", async () => { server.mockResponse({ type: "success", @@ -819,6 +879,63 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { assert.equal(error.error.errorMessage, "provider failed") }) + it("rejects a truncated stream without a finish event", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "text-delta", text: "truncated" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /no finish event/i) + }) + + it("maps an upstream abort event to an aborted request", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "abort" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.equal(error.reason, "aborted") + }) + + it("rejects terminal upstream network failure reasons", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "finish", + finishReason: "stop", + rawFinishReason: "upstream_error", + }), + ], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ) + + const error = events.at(-1) + assert.equal(error?.type, "error") + if (error?.type !== "error") throw new Error("expected error") + assert.match(error.error.errorMessage ?? "", /upstream connection failed/i) + }) + it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => { const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n` const finishEvent = JSON.stringify({