/** * 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 */ 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"; 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. 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(), ); }); // --------------------------------------------------------------------------- // 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())); }); beforeEach(() => { _requestCount = 0; _lastRequestBody = null; _nextPlan = { type: "success", status: 200, events: [] }; }); // --------------------------------------------------------------------------- // 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; } // --------------------------------------------------------------------------- // 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: "" }); 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.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}`, ); }); 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; 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; } }); 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; } }); }); describe("streamCommandCode — simple text response", () => { it("emits start → text_start → text_delta → text_end → done", async () => { mockResponse({ type: "success", status: 200, events: [ JSON.stringify({ type: "text-delta", text: "Hel" }), JSON.stringify({ type: "text-delta", text: "lo!" }), JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 5, outputTokens: 3 }, }), ], }); const model = makeModel({ baseUrl: baseUrl() }); const ctx = makeContext(); const stream = streamCommandCode(model, ctx, { apiKey: "mock-key", }); const events = await collectEvents(stream); // 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.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); }); it("builds consecutive text-delta events into one text block", async () => { 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 model = makeModel({ baseUrl: baseUrl() }); const ctx = makeContext(); const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); const events = await collectEvents(stream, { timeoutMs: 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"); }); }); describe("streamCommandCode — reasoning/thinking", () => { it("buffers reasoning-delta and emits on reasoning-end", async () => { 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-end" }), JSON.stringify({ type: "text-delta", text: "Answer" }), JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 5, outputTokens: 5 }, }), ], }); const model = makeModel({ baseUrl: baseUrl(), reasoning: true }); const ctx = makeContext(); const stream = streamCommandCode(model, ctx, { apiKey: "mock-key" }); const events = await collectEvents(stream); // 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(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); }); it("includes tools in request when context has tools", 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({ tools: [ { name: "get_weather", description: "Get weather", 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"); }); 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 }, }), ], }); 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, { apiKey: "mock-key", maxTokens: 500_000, }); await collectEvents(stream); const body = lastRequestBody(); assert.equal(body.params.max_tokens, 200_000); }); }); 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, }); const controller = new AbortController(); const model = makeModel({ baseUrl: baseUrl() }); const ctx = makeContext(); const stream = streamCommandCode(model, ctx, { apiKey: "mock-key", signal: controller.signal, }); // 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"); }); }); 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"; 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 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"); }); it("accepts SSE data lines", async () => { mockResponse({ type: "success", status: 200, events: [ `data: ${JSON.stringify({ type: "text-delta", text: "sse" })}`, "event: ignored", `data: ${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.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"); }); it("maps finishReason 'tool-calls' → 'toolUse'", async () => { 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 }, }), ], }); 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, "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"); assert.equal(done.reason, "length"); }); });