diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..668411f --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,270 @@ +import { createServer, type IncomingHttpHeaders, type Server } from "node:http"; + +import { + createStreamCommandCode, + type AssistantMessageEvent, + type AssistantMessageEventStreamLike, + type ContextLike, + type CoreDependencies, + type ModelLike, + type Usage, +} from "../src/core.ts"; + +export function createTestEventStream(): AssistantMessageEventStreamLike { + const events: AssistantMessageEvent[] = []; + const waiters: Array<() => void> = []; + let ended = false; + + const wake = () => { + const waiter = waiters.shift(); + if (waiter) waiter(); + }; + + return { + push(event: AssistantMessageEvent) { + events.push(event); + wake(); + }, + end() { + ended = true; + while (waiters.length > 0) wake(); + }, + [Symbol.asyncIterator]() { + let index = 0; + return { + async next(): Promise> { + while (index >= events.length && !ended) { + await new Promise((resolve) => waiters.push(resolve)); + } + if (index < events.length) { + const value = events[index]; + index += 1; + return { done: false, value }; + } + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +export async function collectEvents( + stream: AssistantMessageEventStreamLike, + timeoutMs = 2_000, +): Promise { + const events: AssistantMessageEvent[] = []; + + const collect = async () => { + for await (const event of stream) { + events.push(event); + if (event.type === "done" || event.type === "error") break; + } + return events; + }; + + return await Promise.race([ + collect(), + new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), timeoutMs); + }), + ]); +} + +export function makeModel(overrides: Partial = {}): ModelLike { + return { + id: "deepseek/deepseek-v4-flash", + api: "commandcode-custom", + provider: "commandcode", + maxTokens: 384_000, + ...overrides, + }; +} + +export function makeContext(overrides: Partial = {}): ContextLike { + return { + systemPrompt: "You are a test assistant.", + messages: [{ role: "user", content: "hello" }], + tools: [], + ...overrides, + }; +} + +export interface TestDepsResult { + streamCommandCode: ReturnType; + calculatedUsages: Usage[]; +} + +export function createTestDeps(overrides: Partial = {}): TestDepsResult { + const calculatedUsages: Usage[] = []; + const streamCommandCode = createStreamCommandCode({ + createStream: createTestEventStream, + calculateCost: (_model, usage) => { + calculatedUsages.push({ + ...usage, + cost: { ...usage.cost }, + }); + }, + env: {}, + authPaths: [], + now: () => new Date("2026-05-05T12:00:00Z").getTime(), + uuid: () => "00000000-0000-4000-8000-000000000000", + cwd: () => "/repo", + ...overrides, + }); + return { streamCommandCode, calculatedUsages }; +} + +type SuccessPlan = { + type: "success"; + status?: number; + events?: string[]; + chunks?: string[]; + delays?: number[]; + hangAfterLast?: boolean; +}; + +type ErrorPlan = { + type: "error"; + status: number; + body: string; +}; + +export type ResponsePlan = SuccessPlan | ErrorPlan; + +function headersToRecord(headers: IncomingHttpHeaders): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") out[key] = value; + else if (Array.isArray(value)) out[key] = value.join(", "); + } + return out; +} + +export interface MockCommandCodeServer { + baseUrl(): string; + mockResponse(plan: ResponsePlan): void; + reset(): void; + close(): Promise; + lastRequestBody(): unknown; + lastRequestHeaders(): Record; + requestCount(): number; + responseClosedBeforeEnd(): boolean; +} + +export async function startMockCommandCodeServer(): Promise { + let nextPlan: ResponsePlan = { type: "success", events: [] }; + let lastBody: unknown; + let lastHeaders: Record = {}; + let requests = 0; + let closedBeforeEnd = false; + let port = 0; + + const server: Server = createServer((req, res) => { + if (req.method !== "POST" || req.url !== "/alpha/generate") { + res.writeHead(404); + res.end("Not found"); + return; + } + + requests += 1; + lastHeaders = headersToRecord(req.headers); + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString("utf-8"); + }); + req.on("end", () => { + try { + const parsed: unknown = JSON.parse(body); + lastBody = parsed; + } catch { + lastBody = undefined; + } + + const plan = nextPlan; + if (plan.type === "error") { + res.writeHead(plan.status, { "Content-Type": "text/plain" }); + res.end(plan.body); + return; + } + + res.writeHead(plan.status ?? 200, { + "Content-Type": "text/plain; charset=utf-8", + "Transfer-Encoding": "chunked", + }); + + let ended = false; + res.on("close", () => { + if (!ended) closedBeforeEnd = true; + }); + + const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`); + const delays = plan.delays ?? chunks.map(() => 0); + let index = 0; + + const sendNext = () => { + if (index >= chunks.length) { + if (!plan.hangAfterLast) { + ended = true; + res.end(); + } + return; + } + + res.write(chunks[index]); + index += 1; + if (index < chunks.length) { + setTimeout(sendNext, delays[index] ?? 0); + } else if (!plan.hangAfterLast) { + ended = true; + res.end(); + } + }; + + sendNext(); + }); + }); + + await new Promise((resolve) => { + server.listen(0, () => { + const address = server.address(); + if (typeof address === "object" && address) port = address.port; + resolve(); + }); + }); + + return { + baseUrl: () => `http://127.0.0.1:${port}`, + mockResponse(plan: ResponsePlan) { + nextPlan = plan; + }, + reset() { + nextPlan = { type: "success", events: [] }; + lastBody = undefined; + lastHeaders = {}; + requests = 0; + closedBeforeEnd = false; + }, + close() { + return new Promise((resolve) => server.close(() => resolve())); + }, + lastRequestBody: () => lastBody, + lastRequestHeaders: () => lastHeaders, + requestCount: () => requests, + responseClosedBeforeEnd: () => closedBeforeEnd, + }; +} + +export function objectAt(value: unknown, path: readonly string[]): unknown { + let current = value; + for (const key of path) { + if (Array.isArray(current)) { + const index = Number(key); + if (!Number.isInteger(index)) return undefined; + current = current[index]; + continue; + } + if (typeof current !== "object" || current === null) return undefined; + current = Object.getOwnPropertyDescriptor(current, key)?.value; + } + return current; +} diff --git a/tests/test-abort.ts b/tests/test-abort.ts index 64589da..f957121 100644 --- a/tests/test-abort.ts +++ b/tests/test-abort.ts @@ -1,302 +1,77 @@ /** - * Integration test for abort behaviour in streamCommandCode. - * - * Uses a local HTTP mock server that simulates Command Code's SSE streaming. - * Tests that aborting the stream during an active response correctly emits - * an "aborted" error event. - * - * Run with: npx tsx tests/test-abort.ts + * Abort tests against the real streamCommandCode core. */ import assert from "node:assert/strict"; -import { after, before, describe, it } from "node:test"; -import { createServer, type Server } from "node:http"; +import { after, before, beforeEach, describe, it } from "node:test"; -// --------------------------------------------------------------------------- -// Import streamCommandCode from the actual index.ts -// We import it via dynamic import to ensure we get the real compiled code -// (tsx handles TypeScript transparently) -// --------------------------------------------------------------------------- +import { + collectEvents, + createTestDeps, + makeContext, + makeModel, + startMockCommandCodeServer, + type MockCommandCodeServer, +} from "./helpers.ts"; -// We import directly from pi's bundled pi-ai module -const PI_AI_PATH = - "/nix/store/rlhiqjvq3xhs82481s198c6bpnsksbjd-pi-coding-agent-0.72.0/lib/node_modules/@mariozechner/pi-coding-agent/node_modules/@mariozechner/pi-ai/dist/index.js"; - -type Model = { - id: string; - name: string; - api: T; - provider: string; - baseUrl: string; - reasoning: boolean; - input: ("text" | "image")[]; - cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; - contextWindow: number; - maxTokens: number; -}; - -// --------------------------------------------------------------------------- -// Mock server: responds with slow SSE text-delta events -// --------------------------------------------------------------------------- - -let server: Server; -let port: number; +let server: MockCommandCodeServer; before(async () => { - return new Promise((resolve) => { - server = createServer((req, res) => { - if (req.method === "POST" && req.url === "/alpha/generate") { - // Simulate a slow streaming response - res.writeHead(200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }); - - // Send a few text-delta events with delays - const events = [ - JSON.stringify({ type: "text-delta", text: "Hello" }) + "\n", - JSON.stringify({ type: "text-delta", text: " " }) + "\n", - JSON.stringify({ type: "text-delta", text: "World" }) + "\n", - JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 10, outputTokens: 10 } }) + "\n", - ]; - - let i = 0; - const sendNext = () => { - if (i >= events.length) { - res.end(); - return; - } - res.write(events[i]); - i++; - if (i < events.length) { - // Deliberately slow — 500ms between events - setTimeout(sendNext, 500); - } else { - res.end(); - } - }; - - sendNext(); - - // Listen for close event (client disconnected → abort was triggered) - req.on("close", () => { - // Request aborted by client - }); - } else { - res.writeHead(404); - res.end("Not found"); - } - }); - - server.listen(0, () => { - port = (server.address() as any).port; - resolve(); - }); - }); + server = await startMockCommandCodeServer(); }); -after(() => { - return new Promise((resolve) => { - server.close(() => resolve()); - }); +after(async () => { + await server.close(); }); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +beforeEach(() => { + server.reset(); +}); describe("streamCommandCode — abort behavior", () => { - it("emits 'aborted' error when abort signal is triggered mid-stream", async () => { - // Dynamically import the actual stream function - // (the index.ts file imports from @mariozechner/pi-ai and @mariozechner/pi-coding-agent) - // We need to trick the module resolution by making these resolvable - // Simpler approach: import the pure types, construct manually - - const { createAssistantMessageEventStream } = await import(PI_AI_PATH); - - // Build a minimal model that matches what the provider uses - const model: Model = { - id: "test-model", - name: "Test Model", - api: "commandcode-custom", - provider: "commandcode", - baseUrl: `http://localhost:${port}`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 100000, - maxTokens: 4096, - }; - - const context = { - systemPrompt: "You are a test assistant.", - messages: [ - { role: "user" as const, content: "Hello", timestamp: Date.now() }, - ], - tools: [], - }; - - // We can't import streamCommandCode directly because of the - // @mariozechner/pi-coding-agent import dependency. - // Instead we test the principle: the AbortController races read() correctly. - // - // This is tested by verifying: - // 1. The source code has the raceAbort helper - // 2. The for(;;) loop checks controller.signal.aborted - // 3. reader.read() is raced against abort - - // Read the source to verify the implementation - const fs = await import("node:fs"); - const source = fs.readFileSync( - new URL("../index.ts", import.meta.url).pathname, - "utf-8", - ); - - // Verify raceAbort helper exists - assert.ok( - source.includes("raceAbort"), - "source should contain raceAbort helper", - ); - assert.ok( - source.includes("controller.signal.aborted) throw"), - "source should check abort before reader.read()", - ); - assert.ok( - source.includes("raceAbort(fetch"), - "source should race fetch against abort signal", - ); - assert.ok( - source.includes("raceAbort(reader.read())"), - "source should race reader.read() against abort signal", - ); - assert.ok( - source.includes("options?.signal?.aborted"), - "source should handle signals that were already aborted before listener registration", - ); - assert.ok( - source.includes("reader?.cancel()"), - "source should cancel the response reader on abort", - ); - assert.ok( - source.includes('removeEventListener("abort", abortUpstream)'), - "source should remove the abort listener after stream completion", - ); - assert.ok( - source.includes('join(homedir(), ".commandcode", "auth.json")') && - source.includes('join(homedir(), ".pi", "agent", "auth.json")'), - "source should support both Command Code and pi auth files", - ); - assert.ok( - source.includes("parseStreamEventLine") && source.includes('trimmed.startsWith("data:")'), - "source should support SSE data lines", - ); - assert.ok( - source.includes("finished = true") && source.includes("break readLoop"), - "source should stop reading after a finish event", - ); - assert.ok( - source.includes("controller.signal.aborted) throw") && - source.split("controller.signal.aborted").length >= 3, - "source should check abort in multiple places", - ); - }); - - it("raceAbort rejects immediately when already aborted", async () => { - // Test the raceAbort pattern in isolation + it("emits aborted error when signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const raceAbort = (promise: Promise): Promise => { - if (controller.signal.aborted) { - return Promise.reject( - Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), - ); - } - return new Promise((resolve, reject) => { - const onAbort = () => - reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); - controller.signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (v) => { controller.signal.removeEventListener("abort", onAbort); resolve(v); }, - (e) => { controller.signal.removeEventListener("abort", onAbort); reject(e); }, - ); - }); - }; + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + signal: controller.signal, + })); - let error: any; - try { - await raceAbort(new Promise(() => {})); // never resolves - } catch (e) { - error = e; - } - assert.ok(error instanceof Error); - assert.ok( - error.message.includes("aborted") || error.message.includes("Aborted"), - `Expected abort message, got: ${error.message}`, - ); + assert.deepEqual(events.map((event) => event.type), ["start", "error"]); + const error = events.at(-1); + assert.equal(error?.type, "error"); + if (error?.type !== "error") throw new Error("expected error"); + assert.equal(error.reason, "aborted"); + assert.equal(error.error.stopReason, "aborted"); + assert.equal(server.requestCount(), 0); }); - it("raceAbort rejects when aborted mid-flight (simulated)", async () => { + it("emits aborted error and cancels the response reader mid-stream", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "text-delta", text: "first" })], + hangAfterLast: true, + }); const controller = new AbortController(); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const raceAbort = (promise: Promise): Promise => { - if (controller.signal.aborted) { - return Promise.reject( - Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), - ); - } - return new Promise((resolve, reject) => { - const onAbort = () => - reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); - controller.signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (v) => { controller.signal.removeEventListener("abort", onAbort); resolve(v); }, - (e) => { controller.signal.removeEventListener("abort", onAbort); reject(e); }, - ); - }); - }; + const stream = streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + signal: controller.signal, + }); - // Start a slow promise, then abort - const slow = new Promise((resolve) => setTimeout(() => resolve("done"), 10000)); - const racedPromise = raceAbort(slow); + setTimeout(() => controller.abort(), 50); + const events = await collectEvents(stream, 2_000); - // Abort after 10ms - setTimeout(() => controller.abort(), 10); - - let error: any; - try { - await racedPromise; - } catch (e) { - error = e; - } - assert.ok(error instanceof Error); - assert.ok( - error.message.includes("aborted") || error.message.includes("Aborted"), - `Expected abort message, got: ${error.message}`, - ); - }); - - it("raceAbort resolves normally when not aborted", async () => { - const controller = new AbortController(); - - const raceAbort = (promise: Promise): Promise => { - if (controller.signal.aborted) { - return Promise.reject( - Object.assign(new Error("The operation was aborted"), { name: "AbortError" }), - ); - } - return new Promise((resolve, reject) => { - const onAbort = () => - reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); - controller.signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (v) => { controller.signal.removeEventListener("abort", onAbort); resolve(v); }, - (e) => { controller.signal.removeEventListener("abort", onAbort); reject(e); }, - ); - }); - }; - - const result = await raceAbort(Promise.resolve("success")); - assert.equal(result, "success"); + assert.ok(events.some((event) => event.type === "text_delta"), "stream should process data before abort"); + const error = events.at(-1); + assert.equal(error?.type, "error"); + if (error?.type !== "error") throw new Error("expected error"); + assert.equal(error.reason, "aborted"); + assert.equal(error.error.errorMessage, "Request aborted"); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response"); }); }); diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 66b7917..a11a627 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -1,611 +1,180 @@ /** - * Unit tests for pi-commandcode-provider pure functions. - * - * These tests DON'T require pi's runtime or any network access. - * They verify message/tool/schema conversion logic in isolation. - * - * Run with: npx tsx tests/test-pure-functions.ts - * Or: node --import tsx tests/test-pure-functions.ts + * Unit tests for the real pure helpers exported by src/core.ts. + * These are hermetic: no pi runtime and no network. */ import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it } from "node:test"; -// --------------------------------------------------------------------------- -// Pure functions copied from index.ts (standalone, no pi imports needed) -// --------------------------------------------------------------------------- +import { + getApiKey, + getEnvironmentInfo, + mapFinishReason, + messagesToCC, + parseStreamEventLine, + textContent, + toJsonSchema, + toolsToJson, +} from "../src/core.ts"; -function uuid(): string { - return crypto.randomUUID(); -} +import { objectAt } from "./helpers.ts"; -function textContent(m: { content: any[] }): string { - return (m.content ?? []) - .filter((c: any) => c.type === "text") - .map((c: any) => c.text ?? "") - .join("\n"); -} - -function getEnvironmentInfo(): string { - return `${process.platform}-${process.arch}, Node.js ${process.version}`; -} - -/** - * Minimal typebox → JSON Schema converter. - * Handles Object, String, Number, Boolean, Array, Union, Optional, Enum. - */ -function toJsonSchema(schema: any): any { - if (!schema) return {}; - const s = schema as Record; - const kind = s.kind ?? s.type; - - if (s.enum) { - return { type: typeof s.enum[0], enum: s.enum }; - } - - switch (kind) { - case "string": - case "String": - return { type: "string" }; - case "number": - case "Number": - return { type: "number" }; - case "boolean": - case "Boolean": - return { type: "boolean" }; - case "object": - case "Object": { - const props: Record = {}; - const inferredRequired: string[] = []; - if (s.properties) { - for (const [k, v] of Object.entries(s.properties)) { - props[k] = toJsonSchema(v); - if (!(v as any).optional && !s.optional?.includes?.(k)) - inferredRequired.push(k); - } - } - const required = Array.isArray(s.required) ? s.required : inferredRequired; - const out: any = { type: "object" }; - if (Object.keys(props).length) out.properties = props; - if (required.length) out.required = required; - return out; - } - case "array": - case "Array": - return { type: "array", items: toJsonSchema(s.items ?? s.element) }; - case "union": - case "Union": { - const variants = s.variants ?? s.anyOf ?? []; - for (const v of variants) { - const schema = toJsonSchema(v); - if (schema && Object.keys(schema).length) return schema; - } - return {}; - } - case "optional": - case "Optional": - return toJsonSchema(s.wrapped ?? s.inner); - default: - return {}; - } -} - -function toolsToJson(tools: any[]): any[] { - if (!tools) return []; - return tools.map((t) => { - const schema = t.parameters ? toJsonSchema(t.parameters) : {}; - return { - type: "function", - name: t.name, - description: t.description, - input_schema: schema, - }; - }); -} - -function messagesToCC(msgs: any[]): any[] { - const out: any[] = []; - for (const m of msgs) { - if (m.role === "user") { - out.push({ - role: "user", - content: typeof m.content === "string" ? m.content : m.content, - }); - } else if (m.role === "assistant") { - const parts: any[] = []; - for (const c of m.content) { - if (c.type === "text") { - parts.push({ type: "text", text: c.text }); - } else if (c.type === "thinking") { - parts.push({ type: "reasoning", text: c.thinking }); - } else if (c.type === "toolCall") { - parts.push({ - type: "tool-call", - toolCallId: c.id, - toolName: c.name, - input: c.arguments, - }); - } - } - out.push({ role: "assistant", content: parts }); - } else if (m.role === "toolResult") { - out.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: m.toolCallId, - toolName: m.toolName, - output: m.isError - ? { type: "error-text", value: textContent(m) } - : { type: "text", value: textContent(m) }, - }, - ], - }); - } - } - return out; -} - -// =========================================================================== -// Tests -// =========================================================================== - -describe("uuid()", () => { - it("returns a valid UUID string", () => { - const id = uuid(); - assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); +describe("getApiKey()", () => { + it("uses COMMANDCODE_API_KEY from provided env", () => { + assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key"); }); - it("returns unique values on each call", () => { - const a = uuid(); - const b = uuid(); - assert.notEqual(a, b); + it("reads apiKey and commandcode fields from explicit auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-")); + try { + const first = join(dir, "first.json"); + const second = join(dir, "second.json"); + writeFileSync(first, JSON.stringify({ apiKey: "file-key" })); + writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })); + assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key"); + assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("ignores malformed auth files", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")); + try { + const bad = join(dir, "bad.json"); + writeFileSync(bad, "not json"); + assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses injected homeDir for default auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-home-")); + try { + const authDir = join(dir, ".pi", "agent"); + mkdirSync(authDir, { recursive: true }); + writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })); + assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); }); -// --------------------------------------------------------------- -// textContent -// --------------------------------------------------------------- - describe("textContent()", () => { - it("extracts text from content array", () => { - const msg = { content: [{ type: "text", text: "hello" }, { type: "text", text: "world" }] }; - assert.equal(textContent(msg), "hello\nworld"); - }); - - it("filters non-text content", () => { - const msg = { content: [{ type: "text", text: "hello" }, { type: "image", data: "x" }] }; - assert.equal(textContent(msg), "hello"); - }); - - it("returns empty string for empty content", () => { - assert.equal(textContent({ content: [] }), ""); - }); - - it("handles missing content gracefully", () => { - assert.equal(textContent({ content: undefined as any }) ?? "", ""); - }); -}); - -// --------------------------------------------------------------- -// getEnvironmentInfo -// --------------------------------------------------------------- - -describe("getEnvironmentInfo()", () => { - it("returns string with platform, arch, and node version", () => { - const info = getEnvironmentInfo(); - assert.match(info, /^(darwin|linux|win32)-/); - assert.ok(info.includes("Node.js"), `expected Node.js in: ${info}`); - }); -}); - -// --------------------------------------------------------------- -// toJsonSchema -// --------------------------------------------------------------- - -describe("toJsonSchema — scalar types", () => { - it("handles string (lowercase kind)", () => { - assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }); - }); - it("handles String (capitalized)", () => { - assert.deepEqual(toJsonSchema({ kind: "String" }), { type: "string" }); - }); - it("handles number", () => { - assert.deepEqual(toJsonSchema({ kind: "number" }), { type: "number" }); - }); - it("handles Number", () => { - assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }); - }); - it("handles boolean", () => { - assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }); - }); - it("handles Boolean", () => { - assert.deepEqual(toJsonSchema({ kind: "Boolean" }), { type: "boolean" }); - }); -}); - -describe("toJsonSchema — enum", () => { - it("detects enum by property (string values)", () => { - const schema = { kind: "string", enum: ["left", "right"] }; - assert.deepEqual(toJsonSchema(schema), { type: "string", enum: ["left", "right"] }); - }); - it("detects enum by property (number values)", () => { - const schema = { kind: "number", enum: [1, 2, 3] }; - assert.deepEqual(toJsonSchema(schema), { type: "number", enum: [1, 2, 3] }); - }); -}); - -describe("toJsonSchema — object", () => { - it("converts simple object with string props", () => { - const schema = { - kind: "object", - properties: { - name: { kind: "string" }, - age: { kind: "number" }, - }, - }; - assert.deepEqual(toJsonSchema(schema), { - type: "object", - properties: { name: { type: "string" }, age: { type: "number" } }, - required: ["name", "age"], - }); - }); - - it("marks optional properties correctly", () => { - const schema = { - kind: "object", - properties: { - name: { kind: "string" }, - nickname: { kind: "string", optional: true }, - }, - }; - const result = toJsonSchema(schema); - assert.deepEqual(result.required, ["name"]); - assert.deepEqual(result.properties?.nickname, { type: "string" }); - }); - - it("handles optional via top-level optional array", () => { - const schema = { - kind: "Object", - properties: { - name: { kind: "string" }, - age: { kind: "number" }, - }, - optional: ["age"], - }; - const result = toJsonSchema(schema); - assert.deepEqual(result.required, ["name"]); - }); - - it("preserves TypeBox required arrays", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - nickname: { type: "string" }, - }, - required: ["name"], - }; - const result = toJsonSchema(schema); - assert.deepEqual(result.required, ["name"]); - }); - - it("handles empty object", () => { - assert.deepEqual(toJsonSchema({ kind: "object" }), { type: "object" }); - }); - - it("handles Object (capitalized)", () => { - assert.deepEqual(toJsonSchema({ kind: "Object" }), { type: "object" }); - }); -}); - -describe("toJsonSchema — array", () => { - it("converts array with items", () => { - const schema = { kind: "array", items: { kind: "string" } }; - assert.deepEqual(toJsonSchema(schema), { type: "array", items: { type: "string" } }); - }); - - it("converts array with element (alternative prop name)", () => { - const schema = { kind: "Array", element: { kind: "number" } }; - assert.deepEqual(toJsonSchema(schema), { type: "array", items: { type: "number" } }); - }); -}); - -describe("toJsonSchema — union", () => { - it("uses first non-empty variant", () => { - const schema = { kind: "union", variants: [{}, { kind: "string" }] }; - assert.deepEqual(toJsonSchema(schema), { type: "string" }); - }); - - it("uses anyOf as fallback property name", () => { - const schema = { kind: "Union", anyOf: [{ kind: "number" }] }; - assert.deepEqual(toJsonSchema(schema), { type: "number" }); - }); - - it("returns empty object for empty union", () => { - assert.deepEqual(toJsonSchema({ kind: "union", variants: [] }), {}); - }); -}); - -describe("toJsonSchema — optional", () => { - it("unwraps optional via wrapped", () => { - const schema = { kind: "optional", wrapped: { kind: "string" } }; - assert.deepEqual(toJsonSchema(schema), { type: "string" }); - }); - - it("unwraps optional via inner", () => { - const schema = { kind: "Optional", inner: { kind: "number" } }; - assert.deepEqual(toJsonSchema(schema), { type: "number" }); - }); -}); - -describe("toJsonSchema — edge cases", () => { - it("returns empty object for null", () => { - assert.deepEqual(toJsonSchema(null), {}); - }); - - it("returns empty object for undefined", () => { - assert.deepEqual(toJsonSchema(undefined), {}); - }); - - it("handles unknown kind gracefully", () => { - assert.deepEqual(toJsonSchema({ kind: "foobar" }), {}); - }); - - it("handles nested objects recursively", () => { - const schema = { - kind: "object", - properties: { - user: { - kind: "object", - properties: { - name: { kind: "string" }, - address: { - kind: "object", - properties: { city: { kind: "string" } }, - }, - }, - }, - }, - }; - const result = toJsonSchema(schema); - assert.equal(result.properties.user.type, "object"); - assert.equal(result.properties.user.properties.address.type, "object"); + it("extracts and joins text blocks", () => { assert.equal( - result.properties.user.properties.address.properties.city.type, - "string", + textContent({ content: [{ type: "text", text: "hello" }, { type: "image", data: "x" }, { type: "text", text: "world" }] }), + "hello\nworld", ); }); - it("handles type property as fallback for kind", () => { - assert.deepEqual(toJsonSchema({ type: "string" }), { type: "string" }); + it("handles empty or missing content", () => { + assert.equal(textContent({ content: [] }), ""); + assert.equal(textContent({}), ""); }); }); -// --------------------------------------------------------------- -// toolsToJson -// --------------------------------------------------------------- +describe("getEnvironmentInfo()", () => { + it("returns platform, arch, and Node version", () => { + const info = getEnvironmentInfo(); + assert.match(info, /^(darwin|linux|win32)-/); + assert.ok(info.includes("Node.js")); + }); +}); + +describe("toJsonSchema()", () => { + it("converts scalar, enum, object, optional, array, and union schema shapes", () => { + assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }); + assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }); + assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }); + assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { + type: "string", + enum: ["left", "right"], + }); + assert.deepEqual( + toJsonSchema({ + kind: "object", + properties: { + name: { kind: "string" }, + tags: { kind: "array", items: { kind: "string" }, optional: true }, + }, + }), + { + type: "object", + properties: { name: { type: "string" }, tags: { type: "array", items: { type: "string" } } }, + required: ["name"], + }, + ); + assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { type: "string" }); + assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { type: "number" }); + }); + + it("preserves explicit required arrays and handles unknown values", () => { + assert.deepEqual( + toJsonSchema({ + type: "object", + properties: { name: { type: "string" }, nickname: { type: "string" } }, + required: ["name"], + }), + { + type: "object", + properties: { name: { type: "string" }, nickname: { type: "string" } }, + required: ["name"], + }, + ); + assert.deepEqual(toJsonSchema(undefined), {}); + assert.deepEqual(toJsonSchema({ kind: "wat" }), {}); + }); +}); describe("toolsToJson()", () => { - it("returns empty array for undefined", () => { - assert.deepEqual(toolsToJson(undefined as any), []); - }); - - it("returns empty array for null", () => { - assert.deepEqual(toolsToJson(null as any), []); - }); - - it("returns empty array for empty array", () => { - assert.deepEqual(toolsToJson([]), []); - }); - - it("converts a tool with object parameters", () => { - const tools = [ - { - name: "get_weather", - description: "Get the weather for a city", - parameters: { - kind: "object", - properties: { - city: { kind: "string" }, + it("converts pi tools to Command Code tool JSON", () => { + assert.deepEqual( + toolsToJson([ + { + name: "get_weather", + description: "Get weather", + parameters: { + kind: "object", + properties: { city: { kind: "string" } }, }, }, - }, - ]; - const result = toolsToJson(tools); - assert.equal(result.length, 1); - assert.equal(result[0].type, "function"); - assert.equal(result[0].name, "get_weather"); - assert.equal(result[0].description, "Get the weather for a city"); - assert.deepEqual(result[0].input_schema, { - type: "object", - properties: { city: { type: "string" } }, - required: ["city"], - }); - }); - - it("handles tool without parameters", () => { - const tools = [{ name: "ping", description: "Check connectivity" }]; - const result = toolsToJson(tools); - assert.equal(result.length, 1); - assert.deepEqual(result[0].input_schema, {}); - }); - - it("converts multiple tools", () => { - const tools = [ - { name: "tool_a", description: "A", parameters: { kind: "string" } }, - { name: "tool_b", description: "B", parameters: { kind: "number" } }, - ]; - const result = toolsToJson(tools); - assert.equal(result.length, 2); - assert.equal(result[0].name, "tool_a"); - assert.equal(result[1].name, "tool_b"); - }); -}); - -// --------------------------------------------------------------- -// messagesToCC -// --------------------------------------------------------------- - -describe("messagesToCC() — user messages", () => { - it("converts string content user message", () => { - const msgs = [{ role: "user", content: "hello" }]; - const result = messagesToCC(msgs); - assert.equal(result.length, 1); - assert.equal(result[0].role, "user"); - assert.equal(result[0].content, "hello"); - }); - - it("passes through array content user message", () => { - const content = [{ type: "text", text: "hello" }]; - const msgs = [{ role: "user", content }]; - const result = messagesToCC(msgs); - assert.equal(result[0].role, "user"); - assert.equal(result[0].content, content); - }); -}); - -describe("messagesToCC() — assistant messages", () => { - it("converts text content", () => { - const msgs = [ - { - role: "assistant", - content: [{ type: "text", text: "Hello from assistant" }], - }, - ]; - const result = messagesToCC(msgs); - assert.equal(result.length, 1); - assert.equal(result[0].role, "assistant"); - assert.deepEqual(result[0].content, [ - { type: "text", text: "Hello from assistant" }, - ]); - }); - - it("converts thinking content to reasoning", () => { - const msgs = [ - { - role: "assistant", - content: [{ type: "thinking", thinking: "Let me think..." }], - }, - ]; - const result = messagesToCC(msgs); - assert.deepEqual(result[0].content, [ - { type: "reasoning", text: "Let me think..." }, - ]); - }); - - it("converts toolCall content", () => { - const msgs = [ - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call_123", - name: "read_file", - arguments: { path: "/tmp/test" }, + ]), + [ + { + type: "function", + name: "get_weather", + description: "Get weather", + input_schema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], }, - ], - }, - ]; - const result = messagesToCC(msgs); - assert.deepEqual(result[0].content, [ - { - type: "tool-call", - toolCallId: "call_123", - toolName: "read_file", - input: { path: "/tmp/test" }, - }, - ]); + }, + ], + ); }); - it("converts mixed content (thinking + text + toolCall)", () => { - const msgs = [ - { - role: "assistant", - content: [ - { type: "thinking", thinking: "planning..." }, - { type: "text", text: "result" }, - { type: "toolCall", id: "t1", name: "ls", arguments: {} }, - ], - }, - ]; - const result = messagesToCC(msgs); - assert.equal(result[0].role, "assistant"); - assert.equal(result[0].content.length, 3); - assert.equal(result[0].content[0].type, "reasoning"); - assert.equal(result[0].content[1].type, "text"); - assert.equal(result[0].content[2].type, "tool-call"); + it("returns an empty array for missing tools", () => { + assert.deepEqual(toolsToJson(), []); }); }); -describe("messagesToCC() — toolResult messages", () => { - it("converts successful tool result", () => { - const msgs = [ - { - role: "toolResult", - toolCallId: "call_123", - toolName: "read_file", - isError: false, - content: [{ type: "text", text: "file contents here" }], - }, - ]; - const result = messagesToCC(msgs); - assert.equal(result.length, 1); - assert.equal(result[0].role, "tool"); - assert.equal(result[0].content[0].type, "tool-result"); - assert.equal(result[0].content[0].output.type, "text"); - assert.equal(result[0].content[0].output.value, "file contents here"); - }); - - it("converts error tool result", () => { - const msgs = [ - { - role: "toolResult", - toolCallId: "call_456", - toolName: "bad_tool", - isError: true, - content: [{ type: "text", text: "something went wrong" }], - }, - ]; - const result = messagesToCC(msgs); - assert.equal(result[0].content[0].output.type, "error-text"); - assert.equal(result[0].content[0].output.value, "something went wrong"); - }); - - it("joins multiple text parts", () => { - const msgs = [ - { - role: "toolResult", - toolCallId: "call_789", - toolName: "grep", - isError: false, - content: [ - { type: "text", text: "line 1" }, - { type: "text", text: "line 2" }, - ], - }, - ]; - const result = messagesToCC(msgs); - assert.equal(result[0].content[0].output.value, "line 1\nline 2"); - }); -}); - -describe("messagesToCC() — full conversation", () => { - it("handles user → assistant(toolCall) → tool → assistant", () => { - const msgs = [ +describe("messagesToCC()", () => { + it("converts user, assistant, and tool result messages", () => { + const result = messagesToCC([ { role: "user", content: "read /tmp/test" }, { role: "assistant", content: [ - { type: "thinking", thinking: "I will read the file" }, - { - type: "toolCall", - id: "c1", - name: "read", - arguments: { path: "/tmp/test" }, - }, + { type: "thinking", thinking: "I will read" }, + { type: "text", text: "Sure" }, + { type: "toolCall", id: "c1", name: "read", arguments: { path: "/tmp/test" } }, ], }, { @@ -613,25 +182,45 @@ describe("messagesToCC() — full conversation", () => { toolCallId: "c1", toolName: "read", isError: false, - content: [{ type: "text", text: "hello world" }], + content: [{ type: "text", text: "hello" }, { type: "text", text: "world" }], }, - { - role: "assistant", - content: [{ type: "text", text: "The file contains: hello world" }], - }, - ]; - const result = messagesToCC(msgs); - assert.equal(result.length, 4); - assert.equal(result[0].role, "user"); - assert.equal(result[1].role, "assistant"); - assert.equal(result[1].content.length, 2); - assert.equal(result[2].role, "tool"); - assert.equal(result[3].role, "assistant"); - assert.equal(result[3].content[0].text, "The file contains: hello world"); + ]); + + assert.equal(objectAt(result, ["0", "role"]), "user"); + assert.equal(objectAt(result, ["1", "role"]), "assistant"); + assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning"); + assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call"); + assert.equal(objectAt(result, ["2", "role"]), "tool"); + assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld"); }); - it("handles empty message array", () => { - const result = messagesToCC([]); - assert.deepEqual(result, []); + it("handles empty conversations", () => { + assert.deepEqual(messagesToCC([]), []); + }); +}); + +describe("parseStreamEventLine()", () => { + it("parses plain JSON and SSE data lines", () => { + assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { type: "text-delta", text: "x" }); + assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), { + type: "finish", + finishReason: "stop", + }); + }); + + it("ignores comments, event labels, done markers, and malformed JSON", () => { + assert.equal(parseStreamEventLine(":"), undefined); + assert.equal(parseStreamEventLine("event: message"), undefined); + assert.equal(parseStreamEventLine("data: [DONE]"), undefined); + assert.equal(parseStreamEventLine("not-json"), undefined); + }); +}); + +describe("mapFinishReason()", () => { + it("maps provider finish reasons to pi stop reasons", () => { + assert.equal(mapFinishReason("stop"), "stop"); + assert.equal(mapFinishReason("tool-calls"), "toolUse"); + assert.equal(mapFinishReason("max_tokens"), "length"); + assert.equal(mapFinishReason("max_output_tokens"), "length"); }); }); diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 47e70f6..1f25cfc 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -1,1725 +1,289 @@ /** - * Integration tests for streamCommandCode using a mock Command Code server. - * - * Tests the full stream lifecycle: events, error handling, edge cases. - * No real API key needed — the mock server simulates all responses. - * - * Run with: npx tsx tests/test-stream.ts + * Integration tests for the real streamCommandCode core using a local mock + * Command Code server. No real API key or pi runtime required. */ import assert from "node:assert/strict"; import { after, before, beforeEach, describe, it } from "node:test"; -import { createServer, type Server } from "node:http"; -// --------------------------------------------------------------------------- -// pi-ai types: import from pi's bundled copy -// --------------------------------------------------------------------------- -const PI_AI = - "/nix/store/rlhiqjvq3xhs82481s198c6bpnsksbjd-pi-coding-agent-0.72.0/lib/node_modules/@mariozechner/pi-coding-agent/node_modules/@mariozechner/pi-ai/dist/index.js"; +import type { AssistantMessageEvent } from "../src/core.ts"; +import { + collectEvents, + createTestDeps, + makeContext, + makeModel, + objectAt, + startMockCommandCodeServer, + type MockCommandCodeServer, +} from "./helpers.ts"; -let createAssistantMessageEventStream: any; -let calculateCost: any; - -// Both the pi-ai import and server startup happen in a single before hook -// to avoid ordering issues with node:test + tsx. +let server: MockCommandCodeServer; before(async () => { - // 1. Import pi-ai - const mod = await import(PI_AI); - createAssistantMessageEventStream = mod.createAssistantMessageEventStream; - calculateCost = mod.calculateCost; - - // 2. Start mock server - await new Promise((resolve) => { - server = createServer((req, res) => { - if (req.method === "POST" && req.url === "/alpha/generate") { - _requestCount++; - let body = ""; - req.on("data", (c) => (body += c.toString())); - req.on("end", () => { - try { - _lastRequestBody = JSON.parse(body); - } catch { - _lastRequestBody = null; - } - - const plan = _nextPlan; - - if (plan.type === "error") { - res.writeHead(plan.status, { "Content-Type": "text/plain" }); - res.end(plan.body); - return; - } - - res.writeHead(plan.status, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }); - - const events = plan.events ?? []; - const delays = plan.delays ?? events.map(() => 0); - const hangAfterLast = plan.type === "success" ? plan.hangAfterLast : false; - - let i = 0; - const sendNext = () => { - if (i >= events.length) { - if (!hangAfterLast) res.end(); - return; - } - res.write(events[i] + "\n"); - i++; - if (i < events.length) { - setTimeout(sendNext, delays[i] ?? 0); - } else if (!hangAfterLast) { - res.end(); - } - }; - - sendNext(); - }); - } else { - res.writeHead(404); - res.end("Not found"); - } - }); - - server.listen(0, () => { - port = (server.address() as any).port; - resolve(); - }); - }); - - // 3. Initialize streamCommandCode - if (typeof createAssistantMessageEventStream !== "function") { - throw new Error( - `createAssistantMessageEventStream is not a function after import, it is ${typeof createAssistantMessageEventStream}`, - ); - } - streamCommandCode = createStreamCommandCode( - createAssistantMessageEventStream, - calculateCost, - baseUrl(), - ); + server = await startMockCommandCodeServer(); }); -// --------------------------------------------------------------------------- -// Build model fixture -// --------------------------------------------------------------------------- - -function makeModel(overrides: Partial> = {}) { - return { - id: "test-model", - name: "Test Model", - api: "commandcode-custom", - provider: "commandcode", - baseUrl: "", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 100_000, - maxTokens: 4096, - ...overrides, - }; -} - -function makeContext(overrides: Partial> = {}) { - return { - systemPrompt: "You are a test assistant.", - messages: [ - { role: "user", content: "hello", timestamp: Date.now() }, - ], - tools: [], - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// Mock server: simulates Command Code /alpha/generate streaming endpoint -// --------------------------------------------------------------------------- - -type ResponsePlan = - | { type: "success"; status: number; events: string[]; delays?: number[]; hangAfterLast?: boolean } - | { type: "error"; status: number; body: string }; - -let server: Server; -let port: number; -let _nextPlan: ResponsePlan = { type: "success", status: 200, events: [] }; -let _requestCount = 0; -let _lastRequestBody: any = null; -let streamCommandCode: any; - -function mockResponse(plan: ResponsePlan) { - _nextPlan = plan; -} - -function lastRequestBody(): any { - return _lastRequestBody; -} - -function requestCount(): number { - return _requestCount; -} - -function baseUrl(): string { - return `http://localhost:${port}`; -} - -after(() => { - return new Promise((resolve) => server.close(() => resolve())); +after(async () => { + await server.close(); }); beforeEach(() => { - _requestCount = 0; - _lastRequestBody = null; - _nextPlan = { type: "success", status: 200, events: [] }; + server.reset(); }); -// --------------------------------------------------------------------------- -// Helper: collect all events from a stream into an array -// --------------------------------------------------------------------------- - -async function collectEvents( - stream: any, - opts?: { signal?: AbortSignal; maxEvents?: number; timeoutMs?: number }, -): Promise { - const events: any[] = []; - const max = opts?.maxEvents ?? 1000; - - // Create abort promise (resolves when signal fires) - const abortPromise = new Promise((resolve) => { - opts?.signal?.addEventListener("abort", () => resolve(), { once: true }); - }); - - // Create timeout promise (only if timeoutMs is set) - const timeoutPromise = opts?.timeoutMs - ? new Promise((resolve) => setTimeout(resolve, opts.timeoutMs)) - : null; - - // The iteration promise: for-await the async iterable stream - const iteratorPromise = (async () => { - try { - for await (const event of stream) { - events.push(event); - if (events.length >= max || event.type === "done" || event.type === "error") { - return; - } - } - } catch { - // Stream ended or was aborted — just return what we have - } - })(); - - // Race: iteration vs abort vs (optional) timeout - if (timeoutPromise) { - await Promise.race([iteratorPromise, abortPromise, timeoutPromise]); - } else { - await Promise.race([iteratorPromise, abortPromise]); - } - - return events; +function eventTypes(events: readonly AssistantMessageEvent[]): string[] { + return events.map((event) => event.type); } -// --------------------------------------------------------------------------- -// Inlined streamCommandCode — identical logic to index.ts, configurable baseUrl. -// We inline instead of importing index.ts because index.ts imports from -// @mariozechner/pi-ai and @mariozechner/pi-coding-agent which aren't -// installed as npm deps (pi resolves them from its own install path). -// --------------------------------------------------------------------------- - -import { existsSync, readFileSync as fsReadFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -// Copy of getApiKey (identical to index.ts) -function getApiKey(): string | undefined { - const env = process.env.COMMANDCODE_API_KEY; - if (env) return env; - try { - const authPath = join(homedir(), ".commandcode", "auth.json"); - if (existsSync(authPath)) { - const auth = JSON.parse(fsReadFileSync(authPath, "utf-8")); - if (auth.apiKey) return auth.apiKey; - } - } catch { - /* ignore */ - } - return undefined; -} - -// Copy of textContent (identical to index.ts) -function textContent(m: { content: any[] }): string { - return (m.content ?? []) - .filter((c: any) => c.type === "text") - .map((c: any) => c.text ?? "") - .join("\n"); -} - -// Copy of uuid (identical to index.ts) -function uuid(): string { - return crypto.randomUUID(); -} - -function getEnvironmentInfo(): string { - return `${process.platform}-${process.arch}, Node.js ${process.version}`; -} - -function parseStreamEventLine(line: string): any | undefined { - let trimmed = line.trim(); - if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined; - if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim(); - if (!trimmed || trimmed === "[DONE]") return undefined; - - try { - return JSON.parse(trimmed); - } catch { - return undefined; - } -} - -function mapFinishReason(reason: unknown): "stop" | "length" | "toolUse" { - if (reason === "tool-calls") return "toolUse"; - if ( - reason === "length" || - reason === "max_tokens" || - reason === "max-tokens" || - reason === "max_output_tokens" - ) { - return "length"; - } - return "stop"; -} - -// Copy of toJsonSchema (identical to index.ts) -function toJsonSchema(schema: any): any { - if (!schema) return {}; - const s = schema as Record; - const kind = s.kind ?? s.type; - if (s.enum) { - return { type: typeof s.enum[0], enum: s.enum }; - } - switch (kind) { - case "string": - case "String": - return { type: "string" }; - case "number": - case "Number": - return { type: "number" }; - case "boolean": - case "Boolean": - return { type: "boolean" }; - case "object": - case "Object": { - const props: Record = {}; - const inferredRequired: string[] = []; - if (s.properties) { - for (const [k, v] of Object.entries(s.properties)) { - props[k] = toJsonSchema(v); - if (!(v as any).optional && !s.optional?.includes?.(k)) - inferredRequired.push(k); - } - } - const required = Array.isArray(s.required) ? s.required : inferredRequired; - const out: any = { type: "object" }; - if (Object.keys(props).length) out.properties = props; - if (required.length) out.required = required; - return out; - } - case "array": - case "Array": - return { type: "array", items: toJsonSchema(s.items ?? s.element) }; - case "union": - case "Union": { - const variants = s.variants ?? s.anyOf ?? []; - for (const v of variants) { - const schema = toJsonSchema(v); - if (schema && Object.keys(schema).length) return schema; - } - return {}; - } - case "optional": - case "Optional": - return toJsonSchema(s.wrapped ?? s.inner); - default: - return {}; - } -} - -function toolsToJson(tools: any[]): any[] { - if (!tools) return []; - return tools.map((t) => { - const schema = t.parameters ? toJsonSchema(t.parameters) : {}; - return { - type: "function", - name: t.name, - description: t.description, - input_schema: schema, - }; - }); -} - -function messagesToCC(msgs: any[]): any[] { - const out: any[] = []; - for (const m of msgs) { - if (m.role === "user") { - out.push({ - role: "user", - content: typeof m.content === "string" ? m.content : m.content, - }); - } else if (m.role === "assistant") { - const parts: any[] = []; - for (const c of m.content) { - if (c.type === "text") { - parts.push({ type: "text", text: c.text }); - } else if (c.type === "thinking") { - parts.push({ type: "reasoning", text: c.thinking }); - } else if (c.type === "toolCall") { - parts.push({ - type: "tool-call", - toolCallId: c.id, - toolName: c.name, - input: c.arguments, - }); - } - } - out.push({ role: "assistant", content: parts }); - } else if (m.role === "toolResult") { - out.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: m.toolCallId, - toolName: m.toolName, - output: m.isError - ? { type: "error-text", value: textContent(m) } - : { type: "text", value: textContent(m) }, - }, - ], - }); - } - } - return out; -} - -// --------------------------------------------------------------------------- -// streamCommandCode — exact copy of index.ts logic, parameterized via baseUrl -// --------------------------------------------------------------------------- - -function createStreamCommandCode( - _createStream: any, - _calculateCost: any, - _apiBase: string, -) { - function raceAbort(promise: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject( - new DOMException("The operation was aborted", "AbortError"), - ); - } - return new Promise((resolve, reject) => { - const onAbort = () => - reject(new DOMException("The operation was aborted", "AbortError")); - signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (v) => { - signal.removeEventListener("abort", onAbort); - resolve(v); - }, - (e) => { - signal.removeEventListener("abort", onAbort); - reject(e); - }, - ); - }); - } - - return function streamCommandCode( - model: any, - context: any, - options?: any, - ): any { - const stream = _createStream(); - - (async () => { - const apiKey = options?.apiKey ?? getApiKey(); - if (!apiKey) { - const msg: any = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "error", - errorMessage: - "No Command Code API key. Set COMMANDCODE_API_KEY env var or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.", - timestamp: Date.now(), - }; - stream.push({ type: "error", reason: "error", error: msg }); - stream.end(); - return; - } - - const output: any = { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; - - const controller = new AbortController(); - let reader: ReadableStreamDefaultReader | undefined; - options?.signal?.addEventListener( - "abort", - () => controller.abort(), - { once: true }, - ); - - try { - stream.push({ type: "start", partial: output as any }); - - const ccHeaders: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "x-command-code-version": "0.24.1", - "x-cli-environment": "production", - "x-project-slug": "pi-cc", - "x-taste-learning": "false", - "x-co-flag": "false", - "x-session-id": uuid(), - ...options?.headers, - }; - - const body = { - config: { - workingDir: process.cwd(), - date: new Date().toISOString().split("T")[0], - environment: getEnvironmentInfo(), - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], - }, - memory: "", - taste: "", - skills: null, - permissionMode: "standard", - params: { - model: model.id, - messages: messagesToCC(context.messages), - tools: toolsToJson(context.tools), - system: context.systemPrompt ?? "", - max_tokens: Math.min( - options?.maxTokens ?? model.maxTokens, - 200_000, - ), - stream: true, - }, - }; - - const response = await raceAbort( - fetch(`${_apiBase}/alpha/generate`, { - method: "POST", - headers: ccHeaders, - body: JSON.stringify(body), - signal: controller.signal, - }), - controller.signal, - ); - - if (!response.ok) { - const errBody = await response.text().catch(() => ""); - throw new Error( - `Command Code API error ${response.status}: ${errBody.slice(0, 500)}`, - ); - } - - reader = response.body?.getReader(); - if (!reader) throw new Error("No response body"); - - const decoder = new TextDecoder(); - let buffer = ""; - let currentTextIdx = -1; - let textBlock: any = null; - let reasoningActive = false; - let thinkingBlock: string[] = []; - let finished = false; - - mainLoop: for (;;) { - if (controller.signal.aborted) - throw new DOMException("Aborted", "AbortError"); - const { done, value } = await raceAbort( - reader.read(), - controller.signal, - ); - if (done) break; - if (controller.signal.aborted) - throw new DOMException("Aborted", "AbortError"); - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (controller.signal.aborted) break mainLoop; - const event = parseStreamEventLine(line); - if (!event) continue; - - switch (event.type) { - case "text-delta": { - if (!textBlock) { - textBlock = { type: "text", text: "" }; - output.content.push(textBlock); - currentTextIdx = output.content.length - 1; - stream.push({ - type: "text_start", - contentIndex: currentTextIdx, - partial: output, - }); - } - textBlock.text += event.text ?? ""; - stream.push({ - type: "text_delta", - contentIndex: currentTextIdx, - delta: event.text ?? "", - partial: output, - }); - break; - } - - case "reasoning-delta": { - if (!reasoningActive) { - reasoningActive = true; - } - thinkingBlock.push(event.text ?? ""); - break; - } - - case "reasoning-end": { - if (thinkingBlock.length > 0) { - const thinkingText = thinkingBlock.join(""); - thinkingBlock = []; - output.content.push({ - type: "thinking", - thinking: thinkingText, - }); - const idx = output.content.length - 1; - stream.push({ - type: "thinking_start", - contentIndex: idx, - partial: output, - }); - stream.push({ - type: "thinking_delta", - contentIndex: idx, - delta: thinkingText, - partial: output, - }); - stream.push({ - type: "thinking_end", - contentIndex: idx, - content: thinkingText, - partial: output, - }); - } - reasoningActive = false; - break; - } - - case "tool-call": { - if (textBlock) { - stream.push({ - type: "text_end", - contentIndex: currentTextIdx, - content: textBlock.text, - partial: output, - }); - textBlock = null; - currentTextIdx = -1; - } - output.content.push({ - type: "toolCall", - id: event.toolCallId, - name: event.toolName, - arguments: event.input ?? event.args ?? {}, - }); - const idx = output.content.length - 1; - stream.push({ - type: "toolcall_start", - contentIndex: idx, - partial: output, - }); - stream.push({ - type: "toolcall_end", - contentIndex: idx, - toolCall: { - type: "toolCall", - id: event.toolCallId, - name: event.toolName, - arguments: event.input ?? event.args ?? {}, - }, - partial: output, - }); - break; - } - - case "finish": { - const usage = event.totalUsage; - if (usage) { - output.usage.input = usage.inputTokens ?? 0; - output.usage.output = usage.outputTokens ?? 0; - output.usage.cacheRead = - usage.inputTokenDetails?.cacheReadTokens ?? 0; - output.usage.cacheWrite = - usage.inputTokenDetails?.cacheWriteTokens ?? 0; - output.usage.totalTokens = - output.usage.input + - output.usage.output + - output.usage.cacheRead + - output.usage.cacheWrite; - _calculateCost(model, output.usage); - } - output.stopReason = mapFinishReason(event.finishReason); - finished = true; - break; - } - - case "error": { - const msg = - event.error?.message ?? event.error ?? "Stream error"; - output.stopReason = "error"; - output.errorMessage = - typeof msg === "string" ? msg : String(msg); - throw new Error(output.errorMessage); - } - } - if (finished) break mainLoop; - } - } - - // End any lingering text block - if (textBlock) { - stream.push({ - type: "text_end", - contentIndex: currentTextIdx, - content: textBlock.text, - partial: output, - }); - } - - // Emit remaining thinking - if (thinkingBlock.length > 0) { - const thinkingText = thinkingBlock.join(""); - output.content.push({ - type: "thinking", - thinking: thinkingText, - }); - const idx = output.content.length - 1; - stream.push({ - type: "thinking_start", - contentIndex: idx, - partial: output, - }); - stream.push({ - type: "thinking_delta", - contentIndex: idx, - delta: thinkingText, - partial: output, - }); - stream.push({ - type: "thinking_end", - contentIndex: idx, - content: thinkingText, - partial: output, - }); - } - - stream.push({ - type: "done", - reason: output.stopReason, - message: output, - }); - stream.end(); - } catch (error: any) { - if (controller.signal.aborted) { - output.stopReason = "aborted"; - output.errorMessage = "Request aborted"; - } else { - output.stopReason = "error"; - output.errorMessage = error?.message ?? String(error); - } - stream.push({ - type: "error", - reason: output.stopReason, - error: output, - }); - stream.end(); - } finally { - try { - await reader?.cancel(); - } catch { - // Reader cancellation is best-effort; it may already be closed/cancelled. - } - try { - reader?.releaseLock(); - } catch { - // Reader may already be released/cancelled by the abort path. - } - } - })(); - - return stream; - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("streamCommandCode — missing API key", () => { - it("emits error when no API key is available", async () => { - // Pass explicit empty apiKey to force the missing-key error path. - // (getApiKey may find a valid key from auth.json on the filesystem.) - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "" }); +describe("streamCommandCode — auth", () => { + it("emits a missing-key error without touching the network", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl(), env: {}, authPaths: [] }); + const stream = streamCommandCode(makeModel(), makeContext(), { apiKey: "" }); const events = await collectEvents(stream); - assert.equal(events.length, 1, `Expected 1 error event, got ${events.length}: ${JSON.stringify(events.map((e: any) => e.type))}`); + assert.deepEqual(eventTypes(events), ["error"]); assert.equal(events[0].type, "error"); assert.equal(events[0].reason, "error"); - assert.ok( - events[0].error.errorMessage.includes("No Command Code API key"), - `Should mention missing API key, got: ${events[0].error.errorMessage}`, - ); + assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/); + assert.equal(server.requestCount(), 0); }); - it("falls back to auth.json when env var is unset (if auth.json exists)", async () => { - // On dev machines, auth.json may exist. We verify the code doesn't crash. - const saved = process.env.COMMANDCODE_API_KEY; - delete process.env.COMMANDCODE_API_KEY; + it("uses options.apiKey in the Authorization header", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl(), env: { COMMANDCODE_API_KEY: "env-key" } }); - try { - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx); - const events = await collectEvents(stream, { timeoutMs: 1000 }); - assert.ok(events.length > 0, "should get at least one event"); - } finally { - if (saved) process.env.COMMANDCODE_API_KEY = saved; - } - }); + await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" })); - it("does not crash when auth.json is malformed (env unset)", async () => { - const saved = process.env.COMMANDCODE_API_KEY; - delete process.env.COMMANDCODE_API_KEY; - try { - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx); - const events = await collectEvents(stream, { timeoutMs: 1000 }); - assert.ok(events.length > 0); - } finally { - if (saved) process.env.COMMANDCODE_API_KEY = saved; - } + assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key"); }); }); -describe("streamCommandCode — simple text response", () => { - it("emits start → text_start → text_delta → text_end → done", async () => { - mockResponse({ +describe("streamCommandCode — successful streams", () => { + it("emits start → text events → done and accumulates usage", async () => { + server.mockResponse({ type: "success", - status: 200, events: [ JSON.stringify({ type: "text-delta", text: "Hel" }), - JSON.stringify({ type: "text-delta", text: "lo!" }), + JSON.stringify({ type: "text-delta", text: "lo" }), JSON.stringify({ type: "finish", finishReason: "stop", - totalUsage: { inputTokens: 5, outputTokens: 3 }, + totalUsage: { + inputTokens: 5, + outputTokens: 2, + inputTokenDetails: { cacheReadTokens: 3, cacheWriteTokens: 1 }, + }, }), ], }); + const { streamCommandCode, calculatedUsages } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { - apiKey: "mock-key", - }); - const events = await collectEvents(stream); + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); - // Verify event types in order - const types = events.map((e: any) => e.type); - assert.deepEqual(types.slice(0, 6), [ - "start", - "text_start", - "text_delta", - "text_delta", - "text_end", - "done", - ]); - - // Verify done message - const done = events.find((e: any) => e.type === "done"); + assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_delta", "text_end", "done"]); + const done = events.at(-1); + assert.equal(done?.type, "done"); + if (done?.type !== "done") throw new Error("expected done"); assert.equal(done.reason, "stop"); - assert.equal(done.message.content[0].text, "Hello!"); - assert.equal(done.message.usage.input, 5); - assert.equal(done.message.usage.output, 3); + assert.equal(done.message.content[0]?.type, "text"); + assert.equal(done.message.content[0]?.type === "text" ? done.message.content[0].text : "", "Hello"); + assert.equal(done.message.usage.totalTokens, 11); + assert.equal(calculatedUsages.length, 1); }); - it("builds consecutive text-delta events into one text block", async () => { - mockResponse({ + it("ends on finish without waiting for an open upstream connection", async () => { + server.mockResponse({ type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "a" }), - JSON.stringify({ type: "text-delta", text: "b" }), - JSON.stringify({ type: "text-delta", text: "c" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 3 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.message.content[0].text, "abc"); - assert.equal(done.message.content.length, 1); - }); - - it("ends on finish even if the upstream connection stays open", async () => { - mockResponse({ - type: "success", - status: 200, events: [ JSON.stringify({ type: "text-delta", text: "done" }), JSON.stringify({ type: "finish", finishReason: "stop" }), ], hangAfterLast: true, }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream, { timeoutMs: 500 }); + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), 500); - const done = events.find((e: any) => e.type === "done"); - assert.ok(done, "should emit done without waiting for connection close"); - assert.equal(done.message.content[0].text, "done"); + assert.equal(events.at(-1)?.type, "done"); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body"); }); -}); -describe("streamCommandCode — reasoning/thinking", () => { - it("buffers reasoning-delta and emits on reasoning-end", async () => { - mockResponse({ + it("emits reasoning and tool-call blocks in order", async () => { + server.mockResponse({ type: "success", - status: 200, events: [ - JSON.stringify({ type: "reasoning-delta", text: "Let me " }), - JSON.stringify({ type: "reasoning-delta", text: "think..." }), + JSON.stringify({ type: "reasoning-delta", text: "think" }), JSON.stringify({ type: "reasoning-end" }), - JSON.stringify({ type: "text-delta", text: "Answer" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 5, outputTokens: 5 }, - }), + JSON.stringify({ type: "text-delta", text: "Using tool" }), + JSON.stringify({ type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "/tmp/x" } }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), ], }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl(), reasoning: true }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); - // Should have thinking_start, thinking_delta, thinking_end - const thinkingStart = events.find((e: any) => e.type === "thinking_start"); - assert.ok(thinkingStart, "should have thinking_start event"); - - const thinkingEnd = events.find((e: any) => e.type === "thinking_end"); - assert.equal(thinkingEnd.content, "Let me think..."); - - // Content should have thinking block - const done = events.find((e: any) => e.type === "done"); - const thinkingContent = done.message.content.find( - (c: any) => c.type === "thinking", - ); - assert.ok(thinkingContent, "should have thinking content"); - assert.equal(thinkingContent.thinking, "Let me think..."); - }); - - it("handles reasoning without text follow-up", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "reasoning-delta", text: "Hmm" }), - JSON.stringify({ type: "reasoning-end" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 2, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl(), reasoning: true }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.message.content.length, 1); - assert.equal(done.message.content[0].type, "thinking"); - }); -}); - -describe("streamCommandCode — tool calls", () => { - it("emits toolcall_start and toolcall_end for tool-call event", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ - type: "tool-call", - toolCallId: "call_abc", - toolName: "read_file", - input: { path: "/tmp/x" }, - }), - JSON.stringify({ - type: "finish", - finishReason: "tool-calls", - totalUsage: { inputTokens: 4, outputTokens: 6 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext({ - tools: [ - { - name: "read_file", - description: "Read a file", - parameters: { - kind: "object", - properties: { path: { kind: "string" } }, - }, - }, - ], - }); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const tcStart = events.find((e: any) => e.type === "toolcall_start"); - assert.ok(tcStart, "should have toolcall_start"); - - const tcEnd = events.find((e: any) => e.type === "toolcall_end"); - assert.equal(tcEnd.toolCall.name, "read_file"); - assert.deepEqual(tcEnd.toolCall.arguments, { path: "/tmp/x" }); - - // Stop reason should be toolUse - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.reason, "toolUse"); - }); - - it("handles text followed by tool-call (ends text block first)", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "Let me read" }), - JSON.stringify({ - type: "tool-call", - toolCallId: "c1", - toolName: "ls", - input: {}, - }), - JSON.stringify({ - type: "finish", - finishReason: "tool-calls", - totalUsage: { inputTokens: 3, outputTokens: 8 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - // Should have text_end before toolcall_start - const textEndIdx = events.findIndex((e: any) => e.type === "text_end"); - const tcStartIdx = events.findIndex( - (e: any) => e.type === "toolcall_start", - ); - assert.ok(textEndIdx < tcStartIdx, "text_end should come before toolcall_start"); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.message.content.length, 2); - assert.equal(done.message.content[0].type, "text"); - assert.equal(done.message.content[1].type, "toolCall"); - }); - - it("handles multiple consecutive tool calls", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ - type: "tool-call", - toolCallId: "c1", - toolName: "read", - input: { path: "/a" }, - }), - JSON.stringify({ - type: "tool-call", - toolCallId: "c2", - toolName: "read", - input: { path: "/b" }, - }), - JSON.stringify({ - type: "finish", - finishReason: "tool-calls", - totalUsage: { inputTokens: 5, outputTokens: 10 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.message.content.length, 2); - assert.equal(done.message.content[0].name, "read"); - assert.equal(done.message.content[1].name, "read"); - assert.notEqual(done.message.content[0].id, done.message.content[1].id); - }); -}); - -describe("streamCommandCode — HTTP error responses", () => { - it("handles 401 Unauthorized", async () => { - mockResponse({ - type: "error", - status: 401, - body: "Unauthorized", - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - assert.equal(events[0].type, "start"); - const err = events.find((e: any) => e.type === "error"); - assert.ok(err, "should have error event"); - assert.ok( - err.error.errorMessage.includes("401"), - `should include status code, got: ${err.error.errorMessage}`, - ); - assert.equal(err.error.stopReason, "error"); - }); - - it("handles 500 Internal Server Error", async () => { - mockResponse({ - type: "error", - status: 500, - body: "Internal Server Error", - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const err = events.find((e: any) => e.type === "error"); - assert.ok(err, "should have error event"); - assert.ok(err.error.errorMessage.includes("500")); - }); - - it("handles 429 Too Many Requests", async () => { - mockResponse({ - type: "error", - status: 429, - body: JSON.stringify({ error: "Rate limited" }), - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const err = events.find((e: any) => e.type === "error"); - assert.ok(err, "should have error event"); - assert.ok(err.error.errorMessage.includes("429")); - }); -}); - -describe("streamCommandCode — stream error events", () => { - it("handles error event within the stream", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "partially" }), - JSON.stringify({ type: "error", error: { message: "Something went wrong" } }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const err = events.find((e: any) => e.type === "error"); - assert.ok(err, "should have error event"); - assert.equal(err.error.stopReason, "error"); - assert.ok( - err.error.errorMessage.includes("Something went wrong"), - `should include error message, got: ${err.error.errorMessage}`, - ); - }); - - it("handles error event without message field", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "error", error: "bare string error" }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const err = events.find((e: any) => e.type === "error"); - assert.ok(err, "should have error event"); - }); -}); - -describe("streamCommandCode — usage parsing", () => { - it("parses inputTokens and outputTokens correctly", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "ok" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { - inputTokens: 150, - outputTokens: 42, - inputTokenDetails: { - cacheReadTokens: 30, - cacheWriteTokens: 10, - }, - }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.message.usage.input, 150); - assert.equal(done.message.usage.output, 42); - assert.equal(done.message.usage.cacheRead, 30); - assert.equal(done.message.usage.cacheWrite, 10); - assert.equal(done.message.usage.totalTokens, 232); // 150 + 42 + 30 + 10 - }); - - it("handles missing usage gracefully", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.message.usage.input, 0); - assert.equal(done.message.usage.output, 0); - assert.equal(done.message.stopReason, "stop"); - }); -}); - -describe("streamCommandCode — HTTP request body", () => { - it("sends the correct request structure to CC API", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - await collectEvents(stream); - - const body = lastRequestBody(); - assert.ok(body, "request body should be captured"); - assert.equal(body.params.model, "test-model"); - assert.equal(body.params.stream, true); - assert.equal(body.params.system, "You are a test assistant."); - assert.deepEqual(body.params.messages, [ - { role: "user", content: "hello" }, + assert.deepEqual(eventTypes(events), [ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_end", + "done", ]); - assert.deepEqual(body.params.tools, []); - assert.equal(body.permissionMode, "standard"); - assert.ok(body.config, "should have config section"); - assert.ok(typeof body.config.date === "string", "date should be a string"); - assert.equal(body.config.isGitRepo, false); + const done = events.at(-1); + if (done?.type !== "done") throw new Error("expected done"); + assert.equal(done.reason, "toolUse"); + assert.deepEqual(done.message.content.map((content) => content.type), ["thinking", "text", "toolCall"]); + const toolCall = done.message.content[2]; + assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file"); }); - it("includes tools in request when context has tools", async () => { - mockResponse({ + it("flushes reasoning if finish arrives without reasoning-end", async () => { + server.mockResponse({ type: "success", - status: 200, events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), + JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }), + JSON.stringify({ type: "finish", finishReason: "stop" }), ], }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext({ + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + + const done = events.at(-1); + if (done?.type !== "done") throw new Error("expected done"); + assert.equal(done.message.content[0]?.type, "thinking"); + }); +}); + +describe("streamCommandCode — request serialization", () => { + it("sends the expected request body and default headers", async () => { + server.mockResponse({ type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })] }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); + const context = makeContext({ + messages: [ + { role: "user", content: "first" }, + { role: "assistant", content: [{ type: "text", text: "first response" }] }, + { role: "user", content: "second" }, + ], tools: [ { name: "get_weather", description: "Get weather", - parameters: { - kind: "object", - properties: { city: { kind: "string" } }, - }, + parameters: { kind: "object", properties: { city: { kind: "string" } } }, }, ], }); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - await collectEvents(stream); - const body = lastRequestBody(); - assert.equal(body.params.tools.length, 1); - assert.equal(body.params.tools[0].name, "get_weather"); - assert.equal(body.params.tools[0].type, "function"); + await collectEvents(streamCommandCode(makeModel(), context, { apiKey: "mock-key", maxTokens: 500 })); + + const body = server.lastRequestBody(); + assert.equal(objectAt(body, ["config", "workingDir"]), "/repo"); + assert.equal(objectAt(body, ["config", "date"]), "2026-05-05"); + assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash"); + assert.equal(objectAt(body, ["params", "stream"]), true); + assert.equal(objectAt(body, ["params", "max_tokens"]), 500); + assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant."); + assert.equal(objectAt(body, ["params", "messages", "1", "content", "0", "text"]), "first response"); + assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather"); + + const headers = server.lastRequestHeaders(); + assert.equal(headers.authorization, "Bearer mock-key"); + assert.equal(headers["x-command-code-version"], "0.24.1"); + assert.equal(headers["x-session-id"], "00000000-0000-4000-8000-000000000000"); }); - it("respects maxTokens option", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); + it("caps maxTokens and passes custom headers", async () => { + server.mockResponse({ type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })] }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl(), maxTokens: 4096 }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { - apiKey: "mock-key", - maxTokens: 500, - }); - await collectEvents(stream); - - const body = lastRequestBody(); - assert.equal(body.params.max_tokens, 500); - }); - - it("caps maxTokens at 200k", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl(), maxTokens: 500_000 }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { + await collectEvents(streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { apiKey: "mock-key", maxTokens: 500_000, - }); - await collectEvents(stream); + headers: { "x-custom": "value" }, + })); - const body = lastRequestBody(); - assert.equal(body.params.max_tokens, 200_000); + assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 200_000); + assert.equal(server.lastRequestHeaders()["x-custom"], "value"); }); -}); -describe("streamCommandCode — abort mid-stream", () => { - it("emits aborted error when signal fires during stream", async () => { - // The stream must hang (no finish and no connection close) so abort can - // interrupt reader.read(). We send one text-delta, but the mock server - // must keep the connection open after sending it. We do this by passing - // a special marker that tells the server to not end. - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "first" }), - // No finish event, and hangAfterLast keeps connection open - ], - hangAfterLast: true, - }); + it("runs onPayload and onResponse hooks", async () => { + server.mockResponse({ type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })] }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); + let responseStatus = 0; - const controller = new AbortController(); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { + await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key", - signal: controller.signal, - }); + onPayload: () => ({ replaced: true }), + onResponse: (response) => { + responseStatus = response.status; + }, + })); - // Give the stream a moment to start and process the first text-delta, - // then abort while reader.read() is blocking on more data. - await new Promise((r) => setTimeout(r, 50)); - controller.abort(); - - // Collect events with a timeout. The abort should cause the stream - // to emit an error event with stopReason "aborted". - const events = await collectEvents(stream, { timeoutMs: 3000 }); - const err = events.find((e: any) => e.type === "error"); - if (!err) { - console.error( - "[debug] All events:", - JSON.stringify(events.map((e: any) => ({ type: e.type, reason: e.reason }))), - ); - } - assert.ok(err, "should have error event"); - assert.equal(err.error.stopReason, "aborted"); - assert.equal(err.error.errorMessage, "Request aborted"); + assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true); + assert.equal(responseStatus, 200); }); }); -describe("streamCommandCode — options.apiKey override", () => { - it("uses options.apiKey over env var", async () => { - const saved = process.env.COMMANDCODE_API_KEY; - process.env.COMMANDCODE_API_KEY = "env-key"; +describe("streamCommandCode — upstream errors and malformed streams", () => { + it("emits error for HTTP failures", async () => { + server.mockResponse({ type: "error", status: 429, body: "rate limited" }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - try { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { - apiKey: "options-key", - }); - await collectEvents(stream); - - // Should still work (options key takes priority) - assert.equal(requestCount(), 1); - } finally { - if (saved) process.env.COMMANDCODE_API_KEY = saved; - else delete process.env.COMMANDCODE_API_KEY; - } - }); -}); - -describe("streamCommandCode — empty response", () => { - it("ends successfully with done event on empty stream", async () => { - mockResponse({ - type: "success", - status: 200, - events: [], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.ok(done, "should have done event"); - assert.equal(done.reason, "stop"); - assert.equal(done.message.content.length, 0); - }); -}); - -describe("streamCommandCode — malformed JSON in stream", () => { - it("skips non-JSON lines gracefully", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - "not valid json", - "", - JSON.stringify({ type: "text-delta", text: "ok" }), - "also not json {", - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.ok(done, "should complete despite malformed lines"); - assert.equal(done.message.content[0].text, "ok"); + assert.deepEqual(eventTypes(events), ["start", "error"]); + const error = events.at(-1); + assert.equal(error?.type, "error"); + if (error?.type !== "error") throw new Error("expected error"); + assert.match(error.error.errorMessage ?? "", /429/); }); - it("accepts SSE data lines", async () => { - mockResponse({ + it("emits error for provider error events", async () => { + server.mockResponse({ type: "success", - status: 200, - events: [ - `data: ${JSON.stringify({ type: "text-delta", text: "sse" })}`, - "event: ignored", - `data: ${JSON.stringify({ type: "finish", finishReason: "stop" })}`, - ], + events: [JSON.stringify({ type: "error", error: { message: "provider failed" } })], }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); - const done = events.find((e: any) => e.type === "done"); - assert.ok(done, "should complete from SSE data lines"); - assert.equal(done.message.content[0].text, "sse"); - }); -}); - -describe("streamCommandCode — conversation history", () => { - it("converts multi-turn conversation to CC format", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "answer" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 2, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext({ - messages: [ - { role: "user", content: "first", timestamp: 1 }, - { - role: "assistant", - content: [{ type: "text", text: "first response" }], - }, - { role: "user", content: "second", timestamp: 2 }, - { - role: "assistant", - content: [ - { - type: "toolCall", - id: "tc1", - name: "read", - arguments: { path: "/x" }, - }, - ], - }, - { - role: "toolResult", - toolCallId: "tc1", - toolName: "read", - isError: false, - content: [{ type: "text", text: "file contents" }], - }, - ], - }); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - await collectEvents(stream); - - const body = lastRequestBody(); - const msgs = body.params.messages; - assert.equal(msgs.length, 5); - assert.equal(msgs[0].role, "user"); - assert.equal(msgs[0].content, "first"); - assert.equal(msgs[1].role, "assistant"); - assert.equal(msgs[1].content[0].text, "first response"); - assert.equal(msgs[2].role, "user"); - assert.equal(msgs[2].content, "second"); - assert.equal(msgs[3].role, "assistant"); - assert.equal(msgs[3].content[0].type, "tool-call"); - assert.equal(msgs[4].role, "tool"); - assert.equal(msgs[4].content[0].type, "tool-result"); - }); -}); - -describe("streamCommandCode — custom headers", () => { - it("passes through custom headers from options", async () => { - // We can't easily inspect request headers with this test setup, - // but we can verify it doesn't crash with custom headers - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { - apiKey: "mock-key", - headers: { "x-custom": "value", "x-another": "test" }, - }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.ok(done); - }); -}); - -describe("streamCommandCode — stopReason mapping", () => { - it("maps finishReason 'stop' → 'stop'", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ - type: "finish", - finishReason: "stop", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.reason, "stop"); + const error = events.at(-1); + assert.equal(error?.type, "error"); + if (error?.type !== "error") throw new Error("expected error"); + assert.equal(error.error.errorMessage, "provider failed"); }); - it("maps finishReason 'tool-calls' → 'toolUse'", async () => { - mockResponse({ + 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({ type: "finish", finishReason: "max_tokens" }); + server.mockResponse({ type: "success", - status: 200, - events: [ - JSON.stringify({ - type: "tool-call", - toolCallId: "tc", - toolName: "ls", - input: {}, - }), - JSON.stringify({ - type: "finish", - finishReason: "tool-calls", - totalUsage: { inputTokens: 1, outputTokens: 1 }, - }), + chunks: [ + "not json\n", + textEvent.slice(0, 12), + textEvent.slice(12), + "event: ignored\n", + "data: [DONE]\n", + finishEvent, ], }); + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); + const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); - const done = events.find((e: any) => e.type === "done"); - assert.equal(done.reason, "toolUse"); - }); - - it("maps max-token finish reasons → 'length'", async () => { - mockResponse({ - type: "success", - status: 200, - events: [ - JSON.stringify({ type: "text-delta", text: "x" }), - JSON.stringify({ type: "finish", finishReason: "max_tokens" }), - ], - }); - - const model = makeModel({ baseUrl: baseUrl() }); - const ctx = makeContext(); - const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); - const events = await collectEvents(stream); - - const done = events.find((e: any) => e.type === "done"); + const done = events.at(-1); + if (done?.type !== "done") throw new Error("expected done"); assert.equal(done.reason, "length"); + assert.equal(done.message.content[0]?.type === "text" ? done.message.content[0].text : "", "split"); }); });