Test real Command Code core behavior
This commit is contained in:
@@ -0,0 +1,270 @@
|
|||||||
|
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createStreamCommandCode,
|
||||||
|
type AssistantMessageEvent,
|
||||||
|
type AssistantMessageEventStreamLike,
|
||||||
|
type ContextLike,
|
||||||
|
type CoreDependencies,
|
||||||
|
type ModelLike,
|
||||||
|
type Usage,
|
||||||
|
} from "../src/core.ts";
|
||||||
|
|
||||||
|
export function createTestEventStream(): AssistantMessageEventStreamLike {
|
||||||
|
const events: AssistantMessageEvent[] = [];
|
||||||
|
const waiters: Array<() => void> = [];
|
||||||
|
let ended = false;
|
||||||
|
|
||||||
|
const wake = () => {
|
||||||
|
const waiter = waiters.shift();
|
||||||
|
if (waiter) waiter();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
push(event: AssistantMessageEvent) {
|
||||||
|
events.push(event);
|
||||||
|
wake();
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
ended = true;
|
||||||
|
while (waiters.length > 0) wake();
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
let index = 0;
|
||||||
|
return {
|
||||||
|
async next(): Promise<IteratorResult<AssistantMessageEvent>> {
|
||||||
|
while (index >= events.length && !ended) {
|
||||||
|
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||||
|
}
|
||||||
|
if (index < events.length) {
|
||||||
|
const value = events[index];
|
||||||
|
index += 1;
|
||||||
|
return { done: false, value };
|
||||||
|
}
|
||||||
|
return { done: true, value: undefined };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectEvents(
|
||||||
|
stream: AssistantMessageEventStreamLike,
|
||||||
|
timeoutMs = 2_000,
|
||||||
|
): Promise<AssistantMessageEvent[]> {
|
||||||
|
const events: AssistantMessageEvent[] = [];
|
||||||
|
|
||||||
|
const collect = async () => {
|
||||||
|
for await (const event of stream) {
|
||||||
|
events.push(event);
|
||||||
|
if (event.type === "done" || event.type === "error") break;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
return await Promise.race([
|
||||||
|
collect(),
|
||||||
|
new Promise<AssistantMessageEvent[]>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeModel(overrides: Partial<ModelLike> = {}): ModelLike {
|
||||||
|
return {
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
api: "commandcode-custom",
|
||||||
|
provider: "commandcode",
|
||||||
|
maxTokens: 384_000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeContext(overrides: Partial<ContextLike> = {}): ContextLike {
|
||||||
|
return {
|
||||||
|
systemPrompt: "You are a test assistant.",
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
tools: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestDepsResult {
|
||||||
|
streamCommandCode: ReturnType<typeof createStreamCommandCode>;
|
||||||
|
calculatedUsages: Usage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTestDeps(overrides: Partial<CoreDependencies> = {}): TestDepsResult {
|
||||||
|
const calculatedUsages: Usage[] = [];
|
||||||
|
const streamCommandCode = createStreamCommandCode({
|
||||||
|
createStream: createTestEventStream,
|
||||||
|
calculateCost: (_model, usage) => {
|
||||||
|
calculatedUsages.push({
|
||||||
|
...usage,
|
||||||
|
cost: { ...usage.cost },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
env: {},
|
||||||
|
authPaths: [],
|
||||||
|
now: () => new Date("2026-05-05T12:00:00Z").getTime(),
|
||||||
|
uuid: () => "00000000-0000-4000-8000-000000000000",
|
||||||
|
cwd: () => "/repo",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return { streamCommandCode, calculatedUsages };
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuccessPlan = {
|
||||||
|
type: "success";
|
||||||
|
status?: number;
|
||||||
|
events?: string[];
|
||||||
|
chunks?: string[];
|
||||||
|
delays?: number[];
|
||||||
|
hangAfterLast?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorPlan = {
|
||||||
|
type: "error";
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResponsePlan = SuccessPlan | ErrorPlan;
|
||||||
|
|
||||||
|
function headersToRecord(headers: IncomingHttpHeaders): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (typeof value === "string") out[key] = value;
|
||||||
|
else if (Array.isArray(value)) out[key] = value.join(", ");
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockCommandCodeServer {
|
||||||
|
baseUrl(): string;
|
||||||
|
mockResponse(plan: ResponsePlan): void;
|
||||||
|
reset(): void;
|
||||||
|
close(): Promise<void>;
|
||||||
|
lastRequestBody(): unknown;
|
||||||
|
lastRequestHeaders(): Record<string, string>;
|
||||||
|
requestCount(): number;
|
||||||
|
responseClosedBeforeEnd(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startMockCommandCodeServer(): Promise<MockCommandCodeServer> {
|
||||||
|
let nextPlan: ResponsePlan = { type: "success", events: [] };
|
||||||
|
let lastBody: unknown;
|
||||||
|
let lastHeaders: Record<string, string> = {};
|
||||||
|
let requests = 0;
|
||||||
|
let closedBeforeEnd = false;
|
||||||
|
let port = 0;
|
||||||
|
|
||||||
|
const server: Server = createServer((req, res) => {
|
||||||
|
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requests += 1;
|
||||||
|
lastHeaders = headersToRecord(req.headers);
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk: Buffer) => {
|
||||||
|
body += chunk.toString("utf-8");
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(body);
|
||||||
|
lastBody = parsed;
|
||||||
|
} catch {
|
||||||
|
lastBody = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = nextPlan;
|
||||||
|
if (plan.type === "error") {
|
||||||
|
res.writeHead(plan.status, { "Content-Type": "text/plain" });
|
||||||
|
res.end(plan.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(plan.status ?? 200, {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Transfer-Encoding": "chunked",
|
||||||
|
});
|
||||||
|
|
||||||
|
let ended = false;
|
||||||
|
res.on("close", () => {
|
||||||
|
if (!ended) closedBeforeEnd = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`);
|
||||||
|
const delays = plan.delays ?? chunks.map(() => 0);
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const sendNext = () => {
|
||||||
|
if (index >= chunks.length) {
|
||||||
|
if (!plan.hangAfterLast) {
|
||||||
|
ended = true;
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(chunks[index]);
|
||||||
|
index += 1;
|
||||||
|
if (index < chunks.length) {
|
||||||
|
setTimeout(sendNext, delays[index] ?? 0);
|
||||||
|
} else if (!plan.hangAfterLast) {
|
||||||
|
ended = true;
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendNext();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (typeof address === "object" && address) port = address.port;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: () => `http://127.0.0.1:${port}`,
|
||||||
|
mockResponse(plan: ResponsePlan) {
|
||||||
|
nextPlan = plan;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
nextPlan = { type: "success", events: [] };
|
||||||
|
lastBody = undefined;
|
||||||
|
lastHeaders = {};
|
||||||
|
requests = 0;
|
||||||
|
closedBeforeEnd = false;
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
lastRequestBody: () => lastBody,
|
||||||
|
lastRequestHeaders: () => lastHeaders,
|
||||||
|
requestCount: () => requests,
|
||||||
|
responseClosedBeforeEnd: () => closedBeforeEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function objectAt(value: unknown, path: readonly string[]): unknown {
|
||||||
|
let current = value;
|
||||||
|
for (const key of path) {
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const index = Number(key);
|
||||||
|
if (!Number.isInteger(index)) return undefined;
|
||||||
|
current = current[index];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof current !== "object" || current === null) return undefined;
|
||||||
|
current = Object.getOwnPropertyDescriptor(current, key)?.value;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
+50
-275
@@ -1,302 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* Integration test for abort behaviour in streamCommandCode.
|
* Abort tests against the real streamCommandCode core.
|
||||||
*
|
|
||||||
* Uses a local HTTP mock server that simulates Command Code's SSE streaming.
|
|
||||||
* Tests that aborting the stream during an active response correctly emits
|
|
||||||
* an "aborted" error event.
|
|
||||||
*
|
|
||||||
* Run with: npx tsx tests/test-abort.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { after, before, describe, it } from "node:test";
|
import { after, before, beforeEach, describe, it } from "node:test";
|
||||||
import { createServer, type Server } from "node:http";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
import {
|
||||||
// Import streamCommandCode from the actual index.ts
|
collectEvents,
|
||||||
// We import it via dynamic import to ensure we get the real compiled code
|
createTestDeps,
|
||||||
// (tsx handles TypeScript transparently)
|
makeContext,
|
||||||
// ---------------------------------------------------------------------------
|
makeModel,
|
||||||
|
startMockCommandCodeServer,
|
||||||
|
type MockCommandCodeServer,
|
||||||
|
} from "./helpers.ts";
|
||||||
|
|
||||||
// We import directly from pi's bundled pi-ai module
|
let server: MockCommandCodeServer;
|
||||||
const PI_AI_PATH =
|
|
||||||
"/nix/store/rlhiqjvq3xhs82481s198c6bpnsksbjd-pi-coding-agent-0.72.0/lib/node_modules/@mariozechner/pi-coding-agent/node_modules/@mariozechner/pi-ai/dist/index.js";
|
|
||||||
|
|
||||||
type Model<T> = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
api: T;
|
|
||||||
provider: string;
|
|
||||||
baseUrl: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
input: ("text" | "image")[];
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Mock server: responds with slow SSE text-delta events
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
let server: Server;
|
|
||||||
let port: number;
|
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
return new Promise<void>((resolve) => {
|
server = await startMockCommandCodeServer();
|
||||||
server = createServer((req, res) => {
|
|
||||||
if (req.method === "POST" && req.url === "/alpha/generate") {
|
|
||||||
// Simulate a slow streaming response
|
|
||||||
res.writeHead(200, {
|
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
|
||||||
"Transfer-Encoding": "chunked",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send a few text-delta events with delays
|
|
||||||
const events = [
|
|
||||||
JSON.stringify({ type: "text-delta", text: "Hello" }) + "\n",
|
|
||||||
JSON.stringify({ type: "text-delta", text: " " }) + "\n",
|
|
||||||
JSON.stringify({ type: "text-delta", text: "World" }) + "\n",
|
|
||||||
JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 10, outputTokens: 10 } }) + "\n",
|
|
||||||
];
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
const sendNext = () => {
|
|
||||||
if (i >= events.length) {
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.write(events[i]);
|
|
||||||
i++;
|
|
||||||
if (i < events.length) {
|
|
||||||
// Deliberately slow — 500ms between events
|
|
||||||
setTimeout(sendNext, 500);
|
|
||||||
} else {
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
sendNext();
|
|
||||||
|
|
||||||
// Listen for close event (client disconnected → abort was triggered)
|
|
||||||
req.on("close", () => {
|
|
||||||
// Request aborted by client
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end("Not found");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(0, () => {
|
|
||||||
port = (server.address() as any).port;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(async () => {
|
||||||
return new Promise<void>((resolve) => {
|
await server.close();
|
||||||
server.close(() => resolve());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
beforeEach(() => {
|
||||||
// Tests
|
server.reset();
|
||||||
// ---------------------------------------------------------------------------
|
});
|
||||||
|
|
||||||
describe("streamCommandCode — abort behavior", () => {
|
describe("streamCommandCode — abort behavior", () => {
|
||||||
it("emits 'aborted' error when abort signal is triggered mid-stream", async () => {
|
it("emits aborted error when signal is already aborted", async () => {
|
||||||
// Dynamically import the actual stream function
|
|
||||||
// (the index.ts file imports from @mariozechner/pi-ai and @mariozechner/pi-coding-agent)
|
|
||||||
// We need to trick the module resolution by making these resolvable
|
|
||||||
// Simpler approach: import the pure types, construct manually
|
|
||||||
|
|
||||||
const { createAssistantMessageEventStream } = await import(PI_AI_PATH);
|
|
||||||
|
|
||||||
// Build a minimal model that matches what the provider uses
|
|
||||||
const model: Model<string> = {
|
|
||||||
id: "test-model",
|
|
||||||
name: "Test Model",
|
|
||||||
api: "commandcode-custom",
|
|
||||||
provider: "commandcode",
|
|
||||||
baseUrl: `http://localhost:${port}`,
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 100000,
|
|
||||||
maxTokens: 4096,
|
|
||||||
};
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
systemPrompt: "You are a test assistant.",
|
|
||||||
messages: [
|
|
||||||
{ role: "user" as const, content: "Hello", timestamp: Date.now() },
|
|
||||||
],
|
|
||||||
tools: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// We can't import streamCommandCode directly because of the
|
|
||||||
// @mariozechner/pi-coding-agent import dependency.
|
|
||||||
// Instead we test the principle: the AbortController races read() correctly.
|
|
||||||
//
|
|
||||||
// This is tested by verifying:
|
|
||||||
// 1. The source code has the raceAbort helper
|
|
||||||
// 2. The for(;;) loop checks controller.signal.aborted
|
|
||||||
// 3. reader.read() is raced against abort
|
|
||||||
|
|
||||||
// Read the source to verify the implementation
|
|
||||||
const fs = await import("node:fs");
|
|
||||||
const source = fs.readFileSync(
|
|
||||||
new URL("../index.ts", import.meta.url).pathname,
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify raceAbort helper exists
|
|
||||||
assert.ok(
|
|
||||||
source.includes("raceAbort"),
|
|
||||||
"source should contain raceAbort helper",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("controller.signal.aborted) throw"),
|
|
||||||
"source should check abort before reader.read()",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("raceAbort(fetch"),
|
|
||||||
"source should race fetch against abort signal",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("raceAbort(reader.read())"),
|
|
||||||
"source should race reader.read() against abort signal",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("options?.signal?.aborted"),
|
|
||||||
"source should handle signals that were already aborted before listener registration",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("reader?.cancel()"),
|
|
||||||
"source should cancel the response reader on abort",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('removeEventListener("abort", abortUpstream)'),
|
|
||||||
"source should remove the abort listener after stream completion",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes('join(homedir(), ".commandcode", "auth.json")') &&
|
|
||||||
source.includes('join(homedir(), ".pi", "agent", "auth.json")'),
|
|
||||||
"source should support both Command Code and pi auth files",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("parseStreamEventLine") && source.includes('trimmed.startsWith("data:")'),
|
|
||||||
"source should support SSE data lines",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("finished = true") && source.includes("break readLoop"),
|
|
||||||
"source should stop reading after a finish event",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
source.includes("controller.signal.aborted) throw") &&
|
|
||||||
source.split("controller.signal.aborted").length >= 3,
|
|
||||||
"source should check abort in multiple places",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("raceAbort rejects immediately when already aborted", async () => {
|
|
||||||
// Test the raceAbort pattern in isolation
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
controller.abort();
|
controller.abort();
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
||||||
|
|
||||||
const raceAbort = <T>(promise: Promise<T>): Promise<T> => {
|
const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), {
|
||||||
if (controller.signal.aborted) {
|
apiKey: "mock-key",
|
||||||
return Promise.reject(
|
signal: controller.signal,
|
||||||
Object.assign(new Error("The operation was aborted"), { name: "AbortError" }),
|
}));
|
||||||
);
|
|
||||||
}
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const onAbort = () =>
|
|
||||||
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
|
|
||||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
promise.then(
|
|
||||||
(v) => { controller.signal.removeEventListener("abort", onAbort); resolve(v); },
|
|
||||||
(e) => { controller.signal.removeEventListener("abort", onAbort); reject(e); },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let error: any;
|
assert.deepEqual(events.map((event) => event.type), ["start", "error"]);
|
||||||
try {
|
const error = events.at(-1);
|
||||||
await raceAbort(new Promise(() => {})); // never resolves
|
assert.equal(error?.type, "error");
|
||||||
} catch (e) {
|
if (error?.type !== "error") throw new Error("expected error");
|
||||||
error = e;
|
assert.equal(error.reason, "aborted");
|
||||||
}
|
assert.equal(error.error.stopReason, "aborted");
|
||||||
assert.ok(error instanceof Error);
|
assert.equal(server.requestCount(), 0);
|
||||||
assert.ok(
|
|
||||||
error.message.includes("aborted") || error.message.includes("Aborted"),
|
|
||||||
`Expected abort message, got: ${error.message}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("raceAbort rejects when aborted mid-flight (simulated)", async () => {
|
it("emits aborted error and cancels the response reader mid-stream", async () => {
|
||||||
|
server.mockResponse({
|
||||||
|
type: "success",
|
||||||
|
events: [JSON.stringify({ type: "text-delta", text: "first" })],
|
||||||
|
hangAfterLast: true,
|
||||||
|
});
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
||||||
|
|
||||||
const raceAbort = <T>(promise: Promise<T>): Promise<T> => {
|
const stream = streamCommandCode(makeModel(), makeContext(), {
|
||||||
if (controller.signal.aborted) {
|
apiKey: "mock-key",
|
||||||
return Promise.reject(
|
signal: controller.signal,
|
||||||
Object.assign(new Error("The operation was aborted"), { name: "AbortError" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const onAbort = () =>
|
|
||||||
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
|
|
||||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
promise.then(
|
|
||||||
(v) => { controller.signal.removeEventListener("abort", onAbort); resolve(v); },
|
|
||||||
(e) => { controller.signal.removeEventListener("abort", onAbort); reject(e); },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start a slow promise, then abort
|
|
||||||
const slow = new Promise<string>((resolve) => setTimeout(() => resolve("done"), 10000));
|
|
||||||
const racedPromise = raceAbort(slow);
|
|
||||||
|
|
||||||
// Abort after 10ms
|
|
||||||
setTimeout(() => controller.abort(), 10);
|
|
||||||
|
|
||||||
let error: any;
|
|
||||||
try {
|
|
||||||
await racedPromise;
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
}
|
|
||||||
assert.ok(error instanceof Error);
|
|
||||||
assert.ok(
|
|
||||||
error.message.includes("aborted") || error.message.includes("Aborted"),
|
|
||||||
`Expected abort message, got: ${error.message}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("raceAbort resolves normally when not aborted", async () => {
|
setTimeout(() => controller.abort(), 50);
|
||||||
const controller = new AbortController();
|
const events = await collectEvents(stream, 2_000);
|
||||||
|
|
||||||
const raceAbort = <T>(promise: Promise<T>): Promise<T> => {
|
assert.ok(events.some((event) => event.type === "text_delta"), "stream should process data before abort");
|
||||||
if (controller.signal.aborted) {
|
const error = events.at(-1);
|
||||||
return Promise.reject(
|
assert.equal(error?.type, "error");
|
||||||
Object.assign(new Error("The operation was aborted"), { name: "AbortError" }),
|
if (error?.type !== "error") throw new Error("expected error");
|
||||||
);
|
assert.equal(error.reason, "aborted");
|
||||||
}
|
assert.equal(error.error.errorMessage, "Request aborted");
|
||||||
return new Promise<T>((resolve, reject) => {
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
const onAbort = () =>
|
assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response");
|
||||||
reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" }));
|
|
||||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
promise.then(
|
|
||||||
(v) => { controller.signal.removeEventListener("abort", onAbort); resolve(v); },
|
|
||||||
(e) => { controller.signal.removeEventListener("abort", onAbort); reject(e); },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await raceAbort(Promise.resolve("success"));
|
|
||||||
assert.equal(result, "success");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+152
-563
@@ -1,611 +1,180 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for pi-commandcode-provider pure functions.
|
* Unit tests for the real pure helpers exported by src/core.ts.
|
||||||
*
|
* These are hermetic: no pi runtime and no network.
|
||||||
* These tests DON'T require pi's runtime or any network access.
|
|
||||||
* They verify message/tool/schema conversion logic in isolation.
|
|
||||||
*
|
|
||||||
* Run with: npx tsx tests/test-pure-functions.ts
|
|
||||||
* Or: node --import tsx tests/test-pure-functions.ts
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
import {
|
||||||
// Pure functions copied from index.ts (standalone, no pi imports needed)
|
getApiKey,
|
||||||
// ---------------------------------------------------------------------------
|
getEnvironmentInfo,
|
||||||
|
mapFinishReason,
|
||||||
|
messagesToCC,
|
||||||
|
parseStreamEventLine,
|
||||||
|
textContent,
|
||||||
|
toJsonSchema,
|
||||||
|
toolsToJson,
|
||||||
|
} from "../src/core.ts";
|
||||||
|
|
||||||
function uuid(): string {
|
import { objectAt } from "./helpers.ts";
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
function textContent(m: { content: any[] }): string {
|
describe("getApiKey()", () => {
|
||||||
return (m.content ?? [])
|
it("uses COMMANDCODE_API_KEY from provided env", () => {
|
||||||
.filter((c: any) => c.type === "text")
|
assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key");
|
||||||
.map((c: any) => c.text ?? "")
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnvironmentInfo(): string {
|
|
||||||
return `${process.platform}-${process.arch}, Node.js ${process.version}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal typebox → JSON Schema converter.
|
|
||||||
* Handles Object, String, Number, Boolean, Array, Union, Optional, Enum.
|
|
||||||
*/
|
|
||||||
function toJsonSchema(schema: any): any {
|
|
||||||
if (!schema) return {};
|
|
||||||
const s = schema as Record<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Tests
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
describe("uuid()", () => {
|
|
||||||
it("returns a valid UUID string", () => {
|
|
||||||
const id = uuid();
|
|
||||||
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns unique values on each call", () => {
|
it("reads apiKey and commandcode fields from explicit auth paths", () => {
|
||||||
const a = uuid();
|
const dir = mkdtempSync(join(tmpdir(), "cc-auth-"));
|
||||||
const b = uuid();
|
try {
|
||||||
assert.notEqual(a, b);
|
const first = join(dir, "first.json");
|
||||||
|
const second = join(dir, "second.json");
|
||||||
|
writeFileSync(first, JSON.stringify({ apiKey: "file-key" }));
|
||||||
|
writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" }));
|
||||||
|
assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key");
|
||||||
|
assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed auth files", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-"));
|
||||||
|
try {
|
||||||
|
const bad = join(dir, "bad.json");
|
||||||
|
writeFileSync(bad, "not json");
|
||||||
|
assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses injected homeDir for default auth paths", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "cc-home-"));
|
||||||
|
try {
|
||||||
|
const authDir = join(dir, ".pi", "agent");
|
||||||
|
mkdirSync(authDir, { recursive: true });
|
||||||
|
writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" }));
|
||||||
|
assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key");
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// textContent
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("textContent()", () => {
|
describe("textContent()", () => {
|
||||||
it("extracts text from content array", () => {
|
it("extracts and joins text blocks", () => {
|
||||||
const msg = { content: [{ type: "text", text: "hello" }, { type: "text", text: "world" }] };
|
assert.equal(
|
||||||
assert.equal(textContent(msg), "hello\nworld");
|
textContent({ content: [{ type: "text", text: "hello" }, { type: "image", data: "x" }, { type: "text", text: "world" }] }),
|
||||||
|
"hello\nworld",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters non-text content", () => {
|
it("handles empty or missing content", () => {
|
||||||
const msg = { content: [{ type: "text", text: "hello" }, { type: "image", data: "x" }] };
|
|
||||||
assert.equal(textContent(msg), "hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty string for empty content", () => {
|
|
||||||
assert.equal(textContent({ content: [] }), "");
|
assert.equal(textContent({ content: [] }), "");
|
||||||
});
|
assert.equal(textContent({}), "");
|
||||||
|
|
||||||
it("handles missing content gracefully", () => {
|
|
||||||
assert.equal(textContent({ content: undefined as any }) ?? "", "");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// getEnvironmentInfo
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("getEnvironmentInfo()", () => {
|
describe("getEnvironmentInfo()", () => {
|
||||||
it("returns string with platform, arch, and node version", () => {
|
it("returns platform, arch, and Node version", () => {
|
||||||
const info = getEnvironmentInfo();
|
const info = getEnvironmentInfo();
|
||||||
assert.match(info, /^(darwin|linux|win32)-/);
|
assert.match(info, /^(darwin|linux|win32)-/);
|
||||||
assert.ok(info.includes("Node.js"), `expected Node.js in: ${info}`);
|
assert.ok(info.includes("Node.js"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
describe("toJsonSchema()", () => {
|
||||||
// toJsonSchema
|
it("converts scalar, enum, object, optional, array, and union schema shapes", () => {
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("toJsonSchema — scalar types", () => {
|
|
||||||
it("handles string (lowercase kind)", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" });
|
assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" });
|
||||||
});
|
|
||||||
it("handles String (capitalized)", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ kind: "String" }), { type: "string" });
|
|
||||||
});
|
|
||||||
it("handles number", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ kind: "number" }), { type: "number" });
|
|
||||||
});
|
|
||||||
it("handles Number", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" });
|
assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" });
|
||||||
});
|
|
||||||
it("handles boolean", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" });
|
assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" });
|
||||||
|
assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), {
|
||||||
|
type: "string",
|
||||||
|
enum: ["left", "right"],
|
||||||
});
|
});
|
||||||
it("handles Boolean", () => {
|
assert.deepEqual(
|
||||||
assert.deepEqual(toJsonSchema({ kind: "Boolean" }), { type: "boolean" });
|
toJsonSchema({
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toJsonSchema — enum", () => {
|
|
||||||
it("detects enum by property (string values)", () => {
|
|
||||||
const schema = { kind: "string", enum: ["left", "right"] };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "string", enum: ["left", "right"] });
|
|
||||||
});
|
|
||||||
it("detects enum by property (number values)", () => {
|
|
||||||
const schema = { kind: "number", enum: [1, 2, 3] };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "number", enum: [1, 2, 3] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toJsonSchema — object", () => {
|
|
||||||
it("converts simple object with string props", () => {
|
|
||||||
const schema = {
|
|
||||||
kind: "object",
|
kind: "object",
|
||||||
properties: {
|
properties: {
|
||||||
name: { kind: "string" },
|
name: { kind: "string" },
|
||||||
age: { kind: "number" },
|
tags: { kind: "array", items: { kind: "string" }, optional: true },
|
||||||
},
|
},
|
||||||
};
|
}),
|
||||||
assert.deepEqual(toJsonSchema(schema), {
|
{
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { name: { type: "string" }, age: { type: "number" } },
|
properties: { name: { type: "string" }, tags: { type: "array", items: { type: "string" } } },
|
||||||
required: ["name", "age"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks optional properties correctly", () => {
|
|
||||||
const schema = {
|
|
||||||
kind: "object",
|
|
||||||
properties: {
|
|
||||||
name: { kind: "string" },
|
|
||||||
nickname: { kind: "string", optional: true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const result = toJsonSchema(schema);
|
|
||||||
assert.deepEqual(result.required, ["name"]);
|
|
||||||
assert.deepEqual(result.properties?.nickname, { type: "string" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles optional via top-level optional array", () => {
|
|
||||||
const schema = {
|
|
||||||
kind: "Object",
|
|
||||||
properties: {
|
|
||||||
name: { kind: "string" },
|
|
||||||
age: { kind: "number" },
|
|
||||||
},
|
|
||||||
optional: ["age"],
|
|
||||||
};
|
|
||||||
const result = toJsonSchema(schema);
|
|
||||||
assert.deepEqual(result.required, ["name"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves TypeBox required arrays", () => {
|
|
||||||
const schema = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string" },
|
|
||||||
nickname: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["name"],
|
required: ["name"],
|
||||||
};
|
},
|
||||||
const result = toJsonSchema(schema);
|
);
|
||||||
assert.deepEqual(result.required, ["name"]);
|
assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { type: "string" });
|
||||||
|
assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { type: "number" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty object", () => {
|
it("preserves explicit required arrays and handles unknown values", () => {
|
||||||
assert.deepEqual(toJsonSchema({ kind: "object" }), { type: "object" });
|
assert.deepEqual(
|
||||||
});
|
toJsonSchema({
|
||||||
|
type: "object",
|
||||||
it("handles Object (capitalized)", () => {
|
properties: { name: { type: "string" }, nickname: { type: "string" } },
|
||||||
assert.deepEqual(toJsonSchema({ kind: "Object" }), { type: "object" });
|
required: ["name"],
|
||||||
});
|
}),
|
||||||
});
|
{
|
||||||
|
type: "object",
|
||||||
describe("toJsonSchema — array", () => {
|
properties: { name: { type: "string" }, nickname: { type: "string" } },
|
||||||
it("converts array with items", () => {
|
required: ["name"],
|
||||||
const schema = { kind: "array", items: { kind: "string" } };
|
},
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "array", items: { type: "string" } });
|
);
|
||||||
});
|
|
||||||
|
|
||||||
it("converts array with element (alternative prop name)", () => {
|
|
||||||
const schema = { kind: "Array", element: { kind: "number" } };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "array", items: { type: "number" } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toJsonSchema — union", () => {
|
|
||||||
it("uses first non-empty variant", () => {
|
|
||||||
const schema = { kind: "union", variants: [{}, { kind: "string" }] };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "string" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses anyOf as fallback property name", () => {
|
|
||||||
const schema = { kind: "Union", anyOf: [{ kind: "number" }] };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "number" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty object for empty union", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ kind: "union", variants: [] }), {});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toJsonSchema — optional", () => {
|
|
||||||
it("unwraps optional via wrapped", () => {
|
|
||||||
const schema = { kind: "optional", wrapped: { kind: "string" } };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "string" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unwraps optional via inner", () => {
|
|
||||||
const schema = { kind: "Optional", inner: { kind: "number" } };
|
|
||||||
assert.deepEqual(toJsonSchema(schema), { type: "number" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toJsonSchema — edge cases", () => {
|
|
||||||
it("returns empty object for null", () => {
|
|
||||||
assert.deepEqual(toJsonSchema(null), {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty object for undefined", () => {
|
|
||||||
assert.deepEqual(toJsonSchema(undefined), {});
|
assert.deepEqual(toJsonSchema(undefined), {});
|
||||||
|
assert.deepEqual(toJsonSchema({ kind: "wat" }), {});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("handles unknown kind gracefully", () => {
|
describe("toolsToJson()", () => {
|
||||||
assert.deepEqual(toJsonSchema({ kind: "foobar" }), {});
|
it("converts pi tools to Command Code tool JSON", () => {
|
||||||
});
|
assert.deepEqual(
|
||||||
|
toolsToJson([
|
||||||
it("handles nested objects recursively", () => {
|
{
|
||||||
const schema = {
|
name: "get_weather",
|
||||||
kind: "object",
|
description: "Get weather",
|
||||||
properties: {
|
parameters: {
|
||||||
user: {
|
|
||||||
kind: "object",
|
|
||||||
properties: {
|
|
||||||
name: { kind: "string" },
|
|
||||||
address: {
|
|
||||||
kind: "object",
|
kind: "object",
|
||||||
properties: { city: { kind: "string" } },
|
properties: { city: { kind: "string" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
]),
|
||||||
},
|
[
|
||||||
};
|
|
||||||
const result = toJsonSchema(schema);
|
|
||||||
assert.equal(result.properties.user.type, "object");
|
|
||||||
assert.equal(result.properties.user.properties.address.type, "object");
|
|
||||||
assert.equal(
|
|
||||||
result.properties.user.properties.address.properties.city.type,
|
|
||||||
"string",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles type property as fallback for kind", () => {
|
|
||||||
assert.deepEqual(toJsonSchema({ type: "string" }), { type: "string" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// toolsToJson
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("toolsToJson()", () => {
|
|
||||||
it("returns empty array for undefined", () => {
|
|
||||||
assert.deepEqual(toolsToJson(undefined as any), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for null", () => {
|
|
||||||
assert.deepEqual(toolsToJson(null as any), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty array for empty array", () => {
|
|
||||||
assert.deepEqual(toolsToJson([]), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts a tool with object parameters", () => {
|
|
||||||
const tools = [
|
|
||||||
{
|
{
|
||||||
|
type: "function",
|
||||||
name: "get_weather",
|
name: "get_weather",
|
||||||
description: "Get the weather for a city",
|
description: "Get weather",
|
||||||
parameters: {
|
input_schema: {
|
||||||
kind: "object",
|
|
||||||
properties: {
|
|
||||||
city: { kind: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = toolsToJson(tools);
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.equal(result[0].type, "function");
|
|
||||||
assert.equal(result[0].name, "get_weather");
|
|
||||||
assert.equal(result[0].description, "Get the weather for a city");
|
|
||||||
assert.deepEqual(result[0].input_schema, {
|
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { city: { type: "string" } },
|
properties: { city: { type: "string" } },
|
||||||
required: ["city"],
|
required: ["city"],
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles tool without parameters", () => {
|
|
||||||
const tools = [{ name: "ping", description: "Check connectivity" }];
|
|
||||||
const result = toolsToJson(tools);
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.deepEqual(result[0].input_schema, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts multiple tools", () => {
|
|
||||||
const tools = [
|
|
||||||
{ name: "tool_a", description: "A", parameters: { kind: "string" } },
|
|
||||||
{ name: "tool_b", description: "B", parameters: { kind: "number" } },
|
|
||||||
];
|
|
||||||
const result = toolsToJson(tools);
|
|
||||||
assert.equal(result.length, 2);
|
|
||||||
assert.equal(result[0].name, "tool_a");
|
|
||||||
assert.equal(result[1].name, "tool_b");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// messagesToCC
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("messagesToCC() — user messages", () => {
|
|
||||||
it("converts string content user message", () => {
|
|
||||||
const msgs = [{ role: "user", content: "hello" }];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.equal(result[0].role, "user");
|
|
||||||
assert.equal(result[0].content, "hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes through array content user message", () => {
|
|
||||||
const content = [{ type: "text", text: "hello" }];
|
|
||||||
const msgs = [{ role: "user", content }];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result[0].role, "user");
|
|
||||||
assert.equal(result[0].content, content);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("messagesToCC() — assistant messages", () => {
|
|
||||||
it("converts text content", () => {
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "Hello from assistant" }],
|
|
||||||
},
|
},
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.equal(result[0].role, "assistant");
|
|
||||||
assert.deepEqual(result[0].content, [
|
|
||||||
{ type: "text", text: "Hello from assistant" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts thinking content to reasoning", () => {
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "thinking", thinking: "Let me think..." }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.deepEqual(result[0].content, [
|
|
||||||
{ type: "reasoning", text: "Let me think..." },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts toolCall content", () => {
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "toolCall",
|
|
||||||
id: "call_123",
|
|
||||||
name: "read_file",
|
|
||||||
arguments: { path: "/tmp/test" },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
);
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.deepEqual(result[0].content, [
|
|
||||||
{
|
|
||||||
type: "tool-call",
|
|
||||||
toolCallId: "call_123",
|
|
||||||
toolName: "read_file",
|
|
||||||
input: { path: "/tmp/test" },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("converts mixed content (thinking + text + toolCall)", () => {
|
it("returns an empty array for missing tools", () => {
|
||||||
const msgs = [
|
assert.deepEqual(toolsToJson(), []);
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [
|
|
||||||
{ type: "thinking", thinking: "planning..." },
|
|
||||||
{ type: "text", text: "result" },
|
|
||||||
{ type: "toolCall", id: "t1", name: "ls", arguments: {} },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result[0].role, "assistant");
|
|
||||||
assert.equal(result[0].content.length, 3);
|
|
||||||
assert.equal(result[0].content[0].type, "reasoning");
|
|
||||||
assert.equal(result[0].content[1].type, "text");
|
|
||||||
assert.equal(result[0].content[2].type, "tool-call");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("messagesToCC() — toolResult messages", () => {
|
describe("messagesToCC()", () => {
|
||||||
it("converts successful tool result", () => {
|
it("converts user, assistant, and tool result messages", () => {
|
||||||
const msgs = [
|
const result = messagesToCC([
|
||||||
{
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: "call_123",
|
|
||||||
toolName: "read_file",
|
|
||||||
isError: false,
|
|
||||||
content: [{ type: "text", text: "file contents here" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result.length, 1);
|
|
||||||
assert.equal(result[0].role, "tool");
|
|
||||||
assert.equal(result[0].content[0].type, "tool-result");
|
|
||||||
assert.equal(result[0].content[0].output.type, "text");
|
|
||||||
assert.equal(result[0].content[0].output.value, "file contents here");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts error tool result", () => {
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: "call_456",
|
|
||||||
toolName: "bad_tool",
|
|
||||||
isError: true,
|
|
||||||
content: [{ type: "text", text: "something went wrong" }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result[0].content[0].output.type, "error-text");
|
|
||||||
assert.equal(result[0].content[0].output.value, "something went wrong");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("joins multiple text parts", () => {
|
|
||||||
const msgs = [
|
|
||||||
{
|
|
||||||
role: "toolResult",
|
|
||||||
toolCallId: "call_789",
|
|
||||||
toolName: "grep",
|
|
||||||
isError: false,
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "line 1" },
|
|
||||||
{ type: "text", text: "line 2" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const result = messagesToCC(msgs);
|
|
||||||
assert.equal(result[0].content[0].output.value, "line 1\nline 2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("messagesToCC() — full conversation", () => {
|
|
||||||
it("handles user → assistant(toolCall) → tool → assistant", () => {
|
|
||||||
const msgs = [
|
|
||||||
{ role: "user", content: "read /tmp/test" },
|
{ role: "user", content: "read /tmp/test" },
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [
|
content: [
|
||||||
{ type: "thinking", thinking: "I will read the file" },
|
{ type: "thinking", thinking: "I will read" },
|
||||||
{
|
{ type: "text", text: "Sure" },
|
||||||
type: "toolCall",
|
{ type: "toolCall", id: "c1", name: "read", arguments: { path: "/tmp/test" } },
|
||||||
id: "c1",
|
|
||||||
name: "read",
|
|
||||||
arguments: { path: "/tmp/test" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -613,25 +182,45 @@ describe("messagesToCC() — full conversation", () => {
|
|||||||
toolCallId: "c1",
|
toolCallId: "c1",
|
||||||
toolName: "read",
|
toolName: "read",
|
||||||
isError: false,
|
isError: false,
|
||||||
content: [{ type: "text", text: "hello world" }],
|
content: [{ type: "text", text: "hello" }, { type: "text", text: "world" }],
|
||||||
},
|
},
|
||||||
{
|
]);
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: "The file contains: hello world" }],
|
assert.equal(objectAt(result, ["0", "role"]), "user");
|
||||||
},
|
assert.equal(objectAt(result, ["1", "role"]), "assistant");
|
||||||
];
|
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning");
|
||||||
const result = messagesToCC(msgs);
|
assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call");
|
||||||
assert.equal(result.length, 4);
|
assert.equal(objectAt(result, ["2", "role"]), "tool");
|
||||||
assert.equal(result[0].role, "user");
|
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld");
|
||||||
assert.equal(result[1].role, "assistant");
|
|
||||||
assert.equal(result[1].content.length, 2);
|
|
||||||
assert.equal(result[2].role, "tool");
|
|
||||||
assert.equal(result[3].role, "assistant");
|
|
||||||
assert.equal(result[3].content[0].text, "The file contains: hello world");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty message array", () => {
|
it("handles empty conversations", () => {
|
||||||
const result = messagesToCC([]);
|
assert.deepEqual(messagesToCC([]), []);
|
||||||
assert.deepEqual(result, []);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseStreamEventLine()", () => {
|
||||||
|
it("parses plain JSON and SSE data lines", () => {
|
||||||
|
assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { type: "text-delta", text: "x" });
|
||||||
|
assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), {
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "stop",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores comments, event labels, done markers, and malformed JSON", () => {
|
||||||
|
assert.equal(parseStreamEventLine(":"), undefined);
|
||||||
|
assert.equal(parseStreamEventLine("event: message"), undefined);
|
||||||
|
assert.equal(parseStreamEventLine("data: [DONE]"), undefined);
|
||||||
|
assert.equal(parseStreamEventLine("not-json"), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapFinishReason()", () => {
|
||||||
|
it("maps provider finish reasons to pi stop reasons", () => {
|
||||||
|
assert.equal(mapFinishReason("stop"), "stop");
|
||||||
|
assert.equal(mapFinishReason("tool-calls"), "toolUse");
|
||||||
|
assert.equal(mapFinishReason("max_tokens"), "length");
|
||||||
|
assert.equal(mapFinishReason("max_output_tokens"), "length");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+190
-1626
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user