1726 lines
53 KiB
TypeScript
1726 lines
53 KiB
TypeScript
/**
|
|
* 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<void>((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<Record<string, any>> = {}) {
|
|
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<Record<string, any>> = {}) {
|
|
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<void>((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<any[]> {
|
|
const events: any[] = [];
|
|
const max = opts?.maxEvents ?? 1000;
|
|
|
|
// Create abort promise (resolves when signal fires)
|
|
const abortPromise = new Promise<void>((resolve) => {
|
|
opts?.signal?.addEventListener("abort", () => resolve(), { once: true });
|
|
});
|
|
|
|
// Create timeout promise (only if timeoutMs is set)
|
|
const timeoutPromise = opts?.timeoutMs
|
|
? new Promise<void>((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<string, any>;
|
|
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<string, any> = {};
|
|
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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
if (signal.aborted) {
|
|
return Promise.reject(
|
|
new DOMException("The operation was aborted", "AbortError"),
|
|
);
|
|
}
|
|
return new Promise<T>((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<Uint8Array> | undefined;
|
|
options?.signal?.addEventListener(
|
|
"abort",
|
|
() => controller.abort(),
|
|
{ once: true },
|
|
);
|
|
|
|
try {
|
|
stream.push({ type: "start", partial: output as any });
|
|
|
|
const ccHeaders: Record<string, string> = {
|
|
"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");
|
|
});
|
|
});
|