style: configure prettier (no semicolons, trailing commas)
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -12,16 +12,13 @@
|
|||||||
* Models: deepseek-v4-pro, deepseek-v4-flash, claude-sonnet-4-6, claude-opus-4-7, etc.
|
* Models: deepseek-v4-pro, deepseek-v4-flash, claude-sonnet-4-6, claude-opus-4-7, etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { calculateCost, createAssistantMessageEventStream } from "@mariozechner/pi-ai"
|
||||||
calculateCost,
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"
|
||||||
createAssistantMessageEventStream,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts";
|
import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"
|
||||||
import { getApiKey, login, refreshToken } from "./src/oauth.ts";
|
import { getApiKey, login, refreshToken } from "./src/oauth.ts"
|
||||||
|
|
||||||
const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE;
|
const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Model definitions
|
// Model definitions
|
||||||
@@ -157,13 +154,13 @@ const MODELS = [
|
|||||||
contextWindow: 1_000_000,
|
contextWindow: 1_000_000,
|
||||||
maxTokens: 131_072,
|
maxTokens: 131_072,
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
const streamCommandCode = createStreamCommandCode({
|
const streamCommandCode = createStreamCommandCode({
|
||||||
createStream: createAssistantMessageEventStream,
|
createStream: createAssistantMessageEventStream,
|
||||||
calculateCost,
|
calculateCost,
|
||||||
apiBase: API_BASE,
|
apiBase: API_BASE,
|
||||||
});
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Extension entry point
|
// Extension entry point
|
||||||
@@ -196,5 +193,5 @@ export default function (pi: ExtensionAPI) {
|
|||||||
contextWindow: model.contextWindow,
|
contextWindow: model.contextWindow,
|
||||||
maxTokens: model.maxTokens,
|
maxTokens: model.maxTokens,
|
||||||
})),
|
})),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-74
@@ -5,21 +5,21 @@
|
|||||||
* website POSTs the user's API key to /callback after they authenticate.
|
* website POSTs the user's API key to /callback after they authenticate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http"
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net"
|
||||||
|
|
||||||
export interface AuthCallback {
|
export interface AuthCallback {
|
||||||
apiKey: string;
|
apiKey: string
|
||||||
state: string;
|
state: string
|
||||||
userId: string;
|
userId: string
|
||||||
userName: string;
|
userName: string
|
||||||
keyName: string;
|
keyName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthServer {
|
export interface AuthServer {
|
||||||
server: Server;
|
server: Server
|
||||||
port: number;
|
port: number
|
||||||
waitForCallback: Promise<AuthCallback>;
|
waitForCallback: Promise<AuthCallback>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,127 +29,121 @@ export interface AuthServer {
|
|||||||
* The server accepts exactly one valid POST to /callback and then closes.
|
* The server accepts exactly one valid POST to /callback and then closes.
|
||||||
*/
|
*/
|
||||||
export function startAuthServer(): Promise<AuthServer> {
|
export function startAuthServer(): Promise<AuthServer> {
|
||||||
let resolveCallback: (value: AuthCallback) => void;
|
let resolveCallback: (value: AuthCallback) => void
|
||||||
let rejectCallback: (error: Error) => void;
|
let rejectCallback: (error: Error) => void
|
||||||
|
|
||||||
const waitForCallback = new Promise<AuthCallback>((resolve, reject) => {
|
const waitForCallback = new Promise<AuthCallback>((resolve, reject) => {
|
||||||
resolveCallback = resolve;
|
resolveCallback = resolve
|
||||||
rejectCallback = reject;
|
rejectCallback = reject
|
||||||
});
|
})
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
// CORS: allow requests from Command Code domains and localhost for dev
|
// CORS: allow requests from Command Code domains and localhost for dev
|
||||||
const origin = req.headers.origin || "";
|
const origin = req.headers.origin || ""
|
||||||
const allowedOrigins = [
|
const allowedOrigins = [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://staging.commandcode.ai",
|
"https://staging.commandcode.ai",
|
||||||
"https://commandcode.ai",
|
"https://commandcode.ai",
|
||||||
];
|
]
|
||||||
const responseOrigin = allowedOrigins.includes(origin)
|
const responseOrigin = allowedOrigins.includes(origin) ? origin : allowedOrigins[0]
|
||||||
? origin
|
|
||||||
: allowedOrigins[0];
|
|
||||||
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", responseOrigin);
|
res.setHeader("Access-Control-Allow-Origin", responseOrigin)
|
||||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json")
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204);
|
res.writeHead(204)
|
||||||
res.end();
|
res.end()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.url !== "/callback") {
|
if (req.url !== "/callback") {
|
||||||
res.writeHead(404);
|
res.writeHead(404)
|
||||||
res.end(JSON.stringify({ success: false, error: "Not found" }));
|
res.end(JSON.stringify({ success: false, error: "Not found" }))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
res.writeHead(405);
|
res.writeHead(405)
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Method not allowed. Use POST.",
|
error: "Method not allowed. Use POST.",
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = "";
|
let body = ""
|
||||||
req.on("data", (chunk) => {
|
req.on("data", (chunk) => {
|
||||||
body += chunk.toString();
|
body += chunk.toString()
|
||||||
if (body.length > 10_000) req.destroy();
|
if (body.length > 10_000) req.destroy()
|
||||||
});
|
})
|
||||||
|
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
const parsed = JSON.parse(body) as Record<string, unknown>
|
||||||
|
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
res.writeHead(200);
|
res.writeHead(200)
|
||||||
res.end(JSON.stringify({ success: true }));
|
res.end(JSON.stringify({ success: true }))
|
||||||
const description =
|
const description =
|
||||||
typeof parsed.error_description === "string"
|
typeof parsed.error_description === "string"
|
||||||
? parsed.error_description
|
? parsed.error_description
|
||||||
: String(parsed.error);
|
: String(parsed.error)
|
||||||
if (parsed.error === "access_denied") {
|
if (parsed.error === "access_denied") {
|
||||||
rejectCallback(
|
rejectCallback(new Error(description || "Authorization was denied by the user"))
|
||||||
new Error(description || "Authorization was denied by the user"),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
rejectCallback(new Error(description || String(parsed.error)));
|
rejectCallback(new Error(description || String(parsed.error)))
|
||||||
}
|
}
|
||||||
server.close();
|
server.close()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : "";
|
const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : ""
|
||||||
const state = typeof parsed.state === "string" ? parsed.state : "";
|
const state = typeof parsed.state === "string" ? parsed.state : ""
|
||||||
const userId = typeof parsed.userId === "string" ? parsed.userId : "";
|
const userId = typeof parsed.userId === "string" ? parsed.userId : ""
|
||||||
const userName =
|
const userName = typeof parsed.userName === "string" ? parsed.userName : ""
|
||||||
typeof parsed.userName === "string" ? parsed.userName : "";
|
const keyName = typeof parsed.keyName === "string" ? parsed.keyName : ""
|
||||||
const keyName =
|
|
||||||
typeof parsed.keyName === "string" ? parsed.keyName : "";
|
|
||||||
|
|
||||||
if (!apiKey || !state || !userId || !userName || !keyName) {
|
if (!apiKey || !state || !userId || !userName || !keyName) {
|
||||||
res.writeHead(400);
|
res.writeHead(400)
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: false,
|
success: false,
|
||||||
error: "Missing required fields",
|
error: "Missing required fields",
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200);
|
res.writeHead(200)
|
||||||
res.end(JSON.stringify({ success: true }));
|
res.end(JSON.stringify({ success: true }))
|
||||||
|
|
||||||
resolveCallback({ apiKey, state, userId, userName, keyName });
|
resolveCallback({ apiKey, state, userId, userName, keyName })
|
||||||
server.close();
|
server.close()
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(400);
|
res.writeHead(400)
|
||||||
res.end(JSON.stringify({ success: false, error: "Invalid JSON" }));
|
res.end(JSON.stringify({ success: false, error: "Invalid JSON" }))
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
req.on("error", () => {
|
req.on("error", () => {
|
||||||
res.writeHead(500);
|
res.writeHead(500)
|
||||||
res.end(JSON.stringify({ success: false, error: "Request error" }));
|
res.end(JSON.stringify({ success: false, error: "Request error" }))
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
rejectCallback(new Error(`Failed to start auth server: ${err.message}`));
|
rejectCallback(new Error(`Failed to start auth server: ${err.message}`))
|
||||||
});
|
})
|
||||||
|
|
||||||
server.listen(0, "127.0.0.1", () => {
|
server.listen(0, "127.0.0.1", () => {
|
||||||
const address = server.address() as AddressInfo;
|
const address = server.address() as AddressInfo
|
||||||
resolve({ server, port: address.port, waitForCallback });
|
resolve({ server, port: address.port, waitForCallback })
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+98
-122
@@ -1,258 +1,235 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path";
|
import { join } from "node:path"
|
||||||
|
|
||||||
import type { MessageLike, StopReason, ToolLike } from "./types.ts";
|
import type { MessageLike, StopReason, ToolLike } from "./types.ts"
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringValue(value: unknown): string | undefined {
|
export function stringValue(value: unknown): string | undefined {
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function booleanValue(value: unknown): boolean | undefined {
|
function booleanValue(value: unknown): boolean | undefined {
|
||||||
return typeof value === "boolean" ? value : undefined;
|
return typeof value === "boolean" ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordArray(
|
export function recordArray(value: unknown): readonly Record<string, unknown>[] {
|
||||||
value: unknown,
|
if (!Array.isArray(value)) return []
|
||||||
): readonly Record<string, unknown>[] {
|
return value.filter(isRecord)
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter(isRecord);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordOrEmpty(value: unknown): Record<string, unknown> {
|
export function recordOrEmpty(value: unknown): Record<string, unknown> {
|
||||||
if (isRecord(value)) return value;
|
if (isRecord(value)) return value
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(value);
|
const parsed: unknown = JSON.parse(value)
|
||||||
if (isRecord(parsed)) return parsed;
|
if (isRecord(parsed)) return parsed
|
||||||
} catch {
|
} catch {
|
||||||
// Some providers stream incomplete JSON argument fragments.
|
// Some providers stream incomplete JSON argument fragments.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function numberValue(value: unknown): number | undefined {
|
export function numberValue(value: unknown): number | undefined {
|
||||||
return typeof value === "number" && Number.isFinite(value)
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
||||||
? value
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultAuthPaths(home: string): string[] {
|
function defaultAuthPaths(home: string): string[] {
|
||||||
return [
|
return [join(home, ".commandcode", "auth.json"), join(home, ".pi", "agent", "auth.json")]
|
||||||
join(home, ".commandcode", "auth.json"),
|
|
||||||
join(home, ".pi", "agent", "auth.json"),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getApiKey(
|
export function getApiKey(
|
||||||
options: {
|
options: {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv
|
||||||
authPaths?: readonly string[];
|
authPaths?: readonly string[]
|
||||||
homeDir?: () => string;
|
homeDir?: () => string
|
||||||
} = {},
|
} = {},
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env
|
||||||
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY;
|
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY
|
||||||
|
|
||||||
const home = options.homeDir?.() ?? homedir();
|
const home = options.homeDir?.() ?? homedir()
|
||||||
const authPaths = options.authPaths ?? defaultAuthPaths(home);
|
const authPaths = options.authPaths ?? defaultAuthPaths(home)
|
||||||
|
|
||||||
for (const authPath of authPaths) {
|
for (const authPath of authPaths) {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(authPath)) continue;
|
if (!existsSync(authPath)) continue
|
||||||
const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8"));
|
const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8"))
|
||||||
if (!isRecord(parsed)) continue;
|
if (!isRecord(parsed)) continue
|
||||||
|
|
||||||
// Legacy: direct apiKey or commandcode field
|
// Legacy: direct apiKey or commandcode field
|
||||||
const apiKey = stringValue(parsed.apiKey);
|
const apiKey = stringValue(parsed.apiKey)
|
||||||
if (apiKey) return apiKey;
|
if (apiKey) return apiKey
|
||||||
const commandcode = stringValue(parsed.commandcode);
|
const commandcode = stringValue(parsed.commandcode)
|
||||||
if (commandcode) return commandcode;
|
if (commandcode) return commandcode
|
||||||
|
|
||||||
// OAuth: pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"...","refresh":"...","expires":...}}
|
// OAuth: pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"...","refresh":"...","expires":...}}
|
||||||
const providerKey = isRecord(parsed.commandcode)
|
const providerKey = isRecord(parsed.commandcode) ? parsed.commandcode : undefined
|
||||||
? parsed.commandcode
|
|
||||||
: undefined;
|
|
||||||
if (providerKey && stringValue(providerKey.type) === "oauth") {
|
if (providerKey && stringValue(providerKey.type) === "oauth") {
|
||||||
const access = stringValue(providerKey.access);
|
const access = stringValue(providerKey.access)
|
||||||
if (access) return access;
|
if (access) return access
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed or unreadable auth files.
|
// Ignore malformed or unreadable auth files.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textContent(message: { content?: unknown }): string {
|
export function textContent(message: { content?: unknown }): string {
|
||||||
return recordArray(message.content)
|
return recordArray(message.content)
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part) => stringValue(part.text) ?? "")
|
.map((part) => stringValue(part.text) ?? "")
|
||||||
.join("\n");
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEnvironmentInfo(): string {
|
export function getEnvironmentInfo(): string {
|
||||||
return `${process.platform}-${process.arch}, Node.js ${process.version}`;
|
return `${process.platform}-${process.arch}, Node.js ${process.version}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toJsonSchema(schema: unknown): unknown {
|
export function toJsonSchema(schema: unknown): unknown {
|
||||||
if (!isRecord(schema)) return {};
|
if (!isRecord(schema)) return {}
|
||||||
|
|
||||||
const kind = stringValue(schema.kind) ?? stringValue(schema.type);
|
const kind = stringValue(schema.kind) ?? stringValue(schema.type)
|
||||||
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
|
const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined
|
||||||
if (enumValues) {
|
if (enumValues) {
|
||||||
return { type: typeof enumValues[0], enum: enumValues };
|
return { type: typeof enumValues[0], enum: enumValues }
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "string":
|
case "string":
|
||||||
case "String":
|
case "String":
|
||||||
return { type: "string" };
|
return { type: "string" }
|
||||||
case "number":
|
case "number":
|
||||||
case "Number":
|
case "Number":
|
||||||
return { type: "number" };
|
return { type: "number" }
|
||||||
case "boolean":
|
case "boolean":
|
||||||
case "Boolean":
|
case "Boolean":
|
||||||
return { type: "boolean" };
|
return { type: "boolean" }
|
||||||
case "object":
|
case "object":
|
||||||
case "Object": {
|
case "Object": {
|
||||||
const properties: Record<string, unknown> = {};
|
const properties: Record<string, unknown> = {}
|
||||||
const inferredRequired: string[] = [];
|
const inferredRequired: string[] = []
|
||||||
const sourceProperties = isRecord(schema.properties)
|
const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined
|
||||||
? schema.properties
|
|
||||||
: undefined;
|
|
||||||
const optional = Array.isArray(schema.optional)
|
const optional = Array.isArray(schema.optional)
|
||||||
? schema.optional.filter(
|
? schema.optional.filter((item): item is string => typeof item === "string")
|
||||||
(item): item is string => typeof item === "string",
|
: []
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (sourceProperties) {
|
if (sourceProperties) {
|
||||||
for (const [key, value] of Object.entries(sourceProperties)) {
|
for (const [key, value] of Object.entries(sourceProperties)) {
|
||||||
properties[key] = toJsonSchema(value);
|
properties[key] = toJsonSchema(value)
|
||||||
const valueRecord = isRecord(value) ? value : undefined;
|
const valueRecord = isRecord(value) ? value : undefined
|
||||||
if (
|
if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) {
|
||||||
booleanValue(valueRecord?.optional) !== true &&
|
inferredRequired.push(key)
|
||||||
!optional.includes(key)
|
|
||||||
) {
|
|
||||||
inferredRequired.push(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitRequired = Array.isArray(schema.required)
|
const explicitRequired = Array.isArray(schema.required)
|
||||||
? schema.required.filter(
|
? schema.required.filter((item): item is string => typeof item === "string")
|
||||||
(item): item is string => typeof item === "string",
|
: undefined
|
||||||
)
|
const required = explicitRequired ?? inferredRequired
|
||||||
: undefined;
|
const out: Record<string, unknown> = { type: "object" }
|
||||||
const required = explicitRequired ?? inferredRequired;
|
if (Object.keys(properties).length > 0) out.properties = properties
|
||||||
const out: Record<string, unknown> = { type: "object" };
|
if (required.length > 0) out.required = required
|
||||||
if (Object.keys(properties).length > 0) out.properties = properties;
|
return out
|
||||||
if (required.length > 0) out.required = required;
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
case "array":
|
case "array":
|
||||||
case "Array":
|
case "Array":
|
||||||
return {
|
return {
|
||||||
type: "array",
|
type: "array",
|
||||||
items: toJsonSchema(schema.items ?? schema.element),
|
items: toJsonSchema(schema.items ?? schema.element),
|
||||||
};
|
}
|
||||||
case "union":
|
case "union":
|
||||||
case "Union": {
|
case "Union": {
|
||||||
const variants = Array.isArray(schema.variants)
|
const variants = Array.isArray(schema.variants)
|
||||||
? schema.variants
|
? schema.variants
|
||||||
: Array.isArray(schema.anyOf)
|
: Array.isArray(schema.anyOf)
|
||||||
? schema.anyOf
|
? schema.anyOf
|
||||||
: [];
|
: []
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
const converted = toJsonSchema(variant);
|
const converted = toJsonSchema(variant)
|
||||||
if (isRecord(converted) && Object.keys(converted).length > 0)
|
if (isRecord(converted) && Object.keys(converted).length > 0) return converted
|
||||||
return converted;
|
|
||||||
}
|
}
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
case "optional":
|
case "optional":
|
||||||
case "Optional":
|
case "Optional":
|
||||||
return toJsonSchema(schema.wrapped ?? schema.inner);
|
return toJsonSchema(schema.wrapped ?? schema.inner)
|
||||||
default:
|
default:
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
export function toolsToJson(tools?: readonly ToolLike[]): unknown[] {
|
||||||
if (!tools) return [];
|
if (!tools) return []
|
||||||
return tools.map((tool) => ({
|
return tools.map((tool) => ({
|
||||||
type: "function",
|
type: "function",
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {},
|
input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {},
|
||||||
}));
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
function completeToolCallIds(messages?: readonly MessageLike[]): Set<string> {
|
||||||
const callIds = new Set<string>();
|
const callIds = new Set<string>()
|
||||||
const resultIds = new Set<string>();
|
const resultIds = new Set<string>()
|
||||||
|
|
||||||
for (const message of messages ?? []) {
|
for (const message of messages ?? []) {
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
for (const content of recordArray(message.content)) {
|
for (const content of recordArray(message.content)) {
|
||||||
if (content.type === "toolCall") {
|
if (content.type === "toolCall") {
|
||||||
const id = stringValue(content.id);
|
const id = stringValue(content.id)
|
||||||
if (id) callIds.add(id);
|
if (id) callIds.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (message.role === "toolResult") {
|
} else if (message.role === "toolResult") {
|
||||||
if (message.toolCallId) resultIds.add(message.toolCallId);
|
if (message.toolCallId) resultIds.add(message.toolCallId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Set([...callIds].filter((id) => resultIds.has(id)));
|
return new Set([...callIds].filter((id) => resultIds.has(id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
||||||
const out: unknown[] = [];
|
const out: unknown[] = []
|
||||||
const pairedToolCallIds = completeToolCallIds(messages);
|
const pairedToolCallIds = completeToolCallIds(messages)
|
||||||
|
|
||||||
for (const message of messages ?? []) {
|
for (const message of messages ?? []) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
out.push({
|
out.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content:
|
content: typeof message.content === "string" ? message.content : message.content,
|
||||||
typeof message.content === "string"
|
})
|
||||||
? message.content
|
|
||||||
: message.content,
|
|
||||||
});
|
|
||||||
} else if (message.role === "assistant") {
|
} else if (message.role === "assistant") {
|
||||||
const parts: unknown[] = [];
|
const parts: unknown[] = []
|
||||||
for (const content of recordArray(message.content)) {
|
for (const content of recordArray(message.content)) {
|
||||||
if (content.type === "text") {
|
if (content.type === "text") {
|
||||||
parts.push({ type: "text", text: stringValue(content.text) ?? "" });
|
parts.push({ type: "text", text: stringValue(content.text) ?? "" })
|
||||||
} else if (content.type === "thinking") {
|
} else if (content.type === "thinking") {
|
||||||
parts.push({
|
parts.push({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: stringValue(content.thinking) ?? "",
|
text: stringValue(content.thinking) ?? "",
|
||||||
});
|
})
|
||||||
} else if (content.type === "toolCall") {
|
} else if (content.type === "toolCall") {
|
||||||
const toolCallId = stringValue(content.id) ?? "";
|
const toolCallId = stringValue(content.id) ?? ""
|
||||||
if (!pairedToolCallIds.has(toolCallId)) continue;
|
if (!pairedToolCallIds.has(toolCallId)) continue
|
||||||
parts.push({
|
parts.push({
|
||||||
type: "tool-call",
|
type: "tool-call",
|
||||||
toolCallId,
|
toolCallId,
|
||||||
toolName: stringValue(content.name) ?? "",
|
toolName: stringValue(content.name) ?? "",
|
||||||
input: recordOrEmpty(content.arguments),
|
input: recordOrEmpty(content.arguments),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parts.length > 0) out.push({ role: "assistant", content: parts });
|
if (parts.length > 0) out.push({ role: "assistant", content: parts })
|
||||||
} else if (message.role === "toolResult") {
|
} else if (message.role === "toolResult") {
|
||||||
if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId))
|
if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) continue
|
||||||
continue;
|
|
||||||
out.push({
|
out.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: [
|
content: [
|
||||||
@@ -265,36 +242,35 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] {
|
|||||||
: { type: "text", value: textContent(message) },
|
: { type: "text", value: textContent(message) },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseStreamEventLine(line: string): unknown | undefined {
|
export function parseStreamEventLine(line: string): unknown | undefined {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim()
|
||||||
if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:"))
|
if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined
|
||||||
return undefined;
|
if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim()
|
||||||
if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim();
|
if (!trimmed || trimmed === "[DONE]") return undefined
|
||||||
if (!trimmed || trimmed === "[DONE]") return undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(trimmed);
|
const parsed: unknown = JSON.parse(trimmed)
|
||||||
return parsed;
|
return parsed
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapFinishReason(reason: unknown): StopReason {
|
export function mapFinishReason(reason: unknown): StopReason {
|
||||||
if (reason === "tool-calls") return "toolUse";
|
if (reason === "tool-calls") return "toolUse"
|
||||||
if (
|
if (
|
||||||
reason === "length" ||
|
reason === "length" ||
|
||||||
reason === "max_tokens" ||
|
reason === "max_tokens" ||
|
||||||
reason === "max-tokens" ||
|
reason === "max-tokens" ||
|
||||||
reason === "max_output_tokens"
|
reason === "max_output_tokens"
|
||||||
) {
|
) {
|
||||||
return "length";
|
return "length"
|
||||||
}
|
}
|
||||||
return "stop";
|
return "stop"
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-163
@@ -5,7 +5,7 @@
|
|||||||
* dependencies so tests can exercise the real serialization and stream parser.
|
* dependencies so tests can exercise the real serialization and stream parser.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getApiKey,
|
getApiKey,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
recordOrEmpty,
|
recordOrEmpty,
|
||||||
stringValue,
|
stringValue,
|
||||||
toolsToJson,
|
toolsToJson,
|
||||||
} from "./converters.ts";
|
} from "./converters.ts"
|
||||||
import type {
|
import type {
|
||||||
AssistantMessageEventStreamLike,
|
AssistantMessageEventStreamLike,
|
||||||
AssistantMessageLike,
|
AssistantMessageLike,
|
||||||
@@ -32,12 +32,12 @@ import type {
|
|||||||
TextContent,
|
TextContent,
|
||||||
ToolCallContent,
|
ToolCallContent,
|
||||||
Usage,
|
Usage,
|
||||||
} from "./types.ts";
|
} from "./types.ts"
|
||||||
|
|
||||||
export * from "./converters.ts";
|
export * from "./converters.ts"
|
||||||
export * from "./types.ts";
|
export * from "./types.ts"
|
||||||
|
|
||||||
export const DEFAULT_API_BASE = "https://api.commandcode.ai";
|
export const DEFAULT_API_BASE = "https://api.commandcode.ai"
|
||||||
|
|
||||||
function defaultUsage(): Usage {
|
function defaultUsage(): Usage {
|
||||||
return {
|
return {
|
||||||
@@ -47,64 +47,60 @@ function defaultUsage(): Usage {
|
|||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandCodeUsage(
|
function commandCodeUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||||
event: Record<string, unknown>,
|
return isRecord(event.totalUsage) ? event.totalUsage : undefined
|
||||||
): Record<string, unknown> | undefined {
|
|
||||||
return isRecord(event.totalUsage) ? event.totalUsage : undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandCodeInputTokenDetails(
|
function commandCodeInputTokenDetails(
|
||||||
usage: Record<string, unknown>,
|
usage: Record<string, unknown>,
|
||||||
): Record<string, unknown> | undefined {
|
): Record<string, unknown> | undefined {
|
||||||
return isRecord(usage.inputTokenDetails)
|
return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined
|
||||||
? usage.inputTokenDetails
|
|
||||||
: undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function headersToRecord(headers: Headers): Record<string, string> {
|
function headersToRecord(headers: Headers): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {}
|
||||||
headers.forEach((value, key) => {
|
headers.forEach((value, key) => {
|
||||||
out[key] = value;
|
out[key] = value
|
||||||
});
|
})
|
||||||
return out;
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortError(message = "The operation was aborted"): DOMException {
|
function abortError(message = "The operation was aborted"): DOMException {
|
||||||
return new DOMException(message, "AbortError");
|
return new DOMException(message, "AbortError")
|
||||||
}
|
}
|
||||||
|
|
||||||
function successStopReason(reason: TerminalReason): StopReason {
|
function successStopReason(reason: TerminalReason): StopReason {
|
||||||
if (reason === "length" || reason === "toolUse") return reason;
|
if (reason === "length" || reason === "toolUse") return reason
|
||||||
return "stop";
|
return "stop"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStreamCommandCode(deps: CoreDependencies) {
|
export function createStreamCommandCode(deps: CoreDependencies) {
|
||||||
const apiBase = deps.apiBase ?? DEFAULT_API_BASE;
|
const apiBase = deps.apiBase ?? DEFAULT_API_BASE
|
||||||
const fetchImpl = deps.fetchImpl ?? fetch;
|
const fetchImpl = deps.fetchImpl ?? fetch
|
||||||
const cwd = deps.cwd ?? (() => process.cwd());
|
const cwd = deps.cwd ?? (() => process.cwd())
|
||||||
const now = deps.now ?? (() => Date.now());
|
const now = deps.now ?? (() => Date.now())
|
||||||
const uuid = deps.uuid ?? (() => randomUUID());
|
const uuid = deps.uuid ?? (() => randomUUID())
|
||||||
|
|
||||||
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||||
if (signal.aborted) return Promise.reject(abortError());
|
if (signal.aborted) return Promise.reject(abortError())
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const onAbort = () => reject(abortError());
|
const onAbort = () => reject(abortError())
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true })
|
||||||
promise.then(
|
promise.then(
|
||||||
(value) => {
|
(value) => {
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort)
|
||||||
resolve(value);
|
resolve(value)
|
||||||
},
|
},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort)
|
||||||
reject(error);
|
reject(error)
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return function streamCommandCode(
|
return function streamCommandCode(
|
||||||
@@ -112,7 +108,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
context: ContextLike,
|
context: ContextLike,
|
||||||
options?: StreamOptions,
|
options?: StreamOptions,
|
||||||
): AssistantMessageEventStreamLike {
|
): AssistantMessageEventStreamLike {
|
||||||
const stream = deps.createStream();
|
const stream = deps.createStream()
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
@@ -121,7 +117,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
env: deps.env,
|
env: deps.env,
|
||||||
authPaths: deps.authPaths,
|
authPaths: deps.authPaths,
|
||||||
homeDir: deps.homeDir,
|
homeDir: deps.homeDir,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
const msg: AssistantMessageLike = {
|
const msg: AssistantMessageLike = {
|
||||||
@@ -135,10 +131,10 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
errorMessage:
|
errorMessage:
|
||||||
"No Command Code API key. Run /login commandcode, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
"No Command Code API key. Run /login commandcode, set COMMANDCODE_API_KEY env var, or configure ~/.commandcode/auth.json or ~/.pi/agent/auth.json.",
|
||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
};
|
}
|
||||||
stream.push({ type: "error", reason: "error", error: msg });
|
stream.push({ type: "error", reason: "error", error: msg })
|
||||||
stream.end();
|
stream.end()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const output: AssistantMessageLike = {
|
const output: AssistantMessageLike = {
|
||||||
@@ -150,168 +146,162 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
usage: defaultUsage(),
|
usage: defaultUsage(),
|
||||||
stopReason: "stop",
|
stopReason: "stop",
|
||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
};
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController()
|
||||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||||
let textBlock: TextContent | undefined;
|
let textBlock: TextContent | undefined
|
||||||
let currentTextIdx = -1;
|
let currentTextIdx = -1
|
||||||
let thinkingBlock: string[] = [];
|
let thinkingBlock: string[] = []
|
||||||
let finished = false;
|
let finished = false
|
||||||
|
|
||||||
const abortUpstream = () => {
|
const abortUpstream = () => {
|
||||||
if (!controller.signal.aborted) controller.abort();
|
if (!controller.signal.aborted) controller.abort()
|
||||||
try {
|
try {
|
||||||
reader?.cancel().catch(() => undefined);
|
reader?.cancel().catch(() => undefined)
|
||||||
} catch {
|
} catch {
|
||||||
// Reader cancellation is best-effort.
|
// Reader cancellation is best-effort.
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if (options?.signal?.aborted) {
|
if (options?.signal?.aborted) {
|
||||||
abortUpstream();
|
abortUpstream()
|
||||||
} else {
|
} else {
|
||||||
options?.signal?.addEventListener("abort", abortUpstream, {
|
options?.signal?.addEventListener("abort", abortUpstream, {
|
||||||
once: true,
|
once: true,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const endTextBlock = () => {
|
const endTextBlock = () => {
|
||||||
if (!textBlock) return;
|
if (!textBlock) return
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_end",
|
type: "text_end",
|
||||||
contentIndex: currentTextIdx,
|
contentIndex: currentTextIdx,
|
||||||
content: textBlock.text,
|
content: textBlock.text,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
textBlock = undefined;
|
textBlock = undefined
|
||||||
currentTextIdx = -1;
|
currentTextIdx = -1
|
||||||
};
|
}
|
||||||
|
|
||||||
const flushThinkingBlock = () => {
|
const flushThinkingBlock = () => {
|
||||||
if (thinkingBlock.length === 0) return;
|
if (thinkingBlock.length === 0) return
|
||||||
const thinkingText = thinkingBlock.join("");
|
const thinkingText = thinkingBlock.join("")
|
||||||
thinkingBlock = [];
|
thinkingBlock = []
|
||||||
output.content.push({ type: "thinking", thinking: thinkingText });
|
output.content.push({ type: "thinking", thinking: thinkingText })
|
||||||
const idx = output.content.length - 1;
|
const idx = output.content.length - 1
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "thinking_start",
|
type: "thinking_start",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "thinking_delta",
|
type: "thinking_delta",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
delta: thinkingText,
|
delta: thinkingText,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "thinking_end",
|
type: "thinking_end",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
content: thinkingText,
|
content: thinkingText,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleEvent = (event: unknown) => {
|
const handleEvent = (event: unknown) => {
|
||||||
if (!isRecord(event)) return;
|
if (!isRecord(event)) return
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "text-delta": {
|
case "text-delta": {
|
||||||
if (!textBlock) {
|
if (!textBlock) {
|
||||||
textBlock = { type: "text", text: "" };
|
textBlock = { type: "text", text: "" }
|
||||||
output.content.push(textBlock);
|
output.content.push(textBlock)
|
||||||
currentTextIdx = output.content.length - 1;
|
currentTextIdx = output.content.length - 1
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_start",
|
type: "text_start",
|
||||||
contentIndex: currentTextIdx,
|
contentIndex: currentTextIdx,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
const delta = stringValue(event.text) ?? "";
|
const delta = stringValue(event.text) ?? ""
|
||||||
textBlock.text += delta;
|
textBlock.text += delta
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "text_delta",
|
type: "text_delta",
|
||||||
contentIndex: currentTextIdx,
|
contentIndex: currentTextIdx,
|
||||||
delta,
|
delta,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "reasoning-delta": {
|
case "reasoning-delta": {
|
||||||
thinkingBlock.push(stringValue(event.text) ?? "");
|
thinkingBlock.push(stringValue(event.text) ?? "")
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "reasoning-end": {
|
case "reasoning-end": {
|
||||||
flushThinkingBlock();
|
flushThinkingBlock()
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
endTextBlock();
|
endTextBlock()
|
||||||
const toolCall: ToolCallContent = {
|
const toolCall: ToolCallContent = {
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
id: stringValue(event.toolCallId) ?? "",
|
id: stringValue(event.toolCallId) ?? "",
|
||||||
name: stringValue(event.toolName) ?? "",
|
name: stringValue(event.toolName) ?? "",
|
||||||
arguments: recordOrEmpty(
|
arguments: recordOrEmpty(event.input ?? event.args ?? event.arguments),
|
||||||
event.input ?? event.args ?? event.arguments,
|
}
|
||||||
),
|
output.content.push(toolCall)
|
||||||
};
|
const idx = output.content.length - 1
|
||||||
output.content.push(toolCall);
|
|
||||||
const idx = output.content.length - 1;
|
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "toolcall_start",
|
type: "toolcall_start",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "toolcall_end",
|
type: "toolcall_end",
|
||||||
contentIndex: idx,
|
contentIndex: idx,
|
||||||
toolCall,
|
toolCall,
|
||||||
partial: output,
|
partial: output,
|
||||||
});
|
})
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "finish": {
|
case "finish": {
|
||||||
const usage = commandCodeUsage(event);
|
const usage = commandCodeUsage(event)
|
||||||
if (usage) {
|
if (usage) {
|
||||||
const details = commandCodeInputTokenDetails(usage);
|
const details = commandCodeInputTokenDetails(usage)
|
||||||
output.usage.input = numberValue(usage.inputTokens) ?? 0;
|
output.usage.input = numberValue(usage.inputTokens) ?? 0
|
||||||
output.usage.output = numberValue(usage.outputTokens) ?? 0;
|
output.usage.output = numberValue(usage.outputTokens) ?? 0
|
||||||
output.usage.cacheRead =
|
output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0
|
||||||
numberValue(details?.cacheReadTokens) ?? 0;
|
output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0
|
||||||
output.usage.cacheWrite =
|
|
||||||
numberValue(details?.cacheWriteTokens) ?? 0;
|
|
||||||
output.usage.totalTokens =
|
output.usage.totalTokens =
|
||||||
output.usage.input +
|
output.usage.input +
|
||||||
output.usage.output +
|
output.usage.output +
|
||||||
output.usage.cacheRead +
|
output.usage.cacheRead +
|
||||||
output.usage.cacheWrite;
|
output.usage.cacheWrite
|
||||||
deps.calculateCost(model, output.usage);
|
deps.calculateCost(model, output.usage)
|
||||||
}
|
}
|
||||||
output.stopReason = mapFinishReason(event.finishReason);
|
output.stopReason = mapFinishReason(event.finishReason)
|
||||||
finished = true;
|
finished = true
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case "error": {
|
case "error": {
|
||||||
const errorRecord = isRecord(event.error) ? event.error : undefined;
|
const errorRecord = isRecord(event.error) ? event.error : undefined
|
||||||
const message =
|
const message =
|
||||||
stringValue(errorRecord?.message) ??
|
stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"
|
||||||
stringValue(event.error) ??
|
output.stopReason = "error"
|
||||||
"Stream error";
|
output.errorMessage = message
|
||||||
output.stopReason = "error";
|
throw new Error(message)
|
||||||
output.errorMessage = message;
|
}
|
||||||
throw new Error(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stream.push({ type: "start", partial: output });
|
stream.push({ type: "start", partial: output })
|
||||||
|
|
||||||
let body: unknown = {
|
let body: unknown = {
|
||||||
config: {
|
config: {
|
||||||
@@ -334,19 +324,16 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
messages: messagesToCC(context.messages),
|
messages: messagesToCC(context.messages),
|
||||||
tools: toolsToJson(context.tools),
|
tools: toolsToJson(context.tools),
|
||||||
system: context.systemPrompt ?? "",
|
system: context.systemPrompt ?? "",
|
||||||
max_tokens: Math.min(
|
max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000),
|
||||||
options?.maxTokens ?? model.maxTokens,
|
|
||||||
200_000,
|
|
||||||
),
|
|
||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
const nextBody = await raceAbort(
|
const nextBody = await raceAbort(
|
||||||
Promise.resolve(options?.onPayload?.(body, model)),
|
Promise.resolve(options?.onPayload?.(body, model)),
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
)
|
||||||
if (nextBody !== undefined) body = nextBody;
|
if (nextBody !== undefined) body = nextBody
|
||||||
|
|
||||||
const response = await raceAbort(
|
const response = await raceAbort(
|
||||||
fetchImpl(`${apiBase}/alpha/generate`, {
|
fetchImpl(`${apiBase}/alpha/generate`, {
|
||||||
@@ -366,7 +353,7 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}),
|
}),
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
)
|
||||||
|
|
||||||
await raceAbort(
|
await raceAbort(
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
@@ -379,78 +366,71 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errBody = await raceAbort(
|
const errBody = await raceAbort(
|
||||||
response.text().catch(() => ""),
|
response.text().catch(() => ""),
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
)
|
||||||
throw new Error(
|
throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`)
|
||||||
`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reader = response.body?.getReader();
|
reader = response.body?.getReader()
|
||||||
if (!reader) throw new Error("No response body");
|
if (!reader) throw new Error("No response body")
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder()
|
||||||
let buffer = "";
|
let buffer = ""
|
||||||
|
|
||||||
readLoop: for (;;) {
|
readLoop: for (;;) {
|
||||||
if (controller.signal.aborted) throw abortError("Aborted");
|
if (controller.signal.aborted) throw abortError("Aborted")
|
||||||
const { done, value } = await raceAbort(
|
const { done, value } = await raceAbort(reader.read(), controller.signal)
|
||||||
reader.read(),
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
if (done) {
|
if (done) {
|
||||||
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer));
|
if (buffer.trim()) handleEvent(parseStreamEventLine(buffer))
|
||||||
break;
|
break
|
||||||
}
|
}
|
||||||
if (controller.signal.aborted) throw abortError("Aborted");
|
if (controller.signal.aborted) throw abortError("Aborted")
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true })
|
||||||
const lines = buffer.split("\n");
|
const lines = buffer.split("\n")
|
||||||
buffer = lines.pop() ?? "";
|
buffer = lines.pop() ?? ""
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (controller.signal.aborted) throw abortError("Aborted");
|
if (controller.signal.aborted) throw abortError("Aborted")
|
||||||
handleEvent(parseStreamEventLine(line));
|
handleEvent(parseStreamEventLine(line))
|
||||||
if (finished) break readLoop;
|
if (finished) break readLoop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
endTextBlock();
|
endTextBlock()
|
||||||
flushThinkingBlock();
|
flushThinkingBlock()
|
||||||
|
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "done",
|
type: "done",
|
||||||
reason: successStopReason(output.stopReason),
|
reason: successStopReason(output.stopReason),
|
||||||
message: output,
|
message: output,
|
||||||
});
|
})
|
||||||
stream.end();
|
stream.end()
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const reason: ErrorReason = controller.signal.aborted
|
const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"
|
||||||
? "aborted"
|
output.stopReason = reason
|
||||||
: "error";
|
|
||||||
output.stopReason = reason;
|
|
||||||
output.errorMessage =
|
output.errorMessage =
|
||||||
reason === "aborted"
|
reason === "aborted"
|
||||||
? "Request aborted"
|
? "Request aborted"
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: String(error);
|
: String(error)
|
||||||
stream.push({ type: "error", reason, error: output });
|
stream.push({ type: "error", reason, error: output })
|
||||||
stream.end();
|
stream.end()
|
||||||
} finally {
|
} finally {
|
||||||
options?.signal?.removeEventListener("abort", abortUpstream);
|
options?.signal?.removeEventListener("abort", abortUpstream)
|
||||||
try {
|
try {
|
||||||
await reader?.cancel();
|
await reader?.cancel()
|
||||||
} catch {
|
} catch {
|
||||||
// Reader may already be closed/cancelled.
|
// Reader may already be closed/cancelled.
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
reader?.releaseLock();
|
reader?.releaseLock()
|
||||||
} catch {
|
} catch {
|
||||||
// Reader may already be released/cancelled by the abort path.
|
// Reader may already be released/cancelled by the abort path.
|
||||||
}
|
}
|
||||||
@@ -468,11 +448,11 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
|||||||
stopReason: "error",
|
stopReason: "error",
|
||||||
errorMessage: error instanceof Error ? error.message : String(error),
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
};
|
}
|
||||||
stream.push({ type: "error", reason: "error", error: msg });
|
stream.push({ type: "error", reason: "error", error: msg })
|
||||||
stream.end();
|
stream.end()
|
||||||
});
|
})
|
||||||
|
|
||||||
return stream;
|
return stream
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-30
@@ -12,25 +12,25 @@
|
|||||||
* OAuth credentials with a far-future expiry.
|
* OAuth credentials with a far-future expiry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto"
|
||||||
import { startAuthServer } from "./auth-server.ts";
|
import { startAuthServer } from "./auth-server.ts"
|
||||||
|
|
||||||
const STUDIO_BASE_URL = "https://commandcode.ai";
|
const STUDIO_BASE_URL = "https://commandcode.ai"
|
||||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000; // API keys don't expire
|
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
|
||||||
|
|
||||||
export interface OAuthLoginCallbacks {
|
export interface OAuthLoginCallbacks {
|
||||||
onAuth(params: { url: string }): void;
|
onAuth(params: { url: string }): void
|
||||||
onPrompt(params: { message: string }): Promise<string>;
|
onPrompt(params: { message: string }): Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OAuthCredentials {
|
export interface OAuthCredentials {
|
||||||
refresh: string;
|
refresh: string
|
||||||
access: string;
|
access: string
|
||||||
expires: number;
|
expires: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateStateToken(): string {
|
function generateStateToken(): string {
|
||||||
return randomBytes(32).toString("base64url");
|
return randomBytes(32).toString("base64url")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,32 +39,29 @@ function generateStateToken(): string {
|
|||||||
* Returns OAuth credentials where access == refresh == the user's API key.
|
* Returns OAuth credentials where access == refresh == the user's API key.
|
||||||
* The keys don't expire, so we set a far-future expiry.
|
* The keys don't expire, so we set a far-future expiry.
|
||||||
*/
|
*/
|
||||||
export async function login(
|
export async function login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||||
callbacks: OAuthLoginCallbacks,
|
const authServer = await startAuthServer()
|
||||||
): Promise<OAuthCredentials> {
|
const stateToken = generateStateToken()
|
||||||
const authServer = await startAuthServer();
|
|
||||||
const stateToken = generateStateToken();
|
|
||||||
|
|
||||||
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(`http://localhost:${authServer.port}/callback`)}&state=${encodeURIComponent(stateToken)}`;
|
const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(`http://localhost:${authServer.port}/callback`)}&state=${encodeURIComponent(stateToken)}`
|
||||||
|
|
||||||
// Tell pi to open the browser
|
// Tell pi to open the browser
|
||||||
callbacks.onAuth({ url: authUrl });
|
callbacks.onAuth({ url: authUrl })
|
||||||
|
|
||||||
// Wait for the Command Code Studio to POST the API key back
|
// Wait for the Command Code Studio to POST the API key back
|
||||||
let callback: { apiKey: string; state: string };
|
let callback: { apiKey: string; state: string }
|
||||||
try {
|
try {
|
||||||
callback = await authServer.waitForCallback;
|
callback = await authServer.waitForCallback
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up server on error
|
// Clean up server on error
|
||||||
authServer.server.close();
|
authServer.server.close()
|
||||||
throw error;
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate state token to prevent CSRF
|
// Validate state token to prevent CSRF
|
||||||
if (callback.state !== stateToken) {
|
if (callback.state !== stateToken) {
|
||||||
throw new Error(
|
authServer.server.close()
|
||||||
"State token mismatch. Authentication may have been tampered with.",
|
throw new Error("State token mismatch. Authentication may have been tampered with.")
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return as OAuth credentials. Since CC API keys don't expire,
|
// Return as OAuth credentials. Since CC API keys don't expire,
|
||||||
@@ -73,26 +70,24 @@ export async function login(
|
|||||||
refresh: callback.apiKey,
|
refresh: callback.apiKey,
|
||||||
access: callback.apiKey,
|
access: callback.apiKey,
|
||||||
expires: Date.now() + TEN_YEARS_MS,
|
expires: Date.now() + TEN_YEARS_MS,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command Code API keys don't expire, so "refresh" is a no-op.
|
* Command Code API keys don't expire, so "refresh" is a no-op.
|
||||||
* Returns the same credentials with an updated far-future expiry.
|
* Returns the same credentials with an updated far-future expiry.
|
||||||
*/
|
*/
|
||||||
export async function refreshToken(
|
export async function refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||||
credentials: OAuthCredentials,
|
|
||||||
): Promise<OAuthCredentials> {
|
|
||||||
return {
|
return {
|
||||||
refresh: credentials.refresh,
|
refresh: credentials.refresh,
|
||||||
access: credentials.access,
|
access: credentials.access,
|
||||||
expires: Date.now() + TEN_YEARS_MS,
|
expires: Date.now() + TEN_YEARS_MS,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the access token (API key) from OAuth credentials.
|
* Returns the access token (API key) from OAuth credentials.
|
||||||
*/
|
*/
|
||||||
export function getApiKey(credentials: OAuthCredentials): string {
|
export function getApiKey(credentials: OAuthCredentials): string {
|
||||||
return credentials.access;
|
return credentials.access
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-100
@@ -1,162 +1,156 @@
|
|||||||
export type StopReason = "stop" | "length" | "toolUse";
|
export type StopReason = "stop" | "length" | "toolUse"
|
||||||
export type ErrorReason = "error" | "aborted";
|
export type ErrorReason = "error" | "aborted"
|
||||||
export type TerminalReason = StopReason | ErrorReason;
|
export type TerminalReason = StopReason | ErrorReason
|
||||||
|
|
||||||
export interface UsageCost {
|
export interface UsageCost {
|
||||||
input: number;
|
input: number
|
||||||
output: number;
|
output: number
|
||||||
cacheRead: number;
|
cacheRead: number
|
||||||
cacheWrite: number;
|
cacheWrite: number
|
||||||
total: number;
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Usage {
|
export interface Usage {
|
||||||
input: number;
|
input: number
|
||||||
output: number;
|
output: number
|
||||||
cacheRead: number;
|
cacheRead: number
|
||||||
cacheWrite: number;
|
cacheWrite: number
|
||||||
totalTokens: number;
|
totalTokens: number
|
||||||
cost: UsageCost;
|
cost: UsageCost
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextContent {
|
export interface TextContent {
|
||||||
type: "text";
|
type: "text"
|
||||||
text: string;
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThinkingContent {
|
export interface ThinkingContent {
|
||||||
type: "thinking";
|
type: "thinking"
|
||||||
thinking: string;
|
thinking: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallContent {
|
export interface ToolCallContent {
|
||||||
type: "toolCall";
|
type: "toolCall"
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
arguments: Record<string, unknown>;
|
arguments: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssistantContent = TextContent | ThinkingContent | ToolCallContent;
|
export type AssistantContent = TextContent | ThinkingContent | ToolCallContent
|
||||||
|
|
||||||
export interface AssistantMessageLike {
|
export interface AssistantMessageLike {
|
||||||
role: "assistant";
|
role: "assistant"
|
||||||
content: AssistantContent[];
|
content: AssistantContent[]
|
||||||
api: unknown;
|
api: unknown
|
||||||
provider: string;
|
provider: string
|
||||||
model: string;
|
model: string
|
||||||
usage: Usage;
|
usage: Usage
|
||||||
stopReason: TerminalReason;
|
stopReason: TerminalReason
|
||||||
errorMessage?: string;
|
errorMessage?: string
|
||||||
timestamp: number;
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelLike {
|
export interface ModelLike {
|
||||||
id: string;
|
id: string
|
||||||
api: unknown;
|
api: unknown
|
||||||
provider: string;
|
provider: string
|
||||||
maxTokens: number;
|
maxTokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageLike {
|
export interface MessageLike {
|
||||||
role: string;
|
role: string
|
||||||
content?: unknown;
|
content?: unknown
|
||||||
toolCallId?: string;
|
toolCallId?: string
|
||||||
toolName?: string;
|
toolName?: string
|
||||||
isError?: boolean;
|
isError?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolLike {
|
export interface ToolLike {
|
||||||
name: string;
|
name: string
|
||||||
description?: string;
|
description?: string
|
||||||
parameters?: unknown;
|
parameters?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextLike {
|
export interface ContextLike {
|
||||||
systemPrompt?: string;
|
systemPrompt?: string
|
||||||
messages?: readonly MessageLike[];
|
messages?: readonly MessageLike[]
|
||||||
tools?: readonly ToolLike[];
|
tools?: readonly ToolLike[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderResponseInfo {
|
export interface ProviderResponseInfo {
|
||||||
status: number;
|
status: number
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamOptions {
|
export interface StreamOptions {
|
||||||
apiKey?: string;
|
apiKey?: string
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>
|
||||||
maxTokens?: number;
|
maxTokens?: number
|
||||||
onPayload?: (
|
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>
|
||||||
payload: unknown,
|
onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise<void>
|
||||||
model: ModelLike,
|
|
||||||
) => unknown | Promise<unknown>;
|
|
||||||
onResponse?: (
|
|
||||||
response: ProviderResponseInfo,
|
|
||||||
model: ModelLike,
|
|
||||||
) => void | Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssistantMessageEvent =
|
export type AssistantMessageEvent =
|
||||||
| { type: "start"; partial: AssistantMessageLike }
|
| { type: "start"; partial: AssistantMessageLike }
|
||||||
| { type: "text_start"; contentIndex: number; partial: AssistantMessageLike }
|
| { type: "text_start"; contentIndex: number; partial: AssistantMessageLike }
|
||||||
| {
|
| {
|
||||||
type: "text_delta";
|
type: "text_delta"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
delta: string;
|
delta: string
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "text_end";
|
type: "text_end"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
content: string;
|
content: string
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "thinking_start";
|
type: "thinking_start"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "thinking_delta";
|
type: "thinking_delta"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
delta: string;
|
delta: string
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "thinking_end";
|
type: "thinking_end"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
content: string;
|
content: string
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "toolcall_start";
|
type: "toolcall_start"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "toolcall_end";
|
type: "toolcall_end"
|
||||||
contentIndex: number;
|
contentIndex: number
|
||||||
toolCall: ToolCallContent;
|
toolCall: ToolCallContent
|
||||||
partial: AssistantMessageLike;
|
partial: AssistantMessageLike
|
||||||
}
|
}
|
||||||
| { type: "done"; reason: StopReason; message: AssistantMessageLike }
|
| { type: "done"; reason: StopReason; message: AssistantMessageLike }
|
||||||
| { type: "error"; reason: ErrorReason; error: AssistantMessageLike };
|
| { type: "error"; reason: ErrorReason; error: AssistantMessageLike }
|
||||||
|
|
||||||
export interface AssistantMessageEventStreamLike extends AsyncIterable<AssistantMessageEvent> {
|
export interface AssistantMessageEventStreamLike extends AsyncIterable<AssistantMessageEvent> {
|
||||||
push(event: AssistantMessageEvent): void;
|
push(event: AssistantMessageEvent): void
|
||||||
end(): void;
|
end(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoreDependencies {
|
export interface CoreDependencies {
|
||||||
createStream: () => AssistantMessageEventStreamLike;
|
createStream: () => AssistantMessageEventStreamLike
|
||||||
calculateCost: (model: ModelLike, usage: Usage) => void;
|
calculateCost: (model: ModelLike, usage: Usage) => void
|
||||||
apiBase?: string;
|
apiBase?: string
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch
|
||||||
authPaths?: readonly string[];
|
authPaths?: readonly string[]
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv
|
||||||
cwd?: () => string;
|
cwd?: () => string
|
||||||
now?: () => number;
|
now?: () => number
|
||||||
uuid?: () => string;
|
uuid?: () => string
|
||||||
homeDir?: () => string;
|
homeDir?: () => string
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-130
@@ -1,4 +1,4 @@
|
|||||||
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
|
import { createServer, type IncomingHttpHeaders, type Server } from "node:http"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createStreamCommandCode,
|
createStreamCommandCode,
|
||||||
@@ -8,74 +8,69 @@ import {
|
|||||||
type CoreDependencies,
|
type CoreDependencies,
|
||||||
type ModelLike,
|
type ModelLike,
|
||||||
type Usage,
|
type Usage,
|
||||||
} from "../src/core.ts";
|
} from "../src/core.ts"
|
||||||
|
|
||||||
export function createTestEventStream(): AssistantMessageEventStreamLike {
|
export function createTestEventStream(): AssistantMessageEventStreamLike {
|
||||||
const events: AssistantMessageEvent[] = [];
|
const events: AssistantMessageEvent[] = []
|
||||||
const waiters: Array<() => void> = [];
|
const waiters: Array<() => void> = []
|
||||||
let ended = false;
|
let ended = false
|
||||||
|
|
||||||
const wake = () => {
|
const wake = () => {
|
||||||
const waiter = waiters.shift();
|
const waiter = waiters.shift()
|
||||||
if (waiter) waiter();
|
if (waiter) waiter()
|
||||||
};
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
push(event: AssistantMessageEvent) {
|
push(event: AssistantMessageEvent) {
|
||||||
events.push(event);
|
events.push(event)
|
||||||
wake();
|
wake()
|
||||||
},
|
},
|
||||||
end() {
|
end() {
|
||||||
ended = true;
|
ended = true
|
||||||
while (waiters.length > 0) wake();
|
while (waiters.length > 0) wake()
|
||||||
},
|
},
|
||||||
[Symbol.asyncIterator]() {
|
[Symbol.asyncIterator]() {
|
||||||
let index = 0;
|
let index = 0
|
||||||
return {
|
return {
|
||||||
async next(): Promise<IteratorResult<AssistantMessageEvent>> {
|
async next(): Promise<IteratorResult<AssistantMessageEvent>> {
|
||||||
while (index >= events.length && !ended) {
|
while (index >= events.length && !ended) {
|
||||||
await new Promise<void>((resolve) => waiters.push(resolve));
|
await new Promise<void>((resolve) => waiters.push(resolve))
|
||||||
}
|
}
|
||||||
if (index < events.length) {
|
if (index < events.length) {
|
||||||
const value = events[index];
|
const value = events[index]
|
||||||
index += 1;
|
index += 1
|
||||||
return { done: false, value };
|
return { done: false, value }
|
||||||
}
|
}
|
||||||
return { done: true, value: undefined };
|
return { done: true, value: undefined }
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectEvents(
|
export async function collectEvents(
|
||||||
stream: AssistantMessageEventStreamLike,
|
stream: AssistantMessageEventStreamLike,
|
||||||
timeoutMs = 2_000,
|
timeoutMs = 2_000,
|
||||||
): Promise<AssistantMessageEvent[]> {
|
): Promise<AssistantMessageEvent[]> {
|
||||||
const events: AssistantMessageEvent[] = [];
|
const events: AssistantMessageEvent[] = []
|
||||||
|
|
||||||
const collect = async () => {
|
const collect = async () => {
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
events.push(event);
|
events.push(event)
|
||||||
if (event.type === "done" || event.type === "error") break;
|
if (event.type === "done" || event.type === "error") break
|
||||||
|
}
|
||||||
|
return events
|
||||||
}
|
}
|
||||||
return events;
|
|
||||||
};
|
|
||||||
|
|
||||||
return await Promise.race([
|
return await Promise.race([
|
||||||
collect(),
|
collect(),
|
||||||
new Promise<AssistantMessageEvent[]>((_, reject) => {
|
new Promise<AssistantMessageEvent[]>((_, reject) => {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() =>
|
() => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)),
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`Timed out collecting stream events after ${timeoutMs}ms`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
)
|
||||||
}),
|
}),
|
||||||
]);
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeModel(overrides: Partial<ModelLike> = {}): ModelLike {
|
export function makeModel(overrides: Partial<ModelLike> = {}): ModelLike {
|
||||||
@@ -85,7 +80,7 @@ export function makeModel(overrides: Partial<ModelLike> = {}): ModelLike {
|
|||||||
provider: "commandcode",
|
provider: "commandcode",
|
||||||
maxTokens: 384_000,
|
maxTokens: 384_000,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeContext(overrides: Partial<ContextLike> = {}): ContextLike {
|
export function makeContext(overrides: Partial<ContextLike> = {}): ContextLike {
|
||||||
@@ -94,25 +89,23 @@ export function makeContext(overrides: Partial<ContextLike> = {}): ContextLike {
|
|||||||
messages: [{ role: "user", content: "hello" }],
|
messages: [{ role: "user", content: "hello" }],
|
||||||
tools: [],
|
tools: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestDepsResult {
|
export interface TestDepsResult {
|
||||||
streamCommandCode: ReturnType<typeof createStreamCommandCode>;
|
streamCommandCode: ReturnType<typeof createStreamCommandCode>
|
||||||
calculatedUsages: Usage[];
|
calculatedUsages: Usage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTestDeps(
|
export function createTestDeps(overrides: Partial<CoreDependencies> = {}): TestDepsResult {
|
||||||
overrides: Partial<CoreDependencies> = {},
|
const calculatedUsages: Usage[] = []
|
||||||
): TestDepsResult {
|
|
||||||
const calculatedUsages: Usage[] = [];
|
|
||||||
const streamCommandCode = createStreamCommandCode({
|
const streamCommandCode = createStreamCommandCode({
|
||||||
createStream: createTestEventStream,
|
createStream: createTestEventStream,
|
||||||
calculateCost: (_model, usage) => {
|
calculateCost: (_model, usage) => {
|
||||||
calculatedUsages.push({
|
calculatedUsages.push({
|
||||||
...usage,
|
...usage,
|
||||||
cost: { ...usage.cost },
|
cost: { ...usage.cost },
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
env: {},
|
env: {},
|
||||||
authPaths: [],
|
authPaths: [],
|
||||||
@@ -120,162 +113,161 @@ export function createTestDeps(
|
|||||||
uuid: () => "00000000-0000-4000-8000-000000000000",
|
uuid: () => "00000000-0000-4000-8000-000000000000",
|
||||||
cwd: () => "/repo",
|
cwd: () => "/repo",
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
})
|
||||||
return { streamCommandCode, calculatedUsages };
|
return { streamCommandCode, calculatedUsages }
|
||||||
}
|
}
|
||||||
|
|
||||||
type SuccessPlan = {
|
type SuccessPlan = {
|
||||||
type: "success";
|
type: "success"
|
||||||
status?: number;
|
status?: number
|
||||||
events?: string[];
|
events?: string[]
|
||||||
chunks?: string[];
|
chunks?: string[]
|
||||||
delays?: number[];
|
delays?: number[]
|
||||||
hangAfterLast?: boolean;
|
hangAfterLast?: boolean
|
||||||
};
|
}
|
||||||
|
|
||||||
type ErrorPlan = {
|
type ErrorPlan = {
|
||||||
type: "error";
|
type: "error"
|
||||||
status: number;
|
status: number
|
||||||
body: string;
|
body: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export type ResponsePlan = SuccessPlan | ErrorPlan;
|
export type ResponsePlan = SuccessPlan | ErrorPlan
|
||||||
|
|
||||||
function headersToRecord(headers: IncomingHttpHeaders): Record<string, string> {
|
function headersToRecord(headers: IncomingHttpHeaders): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
if (typeof value === "string") out[key] = value;
|
if (typeof value === "string") out[key] = value
|
||||||
else if (Array.isArray(value)) out[key] = value.join(", ");
|
else if (Array.isArray(value)) out[key] = value.join(", ")
|
||||||
}
|
}
|
||||||
return out;
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockCommandCodeServer {
|
export interface MockCommandCodeServer {
|
||||||
baseUrl(): string;
|
baseUrl(): string
|
||||||
mockResponse(plan: ResponsePlan): void;
|
mockResponse(plan: ResponsePlan): void
|
||||||
reset(): void;
|
reset(): void
|
||||||
close(): Promise<void>;
|
close(): Promise<void>
|
||||||
lastRequestBody(): unknown;
|
lastRequestBody(): unknown
|
||||||
lastRequestHeaders(): Record<string, string>;
|
lastRequestHeaders(): Record<string, string>
|
||||||
requestCount(): number;
|
requestCount(): number
|
||||||
responseClosedBeforeEnd(): boolean;
|
responseClosedBeforeEnd(): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startMockCommandCodeServer(): Promise<MockCommandCodeServer> {
|
export async function startMockCommandCodeServer(): Promise<MockCommandCodeServer> {
|
||||||
let nextPlan: ResponsePlan = { type: "success", events: [] };
|
let nextPlan: ResponsePlan = { type: "success", events: [] }
|
||||||
let lastBody: unknown;
|
let lastBody: unknown
|
||||||
let lastHeaders: Record<string, string> = {};
|
let lastHeaders: Record<string, string> = {}
|
||||||
let requests = 0;
|
let requests = 0
|
||||||
let closedBeforeEnd = false;
|
let closedBeforeEnd = false
|
||||||
let port = 0;
|
let port = 0
|
||||||
|
|
||||||
const server: Server = createServer((req, res) => {
|
const server: Server = createServer((req, res) => {
|
||||||
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
||||||
res.writeHead(404);
|
res.writeHead(404)
|
||||||
res.end("Not found");
|
res.end("Not found")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requests += 1;
|
requests += 1
|
||||||
lastHeaders = headersToRecord(req.headers);
|
lastHeaders = headersToRecord(req.headers)
|
||||||
let body = "";
|
let body = ""
|
||||||
req.on("data", (chunk: Buffer) => {
|
req.on("data", (chunk: Buffer) => {
|
||||||
body += chunk.toString("utf-8");
|
body += chunk.toString("utf-8")
|
||||||
});
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(body);
|
const parsed: unknown = JSON.parse(body)
|
||||||
lastBody = parsed;
|
lastBody = parsed
|
||||||
} catch {
|
} catch {
|
||||||
lastBody = undefined;
|
lastBody = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = nextPlan;
|
const plan = nextPlan
|
||||||
if (plan.type === "error") {
|
if (plan.type === "error") {
|
||||||
res.writeHead(plan.status, { "Content-Type": "text/plain" });
|
res.writeHead(plan.status, { "Content-Type": "text/plain" })
|
||||||
res.end(plan.body);
|
res.end(plan.body)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(plan.status ?? 200, {
|
res.writeHead(plan.status ?? 200, {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
});
|
})
|
||||||
|
|
||||||
let ended = false;
|
let ended = false
|
||||||
res.on("close", () => {
|
res.on("close", () => {
|
||||||
if (!ended) closedBeforeEnd = true;
|
if (!ended) closedBeforeEnd = true
|
||||||
});
|
})
|
||||||
|
|
||||||
const chunks =
|
const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`)
|
||||||
plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`);
|
const delays = plan.delays ?? chunks.map(() => 0)
|
||||||
const delays = plan.delays ?? chunks.map(() => 0);
|
let index = 0
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
const sendNext = () => {
|
const sendNext = () => {
|
||||||
if (index >= chunks.length) {
|
if (index >= chunks.length) {
|
||||||
if (!plan.hangAfterLast) {
|
if (!plan.hangAfterLast) {
|
||||||
ended = true;
|
ended = true
|
||||||
res.end();
|
res.end()
|
||||||
}
|
}
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.write(chunks[index]);
|
res.write(chunks[index])
|
||||||
index += 1;
|
index += 1
|
||||||
if (index < chunks.length) {
|
if (index < chunks.length) {
|
||||||
setTimeout(sendNext, delays[index] ?? 0);
|
setTimeout(sendNext, delays[index] ?? 0)
|
||||||
} else if (!plan.hangAfterLast) {
|
} else if (!plan.hangAfterLast) {
|
||||||
ended = true;
|
ended = true
|
||||||
res.end();
|
res.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
sendNext();
|
sendNext()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
const address = server.address();
|
const address = server.address()
|
||||||
if (typeof address === "object" && address) port = address.port;
|
if (typeof address === "object" && address) port = address.port
|
||||||
resolve();
|
resolve()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: () => `http://127.0.0.1:${port}`,
|
baseUrl: () => `http://127.0.0.1:${port}`,
|
||||||
mockResponse(plan: ResponsePlan) {
|
mockResponse(plan: ResponsePlan) {
|
||||||
nextPlan = plan;
|
nextPlan = plan
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
nextPlan = { type: "success", events: [] };
|
nextPlan = { type: "success", events: [] }
|
||||||
lastBody = undefined;
|
lastBody = undefined
|
||||||
lastHeaders = {};
|
lastHeaders = {}
|
||||||
requests = 0;
|
requests = 0
|
||||||
closedBeforeEnd = false;
|
closedBeforeEnd = false
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
return new Promise<void>((resolve) => server.close(() => resolve()));
|
return new Promise<void>((resolve) => server.close(() => resolve()))
|
||||||
},
|
},
|
||||||
lastRequestBody: () => lastBody,
|
lastRequestBody: () => lastBody,
|
||||||
lastRequestHeaders: () => lastHeaders,
|
lastRequestHeaders: () => lastHeaders,
|
||||||
requestCount: () => requests,
|
requestCount: () => requests,
|
||||||
responseClosedBeforeEnd: () => closedBeforeEnd,
|
responseClosedBeforeEnd: () => closedBeforeEnd,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function objectAt(value: unknown, path: readonly string[]): unknown {
|
export function objectAt(value: unknown, path: readonly string[]): unknown {
|
||||||
let current = value;
|
let current = value
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
if (Array.isArray(current)) {
|
if (Array.isArray(current)) {
|
||||||
const index = Number(key);
|
const index = Number(key)
|
||||||
if (!Number.isInteger(index)) return undefined;
|
if (!Number.isInteger(index)) return undefined
|
||||||
current = current[index];
|
current = current[index]
|
||||||
continue;
|
continue
|
||||||
}
|
}
|
||||||
if (typeof current !== "object" || current === null) return undefined;
|
if (typeof current !== "object" || current === null) return undefined
|
||||||
current = Object.getOwnPropertyDescriptor(current, key)?.value;
|
current = Object.getOwnPropertyDescriptor(current, key)?.value
|
||||||
}
|
}
|
||||||
return current;
|
return current
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-41
@@ -2,8 +2,8 @@
|
|||||||
* Abort tests against the real streamCommandCode core.
|
* Abort tests against the real streamCommandCode core.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict"
|
||||||
import { after, before, beforeEach, describe, it } from "node:test";
|
import { after, before, beforeEach, describe, it } from "node:test"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
collectEvents,
|
collectEvents,
|
||||||
@@ -12,77 +12,74 @@ import {
|
|||||||
makeModel,
|
makeModel,
|
||||||
startMockCommandCodeServer,
|
startMockCommandCodeServer,
|
||||||
type MockCommandCodeServer,
|
type MockCommandCodeServer,
|
||||||
} from "./helpers.ts";
|
} from "./helpers.ts"
|
||||||
|
|
||||||
let server: MockCommandCodeServer;
|
let server: MockCommandCodeServer
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await startMockCommandCodeServer();
|
server = await startMockCommandCodeServer()
|
||||||
});
|
})
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await server.close();
|
await server.close()
|
||||||
});
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.reset();
|
server.reset()
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("streamCommandCode — abort behavior", () => {
|
describe("streamCommandCode — abort behavior", () => {
|
||||||
it("emits aborted error when signal is already aborted", async () => {
|
it("emits aborted error when signal is already aborted", async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController()
|
||||||
controller.abort();
|
controller.abort()
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), {
|
streamCommandCode(makeModel(), makeContext(), {
|
||||||
apiKey: "mock-key",
|
apiKey: "mock-key",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
events.map((event) => event.type),
|
events.map((event) => event.type),
|
||||||
["start", "error"],
|
["start", "error"],
|
||||||
);
|
)
|
||||||
const error = events.at(-1);
|
const error = events.at(-1)
|
||||||
assert.equal(error?.type, "error");
|
assert.equal(error?.type, "error")
|
||||||
if (error?.type !== "error") throw new Error("expected error");
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
assert.equal(error.reason, "aborted");
|
assert.equal(error.reason, "aborted")
|
||||||
assert.equal(error.error.stopReason, "aborted");
|
assert.equal(error.error.stopReason, "aborted")
|
||||||
assert.equal(server.requestCount(), 0);
|
assert.equal(server.requestCount(), 0)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("emits aborted error and cancels the response reader mid-stream", async () => {
|
it("emits aborted error and cancels the response reader mid-stream", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
events: [JSON.stringify({ type: "text-delta", text: "first" })],
|
events: [JSON.stringify({ type: "text-delta", text: "first" })],
|
||||||
hangAfterLast: true,
|
hangAfterLast: true,
|
||||||
});
|
})
|
||||||
const controller = new AbortController();
|
const controller = new AbortController()
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const stream = streamCommandCode(makeModel(), makeContext(), {
|
const stream = streamCommandCode(makeModel(), makeContext(), {
|
||||||
apiKey: "mock-key",
|
apiKey: "mock-key",
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
})
|
||||||
|
|
||||||
setTimeout(() => controller.abort(), 50);
|
setTimeout(() => controller.abort(), 50)
|
||||||
const events = await collectEvents(stream, 2_000);
|
const events = await collectEvents(stream, 2_000)
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
events.some((event) => event.type === "text_delta"),
|
events.some((event) => event.type === "text_delta"),
|
||||||
"stream should process data before abort",
|
"stream should process data before abort",
|
||||||
);
|
)
|
||||||
const error = events.at(-1);
|
const error = events.at(-1)
|
||||||
assert.equal(error?.type, "error");
|
assert.equal(error?.type, "error")
|
||||||
if (error?.type !== "error") throw new Error("expected error");
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
assert.equal(error.reason, "aborted");
|
assert.equal(error.reason, "aborted")
|
||||||
assert.equal(error.error.errorMessage, "Request aborted");
|
assert.equal(error.error.errorMessage, "Request aborted")
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
assert.ok(
|
assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response")
|
||||||
server.responseClosedBeforeEnd(),
|
})
|
||||||
"abort should close the hanging upstream response",
|
})
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+95
-96
@@ -5,15 +5,15 @@
|
|||||||
* integration functions (src/oauth.ts).
|
* integration functions (src/oauth.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict"
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
import { startAuthServer, type AuthCallback } from "../src/auth-server.ts";
|
import { startAuthServer, type AuthCallback } from "../src/auth-server.ts"
|
||||||
import { getApiKey, login, refreshToken } from "../src/oauth.ts";
|
import { getApiKey, login, refreshToken } from "../src/oauth.ts"
|
||||||
|
|
||||||
describe("startAuthServer()", () => {
|
describe("startAuthServer()", () => {
|
||||||
it("starts on a random port and accepts a valid callback POST", async () => {
|
it("starts on a random port and accepts a valid callback POST", async () => {
|
||||||
const { server, port, waitForCallback } = await startAuthServer();
|
const { server, port, waitForCallback } = await startAuthServer()
|
||||||
|
|
||||||
const callbackData: AuthCallback = {
|
const callbackData: AuthCallback = {
|
||||||
apiKey: "user_testKey123",
|
apiKey: "user_testKey123",
|
||||||
@@ -21,7 +21,7 @@ describe("startAuthServer()", () => {
|
|||||||
userId: "user_123",
|
userId: "user_123",
|
||||||
userName: "Test User",
|
userName: "Test User",
|
||||||
keyName: "test-key",
|
keyName: "test-key",
|
||||||
};
|
}
|
||||||
|
|
||||||
// Simulate the Command Code Studio posting the API key back
|
// Simulate the Command Code Studio posting the API key back
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
@@ -31,25 +31,28 @@ describe("startAuthServer()", () => {
|
|||||||
Origin: "https://commandcode.ai",
|
Origin: "https://commandcode.ai",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(callbackData),
|
body: JSON.stringify(callbackData),
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200)
|
||||||
const body = (await response.json()) as { success: boolean };
|
const body = (await response.json()) as { success: boolean }
|
||||||
assert.equal(body.success, true);
|
assert.equal(body.success, true)
|
||||||
|
|
||||||
const result = await waitForCallback;
|
const result = await waitForCallback
|
||||||
assert.equal(result.apiKey, "user_testKey123");
|
assert.equal(result.apiKey, "user_testKey123")
|
||||||
assert.equal(result.state, "test-state-token");
|
assert.equal(result.state, "test-state-token")
|
||||||
assert.equal(result.userId, "user_123");
|
assert.equal(result.userId, "user_123")
|
||||||
assert.equal(result.userName, "Test User");
|
assert.equal(result.userName, "Test User")
|
||||||
assert.equal(result.keyName, "test-key");
|
assert.equal(result.keyName, "test-key")
|
||||||
|
|
||||||
// Server should close after successful callback
|
// Server closes itself after callback; ensure it's done
|
||||||
await new Promise((resolve) => server.on("close", resolve));
|
await new Promise((resolve) => {
|
||||||
});
|
if (!server.listening) return resolve(undefined)
|
||||||
|
server.on("close", resolve)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("rejects when the callback indicates access_denied", async () => {
|
it("rejects when the callback indicates access_denied", async () => {
|
||||||
const { server, port, waitForCallback } = await startAuthServer();
|
const { server, port, waitForCallback } = await startAuthServer()
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -61,17 +64,20 @@ describe("startAuthServer()", () => {
|
|||||||
error: "access_denied",
|
error: "access_denied",
|
||||||
error_description: "User cancelled",
|
error_description: "User cancelled",
|
||||||
}),
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200)
|
||||||
|
|
||||||
await assert.rejects(() => waitForCallback, /User cancelled/);
|
await assert.rejects(() => waitForCallback, /User cancelled/)
|
||||||
|
|
||||||
await new Promise((resolve) => server.on("close", resolve));
|
await new Promise((resolve) => {
|
||||||
});
|
if (!server.listening) return resolve(undefined)
|
||||||
|
server.on("close", resolve)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("returns 400 for missing required fields", async () => {
|
it("returns 400 for missing required fields", async () => {
|
||||||
const { server, port } = await startAuthServer();
|
const { server, port } = await startAuthServer()
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -80,57 +86,57 @@ describe("startAuthServer()", () => {
|
|||||||
Origin: "https://commandcode.ai",
|
Origin: "https://commandcode.ai",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ apiKey: "key", state: "s" }),
|
body: JSON.stringify({ apiKey: "key", state: "s" }),
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 400);
|
assert.equal(response.status, 400)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
server.close();
|
server.close()
|
||||||
});
|
})
|
||||||
|
|
||||||
it("handles CORS preflight OPTIONS request", async () => {
|
it("handles CORS preflight OPTIONS request", async () => {
|
||||||
const { server, port } = await startAuthServer();
|
const { server, port } = await startAuthServer()
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
method: "OPTIONS",
|
method: "OPTIONS",
|
||||||
headers: { Origin: "https://commandcode.ai" },
|
headers: { Origin: "https://commandcode.ai" },
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 204);
|
assert.equal(response.status, 204)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
server.close();
|
server.close()
|
||||||
});
|
})
|
||||||
|
|
||||||
it("returns 404 for non-callback paths", async () => {
|
it("returns 404 for non-callback paths", async () => {
|
||||||
const { server, port } = await startAuthServer();
|
const { server, port } = await startAuthServer()
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/other`, {
|
const response = await fetch(`http://127.0.0.1:${port}/other`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: "{}",
|
body: "{}",
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 404);
|
assert.equal(response.status, 404)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
server.close();
|
server.close()
|
||||||
});
|
})
|
||||||
|
|
||||||
it("returns 405 for GET on /callback", async () => {
|
it("returns 405 for GET on /callback", async () => {
|
||||||
const { server, port } = await startAuthServer();
|
const { server, port } = await startAuthServer()
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Origin: "https://commandcode.ai" },
|
headers: { Origin: "https://commandcode.ai" },
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 405);
|
assert.equal(response.status, 405)
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
server.close();
|
server.close()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("OAuth functions", () => {
|
describe("OAuth functions", () => {
|
||||||
it("getApiKey returns the access token", () => {
|
it("getApiKey returns the access token", () => {
|
||||||
@@ -138,53 +144,51 @@ describe("OAuth functions", () => {
|
|||||||
refresh: "refresh-key",
|
refresh: "refresh-key",
|
||||||
access: "access-key",
|
access: "access-key",
|
||||||
expires: Date.now() + 3600000,
|
expires: Date.now() + 3600000,
|
||||||
};
|
}
|
||||||
assert.equal(getApiKey(creds), "access-key");
|
assert.equal(getApiKey(creds), "access-key")
|
||||||
});
|
})
|
||||||
|
|
||||||
it("refreshToken returns updated far-future expiry", async () => {
|
it("refreshToken returns updated far-future expiry", async () => {
|
||||||
const creds = {
|
const creds = {
|
||||||
refresh: "my-api-key",
|
refresh: "my-api-key",
|
||||||
access: "my-api-key",
|
access: "my-api-key",
|
||||||
expires: Date.now() - 1000, // already expired
|
expires: Date.now() - 1000, // already expired
|
||||||
};
|
}
|
||||||
const result = await refreshToken(creds);
|
const result = await refreshToken(creds)
|
||||||
assert.equal(result.access, "my-api-key");
|
assert.equal(result.access, "my-api-key")
|
||||||
assert.equal(result.refresh, "my-api-key");
|
assert.equal(result.refresh, "my-api-key")
|
||||||
assert.ok(result.expires > Date.now(), "expiry should be in the future");
|
assert.ok(result.expires > Date.now(), "expiry should be in the future")
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("login()", () => {
|
describe("login()", () => {
|
||||||
it("completes the full browser login flow via the local server", async () => {
|
it("completes the full browser login flow via the local server", async () => {
|
||||||
let authUrl = "";
|
let authUrl = ""
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
onAuth(params: { url: string }) {
|
onAuth(params: { url: string }) {
|
||||||
authUrl = params.url;
|
authUrl = params.url
|
||||||
},
|
},
|
||||||
onPrompt(params: { message: string }): Promise<string> {
|
onPrompt(params: { message: string }): Promise<string> {
|
||||||
throw new Error("onPrompt should not be called in browser flow");
|
throw new Error("onPrompt should not be called in browser flow")
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
// Start login in the background
|
// Start login in the background
|
||||||
const loginPromise = login(callbacks);
|
const loginPromise = login(callbacks)
|
||||||
|
|
||||||
// Verify the auth URL was passed to callbacks
|
// Verify the auth URL was passed to callbacks
|
||||||
assert.match(
|
assert.match(
|
||||||
authUrl,
|
authUrl,
|
||||||
/^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=http:\/\/localhost:\d+\/callback&state=/,
|
/^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=http:\/\/localhost:\d+\/callback&state=/,
|
||||||
);
|
)
|
||||||
|
|
||||||
// Extract port and state from the URL
|
// Extract port and state from the URL
|
||||||
const url = new URL(authUrl);
|
const url = new URL(authUrl)
|
||||||
const port = parseInt(
|
const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0")
|
||||||
url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0",
|
const state = url.searchParams.get("state") ?? ""
|
||||||
);
|
|
||||||
const state = url.searchParams.get("state") ?? "";
|
|
||||||
|
|
||||||
assert.ok(port > 0, "auth server should be on a non-zero port");
|
assert.ok(port > 0, "auth server should be on a non-zero port")
|
||||||
assert.ok(state.length > 0, "state token should not be empty");
|
assert.ok(state.length > 0, "state token should not be empty")
|
||||||
|
|
||||||
// Simulate the Command Code Studio posting the API key back
|
// Simulate the Command Code Studio posting the API key back
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
const response = await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
@@ -200,36 +204,31 @@ describe("login()", () => {
|
|||||||
userName: "Browser User",
|
userName: "Browser User",
|
||||||
keyName: "browser-key",
|
keyName: "browser-key",
|
||||||
}),
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200)
|
||||||
|
|
||||||
const result = await loginPromise;
|
const result = await loginPromise
|
||||||
assert.equal(result.access, "user_browserApiKey");
|
assert.equal(result.access, "user_browserApiKey")
|
||||||
assert.equal(result.refresh, "user_browserApiKey");
|
assert.equal(result.refresh, "user_browserApiKey")
|
||||||
assert.ok(
|
assert.ok(result.expires > Date.now(), "expiry should be far in the future")
|
||||||
result.expires > Date.now(),
|
})
|
||||||
"expiry should be far in the future",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects on state token mismatch", async () => {
|
it("rejects on state token mismatch", async () => {
|
||||||
let authUrl = "";
|
let authUrl = ""
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
onAuth(params: { url: string }) {
|
onAuth(params: { url: string }) {
|
||||||
authUrl = params.url;
|
authUrl = params.url
|
||||||
},
|
},
|
||||||
onPrompt(params: { message: string }): Promise<string> {
|
onPrompt(params: { message: string }): Promise<string> {
|
||||||
throw new Error("should not prompt");
|
throw new Error("should not prompt")
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
const loginPromise = login(callbacks);
|
const loginPromise = login(callbacks)
|
||||||
|
|
||||||
const url = new URL(authUrl);
|
const url = new URL(authUrl)
|
||||||
const port = parseInt(
|
const port = parseInt(url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0")
|
||||||
url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Post back with a wrong state token
|
// Post back with a wrong state token
|
||||||
await fetch(`http://127.0.0.1:${port}/callback`, {
|
await fetch(`http://127.0.0.1:${port}/callback`, {
|
||||||
@@ -245,8 +244,8 @@ describe("login()", () => {
|
|||||||
userName: "Attacker",
|
userName: "Attacker",
|
||||||
keyName: "evil-key",
|
keyName: "evil-key",
|
||||||
}),
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
await assert.rejects(() => loginPromise, /State token mismatch/);
|
await assert.rejects(() => loginPromise, /State token mismatch/)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
+137
-167
@@ -4,8 +4,8 @@
|
|||||||
* Command Code API is replaced by a deterministic local mock server.
|
* Command Code API is replaced by a deterministic local mock server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict"
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process"
|
||||||
import {
|
import {
|
||||||
accessSync,
|
accessSync,
|
||||||
constants,
|
constants,
|
||||||
@@ -14,123 +14,118 @@ import {
|
|||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs"
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http"
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os"
|
||||||
import { delimiter, dirname, join, resolve } from "node:path";
|
import { delimiter, dirname, join, resolve } from "node:path"
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const PROJECT_DIR = resolve(__dirname, "..");
|
const PROJECT_DIR = resolve(__dirname, "..")
|
||||||
const EXT_PATH = resolve(PROJECT_DIR, "index.ts");
|
const EXT_PATH = resolve(PROJECT_DIR, "index.ts")
|
||||||
const TEST_MODEL = "deepseek/deepseek-v4-flash";
|
const TEST_MODEL = "deepseek/deepseek-v4-flash"
|
||||||
|
|
||||||
function findPiBinary() {
|
function findPiBinary() {
|
||||||
if (process.env.PI_BIN) return process.env.PI_BIN;
|
if (process.env.PI_BIN) return process.env.PI_BIN
|
||||||
const localBin = resolve(PROJECT_DIR, "node_modules", ".bin");
|
const localBin = resolve(PROJECT_DIR, "node_modules", ".bin")
|
||||||
const candidates = (process.env.PATH ?? "")
|
const candidates = (process.env.PATH ?? "")
|
||||||
.split(delimiter)
|
.split(delimiter)
|
||||||
.map((entry) => resolve(entry, "pi"))
|
.map((entry) => resolve(entry, "pi"))
|
||||||
.filter((candidate) => !candidate.startsWith(localBin));
|
.filter((candidate) => !candidate.startsWith(localBin))
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
accessSync(candidate, constants.X_OK);
|
accessSync(candidate, constants.X_OK)
|
||||||
return candidate;
|
return candidate
|
||||||
} catch {
|
} catch {
|
||||||
// Try next PATH entry.
|
// Try next PATH entry.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const PI_BIN = findPiBinary();
|
const PI_BIN = findPiBinary()
|
||||||
if (!PI_BIN) {
|
if (!PI_BIN) {
|
||||||
console.log("[pi-local] SKIP — pi is not on PATH");
|
console.log("[pi-local] SKIP — pi is not on PATH")
|
||||||
process.exit(0);
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const piCheck = spawnSync(PI_BIN, ["--help"], { stdio: "ignore" });
|
const piCheck = spawnSync(PI_BIN, ["--help"], { stdio: "ignore" })
|
||||||
if (piCheck.error) {
|
if (piCheck.error) {
|
||||||
console.log(`[pi-local] SKIP — pi failed to start: ${piCheck.error.message}`);
|
console.log(`[pi-local] SKIP — pi failed to start: ${piCheck.error.message}`)
|
||||||
process.exit(0);
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestCount = 0;
|
let requestCount = 0
|
||||||
let lastRequestBody;
|
let lastRequestBody
|
||||||
let lastRequestHeaders = {};
|
let lastRequestHeaders = {}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
if (req.method !== "POST" || req.url !== "/alpha/generate") {
|
||||||
res.writeHead(404);
|
res.writeHead(404)
|
||||||
res.end("Not found");
|
res.end("Not found")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestCount += 1;
|
requestCount += 1
|
||||||
lastRequestHeaders = Object.fromEntries(
|
lastRequestHeaders = Object.fromEntries(
|
||||||
Object.entries(req.headers).map(([key, value]) => [
|
Object.entries(req.headers).map(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
Array.isArray(value) ? value.join(", ") : (value ?? ""),
|
Array.isArray(value) ? value.join(", ") : (value ?? ""),
|
||||||
]),
|
]),
|
||||||
);
|
)
|
||||||
|
|
||||||
let body = "";
|
let body = ""
|
||||||
req.on("data", (chunk) => {
|
req.on("data", (chunk) => {
|
||||||
body += chunk.toString("utf-8");
|
body += chunk.toString("utf-8")
|
||||||
});
|
})
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
try {
|
try {
|
||||||
lastRequestBody = JSON.parse(body);
|
lastRequestBody = JSON.parse(body)
|
||||||
} catch {
|
} catch {
|
||||||
lastRequestBody = undefined;
|
lastRequestBody = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": "text/plain; charset=utf-8",
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
"Transfer-Encoding": "chunked",
|
"Transfer-Encoding": "chunked",
|
||||||
});
|
})
|
||||||
res.write(
|
res.write(`${JSON.stringify({ type: "text-delta", text: "mock-pi-ok" })}\n`)
|
||||||
`${JSON.stringify({ type: "text-delta", text: "mock-pi-ok" })}\n`,
|
|
||||||
);
|
|
||||||
res.write(
|
res.write(
|
||||||
`${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`,
|
`${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`,
|
||||||
);
|
)
|
||||||
res.end();
|
res.end()
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
await new Promise((resolve) => server.listen(0, resolve));
|
await new Promise((resolve) => server.listen(0, resolve))
|
||||||
const address = server.address();
|
const address = server.address()
|
||||||
const port = typeof address === "object" && address ? address.port : 0;
|
const port = typeof address === "object" && address ? address.port : 0
|
||||||
const apiBase = `http://127.0.0.1:${port}`;
|
const apiBase = `http://127.0.0.1:${port}`
|
||||||
|
|
||||||
function hasLivePiAuth() {
|
function hasLivePiAuth() {
|
||||||
return (
|
return (
|
||||||
!!process.env.COMMANDCODE_API_KEY ||
|
!!process.env.COMMANDCODE_API_KEY ||
|
||||||
existsSync(join(homedir(), ".commandcode", "auth.json")) ||
|
existsSync(join(homedir(), ".commandcode", "auth.json")) ||
|
||||||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
|
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let tempHome;
|
let tempHome
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
COMMANDCODE_API_BASE: apiBase,
|
COMMANDCODE_API_BASE: apiBase,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (hasLivePiAuth()) {
|
if (hasLivePiAuth()) {
|
||||||
console.log("[pi-local] using live pi auth");
|
console.log("[pi-local] using live pi auth")
|
||||||
} else {
|
} else {
|
||||||
console.log("[pi-local] live pi auth not found; using mock auth fallback");
|
console.log("[pi-local] live pi auth not found; using mock auth fallback")
|
||||||
tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-"));
|
tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-"))
|
||||||
mkdirSync(join(tempHome, ".commandcode"), { recursive: true });
|
mkdirSync(join(tempHome, ".commandcode"), { recursive: true })
|
||||||
writeFileSync(
|
writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" }))
|
||||||
join(tempHome, ".commandcode", "auth.json"),
|
env.HOME = tempHome
|
||||||
JSON.stringify({ apiKey: "mock-key" }),
|
env.USERPROFILE = tempHome
|
||||||
);
|
env.COMMANDCODE_API_KEY = "mock-key"
|
||||||
env.HOME = tempHome;
|
|
||||||
env.USERPROFILE = tempHome;
|
|
||||||
env.COMMANDCODE_API_KEY = "mock-key";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runPi(args, timeoutMs = 30_000) {
|
function runPi(args, timeoutMs = 30_000) {
|
||||||
@@ -139,125 +134,109 @@ function runPi(args, timeoutMs = 30_000) {
|
|||||||
cwd: PROJECT_DIR,
|
cwd: PROJECT_DIR,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
})
|
||||||
let stdout = "";
|
let stdout = ""
|
||||||
let stderr = "";
|
let stderr = ""
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill();
|
child.kill()
|
||||||
resolve({
|
resolve({
|
||||||
code: -1,
|
code: -1,
|
||||||
stdout,
|
stdout,
|
||||||
stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms`,
|
stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms`,
|
||||||
});
|
})
|
||||||
}, timeoutMs);
|
}, timeoutMs)
|
||||||
child.stdout.on("data", (chunk) => {
|
child.stdout.on("data", (chunk) => {
|
||||||
stdout += chunk.toString("utf-8");
|
stdout += chunk.toString("utf-8")
|
||||||
});
|
})
|
||||||
child.stderr.on("data", (chunk) => {
|
child.stderr.on("data", (chunk) => {
|
||||||
stderr += chunk.toString("utf-8");
|
stderr += chunk.toString("utf-8")
|
||||||
});
|
})
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
resolve({ code, stdout, stderr });
|
resolve({ code, stdout, stderr })
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRpcQuery(timeoutMs = 30_000) {
|
async function runRpcQuery(timeoutMs = 30_000) {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
PI_BIN,
|
PI_BIN,
|
||||||
[
|
["--mode", "rpc", "-e", EXT_PATH, "--provider", "commandcode", "--model", TEST_MODEL],
|
||||||
"--mode",
|
|
||||||
"rpc",
|
|
||||||
"-e",
|
|
||||||
EXT_PATH,
|
|
||||||
"--provider",
|
|
||||||
"commandcode",
|
|
||||||
"--model",
|
|
||||||
TEST_MODEL,
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
cwd: PROJECT_DIR,
|
cwd: PROJECT_DIR,
|
||||||
env,
|
env,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = ""
|
||||||
let stderr = "";
|
let stderr = ""
|
||||||
let buffer = "";
|
let buffer = ""
|
||||||
let sawPromptAccepted = false;
|
let sawPromptAccepted = false
|
||||||
let sawAssistantMessage = false;
|
let sawAssistantMessage = false
|
||||||
let sawTextDelta = false;
|
let sawTextDelta = false
|
||||||
const events = [];
|
const events = []
|
||||||
|
|
||||||
const done = new Promise((resolve) => {
|
const done = new Promise((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill();
|
child.kill()
|
||||||
resolve(false);
|
resolve(false)
|
||||||
}, timeoutMs);
|
}, timeoutMs)
|
||||||
|
|
||||||
const finish = (ok) => {
|
const finish = (ok) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
try {
|
try {
|
||||||
child.stdin.write(`${JSON.stringify({ type: "quit" })}\n`);
|
child.stdin.write(`${JSON.stringify({ type: "quit" })}\n`)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore shutdown race
|
// ignore shutdown race
|
||||||
}
|
}
|
||||||
child.kill();
|
child.kill()
|
||||||
resolve(ok);
|
resolve(ok)
|
||||||
};
|
}
|
||||||
|
|
||||||
child.stdin.write(
|
child.stdin.write(
|
||||||
`${JSON.stringify({ id: "prompt-1", type: "prompt", message: "say mock token" })}\n`,
|
`${JSON.stringify({ id: "prompt-1", type: "prompt", message: "say mock token" })}\n`,
|
||||||
);
|
)
|
||||||
|
|
||||||
child.stdout.on("data", (chunk) => {
|
child.stdout.on("data", (chunk) => {
|
||||||
const text = chunk.toString("utf-8");
|
const text = chunk.toString("utf-8")
|
||||||
stdout += text;
|
stdout += text
|
||||||
buffer += text;
|
buffer += text
|
||||||
const lines = buffer.split("\n");
|
const lines = buffer.split("\n")
|
||||||
buffer = lines.pop() ?? "";
|
buffer = lines.pop() ?? ""
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(trimmed);
|
const event = JSON.parse(trimmed)
|
||||||
events.push(event);
|
events.push(event)
|
||||||
if (
|
if (event.type === "response" && event.id === "prompt-1" && event.success === true) {
|
||||||
event.type === "response" &&
|
sawPromptAccepted = true
|
||||||
event.id === "prompt-1" &&
|
|
||||||
event.success === true
|
|
||||||
) {
|
|
||||||
sawPromptAccepted = true;
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
event.type === "message_update" &&
|
event.type === "message_update" &&
|
||||||
event.assistantMessageEvent?.type === "text_delta"
|
event.assistantMessageEvent?.type === "text_delta"
|
||||||
) {
|
) {
|
||||||
sawTextDelta = true;
|
sawTextDelta = true
|
||||||
}
|
}
|
||||||
if (
|
if (event.type === "message_end" && event.message?.role === "assistant") {
|
||||||
event.type === "message_end" &&
|
sawAssistantMessage = true
|
||||||
event.message?.role === "assistant"
|
finish(true)
|
||||||
) {
|
|
||||||
sawAssistantMessage = true;
|
|
||||||
finish(true);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore non-JSON output
|
// ignore non-JSON output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
child.stderr.on("data", (chunk) => {
|
child.stderr.on("data", (chunk) => {
|
||||||
stderr += chunk.toString("utf-8");
|
stderr += chunk.toString("utf-8")
|
||||||
});
|
})
|
||||||
child.on("close", () => {
|
child.on("close", () => {
|
||||||
if (!sawAssistantMessage) finish(false);
|
if (!sawAssistantMessage) finish(false)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
const ok = await done;
|
const ok = await done
|
||||||
return {
|
return {
|
||||||
ok,
|
ok,
|
||||||
stdout,
|
stdout,
|
||||||
@@ -266,44 +245,35 @@ async function runRpcQuery(timeoutMs = 30_000) {
|
|||||||
sawPromptAccepted,
|
sawPromptAccepted,
|
||||||
sawAssistantMessage,
|
sawAssistantMessage,
|
||||||
sawTextDelta,
|
sawTextDelta,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[pi-local] list models through real extension");
|
console.log("[pi-local] list models through real extension")
|
||||||
const list = await runPi(["-e", EXT_PATH, "--list-models"], 20_000);
|
const list = await runPi(["-e", EXT_PATH, "--list-models"], 20_000)
|
||||||
assert.equal(list.code, 0, list.stderr);
|
assert.equal(list.code, 0, list.stderr)
|
||||||
assert.match(list.stdout, /commandcode/);
|
assert.match(list.stdout, /commandcode/)
|
||||||
assert.match(list.stdout, /deepseek\/deepseek-v4-flash/);
|
assert.match(list.stdout, /deepseek\/deepseek-v4-flash/)
|
||||||
|
|
||||||
console.log("[pi-local] print mode through real extension and mock API");
|
console.log("[pi-local] print mode through real extension and mock API")
|
||||||
requestCount = 0;
|
requestCount = 0
|
||||||
const print = await runPi(
|
const print = await runPi(
|
||||||
[
|
["-e", EXT_PATH, "-p", "say mock token", "--provider", "commandcode", "--model", TEST_MODEL],
|
||||||
"-e",
|
|
||||||
EXT_PATH,
|
|
||||||
"-p",
|
|
||||||
"say mock token",
|
|
||||||
"--provider",
|
|
||||||
"commandcode",
|
|
||||||
"--model",
|
|
||||||
TEST_MODEL,
|
|
||||||
],
|
|
||||||
30_000,
|
30_000,
|
||||||
);
|
)
|
||||||
assert.equal(print.code, 0, print.stderr);
|
assert.equal(print.code, 0, print.stderr)
|
||||||
assert.match(print.stdout, /mock-pi-ok/);
|
assert.match(print.stdout, /mock-pi-ok/)
|
||||||
assert.equal(requestCount, 1);
|
assert.equal(requestCount, 1)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
typeof lastRequestHeaders.authorization === "string" &&
|
typeof lastRequestHeaders.authorization === "string" &&
|
||||||
lastRequestHeaders.authorization.startsWith("Bearer "),
|
lastRequestHeaders.authorization.startsWith("Bearer "),
|
||||||
"should send a bearer Authorization header",
|
"should send a bearer Authorization header",
|
||||||
);
|
)
|
||||||
assert.equal(lastRequestBody?.params?.model, TEST_MODEL);
|
assert.equal(lastRequestBody?.params?.model, TEST_MODEL)
|
||||||
|
|
||||||
console.log("[pi-local] RPC prompt through real extension and mock API");
|
console.log("[pi-local] RPC prompt through real extension and mock API")
|
||||||
requestCount = 0;
|
requestCount = 0
|
||||||
const rpc = await runRpcQuery();
|
const rpc = await runRpcQuery()
|
||||||
assert.equal(
|
assert.equal(
|
||||||
rpc.ok,
|
rpc.ok,
|
||||||
true,
|
true,
|
||||||
@@ -312,14 +282,14 @@ try {
|
|||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
assert.equal(rpc.sawPromptAccepted, true);
|
assert.equal(rpc.sawPromptAccepted, true)
|
||||||
assert.equal(rpc.sawAssistantMessage, true);
|
assert.equal(rpc.sawAssistantMessage, true)
|
||||||
assert.equal(rpc.sawTextDelta, true);
|
assert.equal(rpc.sawTextDelta, true)
|
||||||
assert.equal(requestCount, 1);
|
assert.equal(requestCount, 1)
|
||||||
|
|
||||||
console.log("[pi-local] PASS");
|
console.log("[pi-local] PASS")
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise((resolve) => server.close(resolve));
|
await new Promise((resolve) => server.close(resolve))
|
||||||
if (tempHome) rmSync(tempHome, { recursive: true, force: true });
|
if (tempHome) rmSync(tempHome, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-124
@@ -3,11 +3,11 @@
|
|||||||
* These are hermetic: no pi runtime and no network.
|
* These are hermetic: no pi runtime and no network.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict"
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path";
|
import { join } from "node:path"
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getApiKey,
|
getApiKey,
|
||||||
@@ -18,26 +18,23 @@ import {
|
|||||||
textContent,
|
textContent,
|
||||||
toJsonSchema,
|
toJsonSchema,
|
||||||
toolsToJson,
|
toolsToJson,
|
||||||
} from "../src/core.ts";
|
} from "../src/core.ts"
|
||||||
|
|
||||||
import { objectAt } from "./helpers.ts";
|
import { objectAt } from "./helpers.ts"
|
||||||
|
|
||||||
describe("getApiKey()", () => {
|
describe("getApiKey()", () => {
|
||||||
it("uses COMMANDCODE_API_KEY from provided env", () => {
|
it("uses COMMANDCODE_API_KEY from provided env", () => {
|
||||||
assert.equal(
|
assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key")
|
||||||
getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }),
|
})
|
||||||
"env-key",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads apiKey, commandcode, and pi OAuth credential fields from explicit auth paths", () => {
|
it("reads apiKey, commandcode, and pi OAuth credential fields from explicit auth paths", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "cc-auth-"));
|
const dir = mkdtempSync(join(tmpdir(), "cc-auth-"))
|
||||||
try {
|
try {
|
||||||
const first = join(dir, "first.json");
|
const first = join(dir, "first.json")
|
||||||
const second = join(dir, "second.json");
|
const second = join(dir, "second.json")
|
||||||
const oauth = join(dir, "oauth.json");
|
const oauth = join(dir, "oauth.json")
|
||||||
writeFileSync(first, JSON.stringify({ apiKey: "file-key" }));
|
writeFileSync(first, JSON.stringify({ apiKey: "file-key" }))
|
||||||
writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" }));
|
writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" }))
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
oauth,
|
oauth,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -48,47 +45,38 @@ describe("getApiKey()", () => {
|
|||||||
expires: Date.now() + 3600000,
|
expires: Date.now() + 3600000,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
assert.equal(
|
assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key")
|
||||||
getApiKey({ env: {}, authPaths: [first, second] }),
|
assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key")
|
||||||
"file-key",
|
assert.equal(getApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key")
|
||||||
);
|
|
||||||
assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key");
|
|
||||||
assert.equal(
|
|
||||||
getApiKey({ env: {}, authPaths: [oauth] }),
|
|
||||||
"oauth-access-key",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it("ignores malformed auth files", () => {
|
it("ignores malformed auth files", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-"));
|
const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-"))
|
||||||
try {
|
try {
|
||||||
const bad = join(dir, "bad.json");
|
const bad = join(dir, "bad.json")
|
||||||
writeFileSync(bad, "not json");
|
writeFileSync(bad, "not json")
|
||||||
assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined);
|
assert.equal(getApiKey({ env: {}, authPaths: [bad] }), undefined)
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it("uses injected homeDir for default auth paths", () => {
|
it("uses injected homeDir for default auth paths", () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "cc-home-"));
|
const dir = mkdtempSync(join(tmpdir(), "cc-home-"))
|
||||||
try {
|
try {
|
||||||
const authDir = join(dir, ".pi", "agent");
|
const authDir = join(dir, ".pi", "agent")
|
||||||
mkdirSync(authDir, { recursive: true });
|
mkdirSync(authDir, { recursive: true })
|
||||||
writeFileSync(
|
writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" }))
|
||||||
join(authDir, "auth.json"),
|
assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key")
|
||||||
JSON.stringify({ commandcode: "pi-key" }),
|
|
||||||
);
|
|
||||||
assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key");
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("textContent()", () => {
|
describe("textContent()", () => {
|
||||||
it("extracts and joins text blocks", () => {
|
it("extracts and joins text blocks", () => {
|
||||||
@@ -101,35 +89,32 @@ describe("textContent()", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
"hello\nworld",
|
"hello\nworld",
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("handles empty or missing content", () => {
|
it("handles empty or missing content", () => {
|
||||||
assert.equal(textContent({ content: [] }), "");
|
assert.equal(textContent({ content: [] }), "")
|
||||||
assert.equal(textContent({}), "");
|
assert.equal(textContent({}), "")
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("getEnvironmentInfo()", () => {
|
describe("getEnvironmentInfo()", () => {
|
||||||
it("returns 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"));
|
assert.ok(info.includes("Node.js"))
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("toJsonSchema()", () => {
|
describe("toJsonSchema()", () => {
|
||||||
it("converts scalar, enum, object, optional, array, and union schema shapes", () => {
|
it("converts scalar, enum, object, optional, array, and union schema shapes", () => {
|
||||||
assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" });
|
assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" })
|
||||||
assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" });
|
assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" })
|
||||||
assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" });
|
assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" })
|
||||||
assert.deepEqual(
|
assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), {
|
||||||
toJsonSchema({ kind: "string", enum: ["left", "right"] }),
|
|
||||||
{
|
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["left", "right"],
|
enum: ["left", "right"],
|
||||||
},
|
})
|
||||||
);
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
toJsonSchema({
|
toJsonSchema({
|
||||||
kind: "object",
|
kind: "object",
|
||||||
@@ -146,16 +131,14 @@ describe("toJsonSchema()", () => {
|
|||||||
},
|
},
|
||||||
required: ["name"],
|
required: ["name"],
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
assert.deepEqual(
|
assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), {
|
||||||
toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }),
|
type: "string",
|
||||||
{ type: "string" },
|
})
|
||||||
);
|
assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), {
|
||||||
assert.deepEqual(
|
type: "number",
|
||||||
toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }),
|
})
|
||||||
{ type: "number" },
|
})
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves explicit required arrays and handles unknown values", () => {
|
it("preserves explicit required arrays and handles unknown values", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -169,11 +152,11 @@ describe("toJsonSchema()", () => {
|
|||||||
properties: { name: { type: "string" }, nickname: { type: "string" } },
|
properties: { name: { type: "string" }, nickname: { type: "string" } },
|
||||||
required: ["name"],
|
required: ["name"],
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
assert.deepEqual(toJsonSchema(undefined), {});
|
assert.deepEqual(toJsonSchema(undefined), {})
|
||||||
assert.deepEqual(toJsonSchema({ kind: "wat" }), {});
|
assert.deepEqual(toJsonSchema({ kind: "wat" }), {})
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("toolsToJson()", () => {
|
describe("toolsToJson()", () => {
|
||||||
it("converts pi tools to Command Code tool JSON", () => {
|
it("converts pi tools to Command Code tool JSON", () => {
|
||||||
@@ -200,13 +183,13 @@ describe("toolsToJson()", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("returns an empty array for missing tools", () => {
|
it("returns an empty array for missing tools", () => {
|
||||||
assert.deepEqual(toolsToJson(), []);
|
assert.deepEqual(toolsToJson(), [])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("messagesToCC()", () => {
|
describe("messagesToCC()", () => {
|
||||||
it("converts user, assistant, and tool result messages", () => {
|
it("converts user, assistant, and tool result messages", () => {
|
||||||
@@ -235,18 +218,15 @@ describe("messagesToCC()", () => {
|
|||||||
{ type: "text", text: "world" },
|
{ type: "text", text: "world" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
])
|
||||||
|
|
||||||
assert.equal(objectAt(result, ["0", "role"]), "user");
|
assert.equal(objectAt(result, ["0", "role"]), "user")
|
||||||
assert.equal(objectAt(result, ["1", "role"]), "assistant");
|
assert.equal(objectAt(result, ["1", "role"]), "assistant")
|
||||||
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning");
|
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning")
|
||||||
assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call");
|
assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call")
|
||||||
assert.equal(objectAt(result, ["2", "role"]), "tool");
|
assert.equal(objectAt(result, ["2", "role"]), "tool")
|
||||||
assert.equal(
|
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld")
|
||||||
objectAt(result, ["2", "content", "0", "output", "value"]),
|
})
|
||||||
"hello\nworld",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops orphaned tool calls that have no matching tool result", () => {
|
it("drops orphaned tool calls that have no matching tool result", () => {
|
||||||
const result = messagesToCC([
|
const result = messagesToCC([
|
||||||
@@ -263,46 +243,43 @@ describe("messagesToCC()", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
])
|
||||||
|
|
||||||
assert.equal(objectAt(result, ["1", "role"]), "assistant");
|
assert.equal(objectAt(result, ["1", "role"]), "assistant")
|
||||||
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text");
|
assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text")
|
||||||
assert.equal(objectAt(result, ["1", "content", "1"]), undefined);
|
assert.equal(objectAt(result, ["1", "content", "1"]), undefined)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("handles empty conversations", () => {
|
it("handles empty conversations", () => {
|
||||||
assert.deepEqual(messagesToCC([]), []);
|
assert.deepEqual(messagesToCC([]), [])
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("parseStreamEventLine()", () => {
|
describe("parseStreamEventLine()", () => {
|
||||||
it("parses plain JSON and SSE data lines", () => {
|
it("parses plain JSON and SSE data lines", () => {
|
||||||
assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), {
|
assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), {
|
||||||
type: "text-delta",
|
type: "text-delta",
|
||||||
text: "x",
|
text: "x",
|
||||||
});
|
})
|
||||||
assert.deepEqual(
|
assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), {
|
||||||
parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'),
|
|
||||||
{
|
|
||||||
type: "finish",
|
type: "finish",
|
||||||
finishReason: "stop",
|
finishReason: "stop",
|
||||||
},
|
})
|
||||||
);
|
})
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores comments, event labels, done markers, and malformed JSON", () => {
|
it("ignores comments, event labels, done markers, and malformed JSON", () => {
|
||||||
assert.equal(parseStreamEventLine(":"), undefined);
|
assert.equal(parseStreamEventLine(":"), undefined)
|
||||||
assert.equal(parseStreamEventLine("event: message"), undefined);
|
assert.equal(parseStreamEventLine("event: message"), undefined)
|
||||||
assert.equal(parseStreamEventLine("data: [DONE]"), undefined);
|
assert.equal(parseStreamEventLine("data: [DONE]"), undefined)
|
||||||
assert.equal(parseStreamEventLine("not-json"), undefined);
|
assert.equal(parseStreamEventLine("not-json"), undefined)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("mapFinishReason()", () => {
|
describe("mapFinishReason()", () => {
|
||||||
it("maps provider finish reasons to pi stop reasons", () => {
|
it("maps provider finish reasons to pi stop reasons", () => {
|
||||||
assert.equal(mapFinishReason("stop"), "stop");
|
assert.equal(mapFinishReason("stop"), "stop")
|
||||||
assert.equal(mapFinishReason("tool-calls"), "toolUse");
|
assert.equal(mapFinishReason("tool-calls"), "toolUse")
|
||||||
assert.equal(mapFinishReason("max_tokens"), "length");
|
assert.equal(mapFinishReason("max_tokens"), "length")
|
||||||
assert.equal(mapFinishReason("max_output_tokens"), "length");
|
assert.equal(mapFinishReason("max_output_tokens"), "length")
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
+178
-225
@@ -10,55 +10,55 @@
|
|||||||
* Requires: pi on PATH plus COMMANDCODE_API_KEY or live pi auth files.
|
* Requires: pi on PATH plus COMMANDCODE_API_KEY or live pi auth files.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process"
|
||||||
import { accessSync, constants, existsSync } from "node:fs";
|
import { accessSync, constants, existsSync } from "node:fs"
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os"
|
||||||
import { delimiter, resolve, dirname, join } from "node:path";
|
import { delimiter, resolve, dirname, join } from "node:path"
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const PROJECT_DIR = resolve(__dirname, "..");
|
const PROJECT_DIR = resolve(__dirname, "..")
|
||||||
const EXT_PATH = resolve(PROJECT_DIR, "index.ts");
|
const EXT_PATH = resolve(PROJECT_DIR, "index.ts")
|
||||||
const TEST_MODEL = "deepseek/deepseek-v4-flash";
|
const TEST_MODEL = "deepseek/deepseek-v4-flash"
|
||||||
|
|
||||||
function findPiBinary() {
|
function findPiBinary() {
|
||||||
if (process.env.PI_BIN) return process.env.PI_BIN;
|
if (process.env.PI_BIN) return process.env.PI_BIN
|
||||||
const localBin = resolve(PROJECT_DIR, "node_modules", ".bin");
|
const localBin = resolve(PROJECT_DIR, "node_modules", ".bin")
|
||||||
const candidates = (process.env.PATH ?? "")
|
const candidates = (process.env.PATH ?? "")
|
||||||
.split(delimiter)
|
.split(delimiter)
|
||||||
.map((entry) => resolve(entry, "pi"))
|
.map((entry) => resolve(entry, "pi"))
|
||||||
.filter((candidate) => !candidate.startsWith(localBin));
|
.filter((candidate) => !candidate.startsWith(localBin))
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
accessSync(candidate, constants.X_OK);
|
accessSync(candidate, constants.X_OK)
|
||||||
return candidate;
|
return candidate
|
||||||
} catch {
|
} catch {
|
||||||
// Try next PATH entry.
|
// Try next PATH entry.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const PI_BIN = findPiBinary();
|
const PI_BIN = findPiBinary()
|
||||||
const HAS_PI = !!PI_BIN;
|
const HAS_PI = !!PI_BIN
|
||||||
|
|
||||||
const PRINT_MODE_TIMEOUT = 120_000; // 2 minutes for print mode
|
const PRINT_MODE_TIMEOUT = 120_000 // 2 minutes for print mode
|
||||||
const RPC_START_TIMEOUT = 15_000;
|
const RPC_START_TIMEOUT = 15_000
|
||||||
const RPC_QUERY_TIMEOUT = 60_000;
|
const RPC_QUERY_TIMEOUT = 60_000
|
||||||
|
|
||||||
function hasCommandCodeAuth() {
|
function hasCommandCodeAuth() {
|
||||||
return (
|
return (
|
||||||
!!process.env.COMMANDCODE_API_KEY ||
|
!!process.env.COMMANDCODE_API_KEY ||
|
||||||
existsSync(join(homedir(), ".commandcode", "auth.json")) ||
|
existsSync(join(homedir(), ".commandcode", "auth.json")) ||
|
||||||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
|
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const HAS_AUTH = hasCommandCodeAuth();
|
const HAS_AUTH = hasCommandCodeAuth()
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0
|
||||||
let failed = 0;
|
let failed = 0
|
||||||
let skipped = 0;
|
let skipped = 0
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -66,7 +66,7 @@ let skipped = 0;
|
|||||||
|
|
||||||
function kill(child) {
|
function kill(child) {
|
||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill()
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -78,22 +78,20 @@ function kill(child) {
|
|||||||
|
|
||||||
async function runPrintMode() {
|
async function runPrintMode() {
|
||||||
if (!HAS_AUTH) {
|
if (!HAS_AUTH) {
|
||||||
console.log(
|
console.log("[smoke] SKIP — Command Code auth not found, skipping print mode test\n")
|
||||||
"[smoke] SKIP — Command Code auth not found, skipping print mode test\n",
|
skipped++
|
||||||
);
|
return
|
||||||
skipped++;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!HAS_PI) {
|
if (!HAS_PI) {
|
||||||
console.log("[smoke] SKIP — pi is not on PATH, skipping print mode test\n");
|
console.log("[smoke] SKIP — pi is not on PATH, skipping print mode test\n")
|
||||||
skipped++;
|
skipped++
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[smoke] Running pi in print mode with extension: ${EXT_PATH}`);
|
console.log(`[smoke] Running pi in print mode with extension: ${EXT_PATH}`)
|
||||||
console.log(
|
console.log(
|
||||||
`[smoke] ${PI_BIN} -e ${EXT_PATH} -p "say hi" --provider commandcode --model ${TEST_MODEL}\n`,
|
`[smoke] ${PI_BIN} -e ${EXT_PATH} -p "say hi" --provider commandcode --model ${TEST_MODEL}\n`,
|
||||||
);
|
)
|
||||||
|
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
PI_BIN,
|
PI_BIN,
|
||||||
@@ -111,47 +109,41 @@ async function runPrintMode() {
|
|||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = ""
|
||||||
let stderr = "";
|
let stderr = ""
|
||||||
|
|
||||||
child.stdout.on("data", (d) => {
|
child.stdout.on("data", (d) => {
|
||||||
stdout += d.toString();
|
stdout += d.toString()
|
||||||
});
|
})
|
||||||
child.stderr.on("data", (d) => {
|
child.stderr.on("data", (d) => {
|
||||||
stderr += d.toString();
|
stderr += d.toString()
|
||||||
});
|
})
|
||||||
|
|
||||||
const done = new Promise((resolve) => {
|
const done = new Promise((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
kill(child);
|
kill(child)
|
||||||
console.log("[smoke] TIMEOUT — pi print mode took too long");
|
console.log("[smoke] TIMEOUT — pi print mode took too long")
|
||||||
resolve(false);
|
resolve(false)
|
||||||
}, PRINT_MODE_TIMEOUT);
|
}, PRINT_MODE_TIMEOUT)
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
console.log(
|
console.log("[smoke] PASS — extension loaded and agent ran without crash")
|
||||||
"[smoke] PASS — extension loaded and agent ran without crash",
|
console.log(`[smoke] stdout (last 300 chars): ${stdout.slice(-300).trim()}`)
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[smoke] stdout (last 300 chars): ${stdout.slice(-300).trim()}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`[smoke] FAIL — exit code ${code}`);
|
console.log(`[smoke] FAIL — exit code ${code}`)
|
||||||
console.log(
|
console.log(`[smoke] stderr (last 500 chars): ${stderr.slice(-500).trim()}`)
|
||||||
`[smoke] stderr (last 500 chars): ${stderr.slice(-500).trim()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
resolve(code === 0);
|
resolve(code === 0)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
const ok = await done;
|
const ok = await done
|
||||||
if (ok) passed++;
|
if (ok) passed++
|
||||||
else failed++;
|
else failed++
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -160,59 +152,51 @@ async function runPrintMode() {
|
|||||||
|
|
||||||
async function runListModels() {
|
async function runListModels() {
|
||||||
if (!HAS_AUTH) {
|
if (!HAS_AUTH) {
|
||||||
console.log(
|
console.log("[smoke] SKIP — Command Code auth not found, skipping model list test\n")
|
||||||
"[smoke] SKIP — Command Code auth not found, skipping model list test\n",
|
skipped++
|
||||||
);
|
return
|
||||||
skipped++;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!HAS_PI) {
|
if (!HAS_PI) {
|
||||||
console.log("[smoke] SKIP — pi is not on PATH, skipping model list test\n");
|
console.log("[smoke] SKIP — pi is not on PATH, skipping model list test\n")
|
||||||
skipped++;
|
skipped++
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`[smoke] Checking that models are discoverable via pi --list-models\n`)
|
||||||
`[smoke] Checking that models are discoverable via pi --list-models\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const child = spawn(PI_BIN, ["-e", EXT_PATH, "--list-models"], {
|
const child = spawn(PI_BIN, ["-e", EXT_PATH, "--list-models"], {
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
})
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = ""
|
||||||
|
|
||||||
child.stdout.on("data", (d) => {
|
child.stdout.on("data", (d) => {
|
||||||
stdout += d.toString();
|
stdout += d.toString()
|
||||||
});
|
})
|
||||||
|
|
||||||
const done = new Promise((resolve) => {
|
const done = new Promise((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
kill(child);
|
kill(child)
|
||||||
console.log("[smoke] TIMEOUT — model listing took too long");
|
console.log("[smoke] TIMEOUT — model listing took too long")
|
||||||
resolve(false);
|
resolve(false)
|
||||||
}, 15_000);
|
}, 15_000)
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
if (code === 0 && stdout.includes("commandcode")) {
|
if (code === 0 && stdout.includes("commandcode")) {
|
||||||
console.log("[smoke] PASS — commandcode provider models are listed");
|
console.log("[smoke] PASS — commandcode provider models are listed")
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log("[smoke] FAIL — commandcode models not found or error listing")
|
||||||
"[smoke] FAIL — commandcode models not found or error listing",
|
console.log(`[smoke] stdout (last 500 chars): ${stdout.slice(-500).trim()}`)
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`[smoke] stdout (last 500 chars): ${stdout.slice(-500).trim()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
resolve(code === 0 && stdout.includes("commandcode"));
|
resolve(code === 0 && stdout.includes("commandcode"))
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
const ok = await done;
|
const ok = await done
|
||||||
if (ok) passed++;
|
if (ok) passed++
|
||||||
else failed++;
|
else failed++
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -221,98 +205,88 @@ async function runListModels() {
|
|||||||
|
|
||||||
async function runRpcStartup() {
|
async function runRpcStartup() {
|
||||||
if (!HAS_AUTH) {
|
if (!HAS_AUTH) {
|
||||||
console.log(
|
console.log("[smoke] SKIP — Command Code auth not found, skipping RPC startup test\n")
|
||||||
"[smoke] SKIP — Command Code auth not found, skipping RPC startup test\n",
|
skipped++
|
||||||
);
|
return
|
||||||
skipped++;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!HAS_PI) {
|
if (!HAS_PI) {
|
||||||
console.log(
|
console.log("[smoke] SKIP — pi is not on PATH, skipping RPC startup test\n")
|
||||||
"[smoke] SKIP — pi is not on PATH, skipping RPC startup test\n",
|
skipped++
|
||||||
);
|
return
|
||||||
skipped++;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[smoke] Testing RPC mode startup with extension\n`);
|
console.log(`[smoke] Testing RPC mode startup with extension\n`)
|
||||||
console.log(`[smoke] ${PI_BIN} --mode rpc -e ${EXT_PATH}\n`);
|
console.log(`[smoke] ${PI_BIN} --mode rpc -e ${EXT_PATH}\n`)
|
||||||
|
|
||||||
const child = spawn(PI_BIN, ["--mode", "rpc", "-e", EXT_PATH], {
|
const child = spawn(PI_BIN, ["--mode", "rpc", "-e", EXT_PATH], {
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
})
|
||||||
|
|
||||||
let sawStateResponse = false;
|
let sawStateResponse = false
|
||||||
let sawError = false;
|
let sawError = false
|
||||||
const events = [];
|
const events = []
|
||||||
|
|
||||||
let buf = "";
|
let buf = ""
|
||||||
child.stdout.on("data", (chunk) => {
|
child.stdout.on("data", (chunk) => {
|
||||||
buf += chunk.toString("utf-8");
|
buf += chunk.toString("utf-8")
|
||||||
const lines = buf.split("\n");
|
const lines = buf.split("\n")
|
||||||
buf = lines.pop() ?? "";
|
buf = lines.pop() ?? ""
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(trimmed);
|
const msg = JSON.parse(trimmed)
|
||||||
events.push(msg);
|
events.push(msg)
|
||||||
if (
|
if (
|
||||||
msg.type === "response" &&
|
msg.type === "response" &&
|
||||||
msg.id === "state-1" &&
|
msg.id === "state-1" &&
|
||||||
msg.command === "get_state" &&
|
msg.command === "get_state" &&
|
||||||
msg.success === true
|
msg.success === true
|
||||||
) {
|
) {
|
||||||
sawStateResponse = true;
|
sawStateResponse = true
|
||||||
console.log("[smoke] RPC received get_state response");
|
console.log("[smoke] RPC received get_state response")
|
||||||
}
|
}
|
||||||
if (msg.type === "error" || msg.type === "fatal") {
|
if (msg.type === "error" || msg.type === "fatal") {
|
||||||
sawError = true;
|
sawError = true
|
||||||
console.error(
|
console.error(`[smoke] RPC error: ${JSON.stringify(msg).slice(0, 300)}`)
|
||||||
`[smoke] RPC error: ${JSON.stringify(msg).slice(0, 300)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore non-JSON
|
// ignore non-JSON
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const result = new Promise((resolve) => {
|
const result = new Promise((resolve) => {
|
||||||
child.stdin.write(
|
child.stdin.write(JSON.stringify({ id: "state-1", type: "get_state" }) + "\n")
|
||||||
JSON.stringify({ id: "state-1", type: "get_state" }) + "\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
if (sawStateResponse) {
|
if (sawStateResponse) {
|
||||||
console.log("[smoke] PASS — extension loaded and RPC get_state works");
|
console.log("[smoke] PASS — extension loaded and RPC get_state works")
|
||||||
resolve(true);
|
resolve(true)
|
||||||
} else {
|
} else {
|
||||||
console.log("[smoke] FAIL — get_state response not received");
|
console.log("[smoke] FAIL — get_state response not received")
|
||||||
resolve(false);
|
resolve(false)
|
||||||
}
|
}
|
||||||
// Send quit
|
// Send quit
|
||||||
try {
|
try {
|
||||||
child.stdin.write(JSON.stringify({ type: "quit" }) + "\n");
|
child.stdin.write(JSON.stringify({ type: "quit" }) + "\n")
|
||||||
} catch {}
|
} catch {}
|
||||||
kill(child);
|
kill(child)
|
||||||
}, RPC_START_TIMEOUT);
|
}, RPC_START_TIMEOUT)
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
if (!sawStateResponse && !sawError) {
|
if (!sawStateResponse && !sawError) {
|
||||||
console.log(
|
console.log(`[smoke] FAIL — pi exited with code ${code} before get_state response`)
|
||||||
`[smoke] FAIL — pi exited with code ${code} before get_state response`,
|
resolve(false)
|
||||||
);
|
|
||||||
resolve(false);
|
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
const ok = await result;
|
const ok = await result
|
||||||
if (ok) passed++;
|
if (ok) passed++
|
||||||
else failed++;
|
else failed++
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -321,73 +295,58 @@ async function runRpcStartup() {
|
|||||||
|
|
||||||
async function runRpcQuery() {
|
async function runRpcQuery() {
|
||||||
if (!HAS_AUTH) {
|
if (!HAS_AUTH) {
|
||||||
console.log(
|
console.log("[smoke] SKIP — Command Code auth not found, skipping RPC prompt test\n")
|
||||||
"[smoke] SKIP — Command Code auth not found, skipping RPC prompt test\n",
|
skipped++
|
||||||
);
|
return
|
||||||
skipped++;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!HAS_PI) {
|
if (!HAS_PI) {
|
||||||
console.log("[smoke] SKIP — pi is not on PATH, skipping RPC prompt test\n");
|
console.log("[smoke] SKIP — pi is not on PATH, skipping RPC prompt test\n")
|
||||||
skipped++;
|
skipped++
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[smoke] Testing RPC prompt flow\n`);
|
console.log(`[smoke] Testing RPC prompt flow\n`)
|
||||||
console.log(
|
console.log(`[smoke] pi --mode rpc -e ${EXT_PATH} → prompt "say hi" → expect response\n`)
|
||||||
`[smoke] pi --mode rpc -e ${EXT_PATH} → prompt "say hi" → expect response\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
PI_BIN,
|
PI_BIN,
|
||||||
[
|
["--mode", "rpc", "-e", EXT_PATH, "--provider", "commandcode", "--model", TEST_MODEL],
|
||||||
"--mode",
|
|
||||||
"rpc",
|
|
||||||
"-e",
|
|
||||||
EXT_PATH,
|
|
||||||
"--provider",
|
|
||||||
"commandcode",
|
|
||||||
"--model",
|
|
||||||
TEST_MODEL,
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
let sawPromptAccepted = false;
|
let sawPromptAccepted = false
|
||||||
let sawAssistantMessage = false;
|
let sawAssistantMessage = false
|
||||||
|
|
||||||
let buf = "";
|
let buf = ""
|
||||||
child.stdout.on("data", (chunk) => {
|
child.stdout.on("data", (chunk) => {
|
||||||
buf += chunk.toString("utf-8");
|
buf += chunk.toString("utf-8")
|
||||||
const lines = buf.split("\n");
|
const lines = buf.split("\n")
|
||||||
buf = lines.pop() ?? "";
|
buf = lines.pop() ?? ""
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim()
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(trimmed);
|
const msg = JSON.parse(trimmed)
|
||||||
if (
|
if (
|
||||||
msg.type === "response" &&
|
msg.type === "response" &&
|
||||||
msg.id === "prompt-1" &&
|
msg.id === "prompt-1" &&
|
||||||
msg.command === "prompt" &&
|
msg.command === "prompt" &&
|
||||||
msg.success === true
|
msg.success === true
|
||||||
) {
|
) {
|
||||||
sawPromptAccepted = true;
|
sawPromptAccepted = true
|
||||||
}
|
}
|
||||||
if (msg.type === "message_end" && msg.message?.role === "assistant") {
|
if (msg.type === "message_end" && msg.message?.role === "assistant") {
|
||||||
sawAssistantMessage = true;
|
sawAssistantMessage = true
|
||||||
console.log(
|
console.log("[smoke] PASS — received assistant message_end in RPC mode")
|
||||||
"[smoke] PASS — received assistant message_end in RPC mode",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
const result = new Promise((resolve) => {
|
const result = new Promise((resolve) => {
|
||||||
child.stdin.write(
|
child.stdin.write(
|
||||||
@@ -396,63 +355,57 @@ async function runRpcQuery() {
|
|||||||
type: "prompt",
|
type: "prompt",
|
||||||
message: "say hi in one word",
|
message: "say hi in one word",
|
||||||
}) + "\n",
|
}) + "\n",
|
||||||
);
|
)
|
||||||
console.log("[smoke] Sent RPC prompt");
|
console.log("[smoke] Sent RPC prompt")
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (sawPromptAccepted && sawAssistantMessage) {
|
if (sawPromptAccepted && sawAssistantMessage) {
|
||||||
console.log("[smoke] PASS — full RPC prompt/response cycle works");
|
console.log("[smoke] PASS — full RPC prompt/response cycle works")
|
||||||
resolve(true);
|
resolve(true)
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log("[smoke] WARN — no assistant message_end received (may still be streaming)")
|
||||||
"[smoke] WARN — no assistant message_end received (may still be streaming)",
|
resolve(false)
|
||||||
);
|
|
||||||
resolve(false);
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
child.stdin.write(JSON.stringify({ type: "quit" }) + "\n");
|
child.stdin.write(JSON.stringify({ type: "quit" }) + "\n")
|
||||||
} catch {}
|
} catch {}
|
||||||
kill(child);
|
kill(child)
|
||||||
}, RPC_QUERY_TIMEOUT);
|
}, RPC_QUERY_TIMEOUT)
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer)
|
||||||
if (!sawAssistantMessage) {
|
if (!sawAssistantMessage) {
|
||||||
console.log(`[smoke] FAIL — pi exited before assistant message_end`);
|
console.log(`[smoke] FAIL — pi exited before assistant message_end`)
|
||||||
resolve(false);
|
resolve(false)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
const ok = await result;
|
const ok = await result
|
||||||
if (ok) passed++;
|
if (ok) passed++
|
||||||
else failed++;
|
else failed++
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Main
|
// Main
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
console.log("=".repeat(60));
|
console.log("=".repeat(60))
|
||||||
console.log(" pi-commandcode-provider Integration Smoke Test");
|
console.log(" pi-commandcode-provider Integration Smoke Test")
|
||||||
console.log("=".repeat(60));
|
console.log("=".repeat(60))
|
||||||
console.log(
|
console.log(` Auth: ${HAS_AUTH ? "✓ found" : "✗ not found (tests will be skipped)"}`)
|
||||||
` Auth: ${HAS_AUTH ? "✓ found" : "✗ not found (tests will be skipped)"}`,
|
console.log(` Extension: ${EXT_PATH}`)
|
||||||
);
|
console.log("=".repeat(60))
|
||||||
console.log(` Extension: ${EXT_PATH}`);
|
console.log("")
|
||||||
console.log("=".repeat(60));
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
await runPrintMode();
|
await runPrintMode()
|
||||||
await runListModels();
|
await runListModels()
|
||||||
await runRpcStartup();
|
await runRpcStartup()
|
||||||
await runRpcQuery();
|
await runRpcQuery()
|
||||||
|
|
||||||
console.log("");
|
console.log("")
|
||||||
console.log("=".repeat(60));
|
console.log("=".repeat(60))
|
||||||
console.log(
|
console.log(` SUITE RESULT: ${passed} passed, ${failed} failed, ${skipped} skipped`)
|
||||||
` SUITE RESULT: ${passed} passed, ${failed} failed, ${skipped} skipped`,
|
console.log("=".repeat(60))
|
||||||
);
|
|
||||||
console.log("=".repeat(60));
|
|
||||||
|
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0)
|
||||||
|
|||||||
+129
-159
@@ -3,10 +3,10 @@
|
|||||||
* Command Code server. No real API key or pi runtime required.
|
* Command Code server. No real API key or pi runtime required.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict"
|
||||||
import { after, before, beforeEach, describe, it } from "node:test";
|
import { after, before, beforeEach, describe, it } from "node:test"
|
||||||
|
|
||||||
import type { AssistantMessageEvent } from "../src/core.ts";
|
import type { AssistantMessageEvent } from "../src/core.ts"
|
||||||
import {
|
import {
|
||||||
collectEvents,
|
collectEvents,
|
||||||
createTestDeps,
|
createTestDeps,
|
||||||
@@ -15,24 +15,24 @@ import {
|
|||||||
objectAt,
|
objectAt,
|
||||||
startMockCommandCodeServer,
|
startMockCommandCodeServer,
|
||||||
type MockCommandCodeServer,
|
type MockCommandCodeServer,
|
||||||
} from "./helpers.ts";
|
} from "./helpers.ts"
|
||||||
|
|
||||||
let server: MockCommandCodeServer;
|
let server: MockCommandCodeServer
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await startMockCommandCodeServer();
|
server = await startMockCommandCodeServer()
|
||||||
});
|
})
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await server.close();
|
await server.close()
|
||||||
});
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.reset();
|
server.reset()
|
||||||
});
|
})
|
||||||
|
|
||||||
function eventTypes(events: readonly AssistantMessageEvent[]): string[] {
|
function eventTypes(events: readonly AssistantMessageEvent[]): string[] {
|
||||||
return events.map((event) => event.type);
|
return events.map((event) => event.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("streamCommandCode — auth", () => {
|
describe("streamCommandCode — auth", () => {
|
||||||
@@ -41,39 +41,34 @@ describe("streamCommandCode — auth", () => {
|
|||||||
apiBase: server.baseUrl(),
|
apiBase: server.baseUrl(),
|
||||||
env: {},
|
env: {},
|
||||||
authPaths: [],
|
authPaths: [],
|
||||||
});
|
})
|
||||||
const stream = streamCommandCode(makeModel(), makeContext(), {
|
const stream = streamCommandCode(makeModel(), makeContext(), {
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
});
|
})
|
||||||
const events = await collectEvents(stream);
|
const events = await collectEvents(stream)
|
||||||
|
|
||||||
assert.deepEqual(eventTypes(events), ["error"]);
|
assert.deepEqual(eventTypes(events), ["error"])
|
||||||
assert.equal(events[0].type, "error");
|
assert.equal(events[0].type, "error")
|
||||||
assert.equal(events[0].reason, "error");
|
assert.equal(events[0].reason, "error")
|
||||||
assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/);
|
assert.match(events[0].error.errorMessage ?? "", /No Command Code API key/)
|
||||||
assert.equal(server.requestCount(), 0);
|
assert.equal(server.requestCount(), 0)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("uses options.apiKey in the Authorization header", async () => {
|
it("uses options.apiKey in the Authorization header", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({
|
const { streamCommandCode } = createTestDeps({
|
||||||
apiBase: server.baseUrl(),
|
apiBase: server.baseUrl(),
|
||||||
env: { COMMANDCODE_API_KEY: "env-key" },
|
env: { COMMANDCODE_API_KEY: "env-key" },
|
||||||
});
|
})
|
||||||
|
|
||||||
await collectEvents(
|
await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" }))
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key")
|
||||||
server.lastRequestHeaders().authorization,
|
})
|
||||||
"Bearer option-key",
|
})
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("streamCommandCode — successful streams", () => {
|
describe("streamCommandCode — successful streams", () => {
|
||||||
it("emits start → text events → done and accumulates usage", async () => {
|
it("emits start → text events → done and accumulates usage", async () => {
|
||||||
@@ -92,14 +87,14 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
const { streamCommandCode, calculatedUsages } = createTestDeps({
|
const { streamCommandCode, calculatedUsages } = createTestDeps({
|
||||||
apiBase: server.baseUrl(),
|
apiBase: server.baseUrl(),
|
||||||
});
|
})
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.deepEqual(eventTypes(events), [
|
assert.deepEqual(eventTypes(events), [
|
||||||
"start",
|
"start",
|
||||||
@@ -108,21 +103,19 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
"text_delta",
|
"text_delta",
|
||||||
"text_end",
|
"text_end",
|
||||||
"done",
|
"done",
|
||||||
]);
|
])
|
||||||
const done = events.at(-1);
|
const done = events.at(-1)
|
||||||
assert.equal(done?.type, "done");
|
assert.equal(done?.type, "done")
|
||||||
if (done?.type !== "done") throw new Error("expected done");
|
if (done?.type !== "done") throw new Error("expected done")
|
||||||
assert.equal(done.reason, "stop");
|
assert.equal(done.reason, "stop")
|
||||||
assert.equal(done.message.content[0]?.type, "text");
|
assert.equal(done.message.content[0]?.type, "text")
|
||||||
assert.equal(
|
assert.equal(
|
||||||
done.message.content[0]?.type === "text"
|
done.message.content[0]?.type === "text" ? done.message.content[0].text : "",
|
||||||
? done.message.content[0].text
|
|
||||||
: "",
|
|
||||||
"Hello",
|
"Hello",
|
||||||
);
|
)
|
||||||
assert.equal(done.message.usage.totalTokens, 11);
|
assert.equal(done.message.usage.totalTokens, 11)
|
||||||
assert.equal(calculatedUsages.length, 1);
|
assert.equal(calculatedUsages.length, 1)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("ends on finish without waiting for an open upstream connection", async () => {
|
it("ends on finish without waiting for an open upstream connection", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
@@ -132,21 +125,18 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||||
],
|
],
|
||||||
hangAfterLast: true,
|
hangAfterLast: true,
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
500,
|
500,
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.equal(events.at(-1)?.type, "done");
|
assert.equal(events.at(-1)?.type, "done")
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
assert.ok(
|
assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body")
|
||||||
server.responseClosedBeforeEnd(),
|
})
|
||||||
"client should cancel the still-open response body",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits reasoning and tool-call blocks in order", async () => {
|
it("emits reasoning and tool-call blocks in order", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
@@ -163,12 +153,12 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
}),
|
}),
|
||||||
JSON.stringify({ type: "finish", finishReason: "tool-calls" }),
|
JSON.stringify({ type: "finish", finishReason: "tool-calls" }),
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.deepEqual(eventTypes(events), [
|
assert.deepEqual(eventTypes(events), [
|
||||||
"start",
|
"start",
|
||||||
@@ -181,20 +171,17 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
"toolcall_start",
|
"toolcall_start",
|
||||||
"toolcall_end",
|
"toolcall_end",
|
||||||
"done",
|
"done",
|
||||||
]);
|
])
|
||||||
const done = events.at(-1);
|
const done = events.at(-1)
|
||||||
if (done?.type !== "done") throw new Error("expected done");
|
if (done?.type !== "done") throw new Error("expected done")
|
||||||
assert.equal(done.reason, "toolUse");
|
assert.equal(done.reason, "toolUse")
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
done.message.content.map((content) => content.type),
|
done.message.content.map((content) => content.type),
|
||||||
["thinking", "text", "toolCall"],
|
["thinking", "text", "toolCall"],
|
||||||
);
|
)
|
||||||
const toolCall = done.message.content[2];
|
const toolCall = done.message.content[2]
|
||||||
assert.equal(
|
assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file")
|
||||||
toolCall?.type === "toolCall" ? toolCall.name : "",
|
})
|
||||||
"read_file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flushes reasoning if finish arrives without reasoning-end", async () => {
|
it("flushes reasoning if finish arrives without reasoning-end", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
@@ -203,26 +190,26 @@ describe("streamCommandCode — successful streams", () => {
|
|||||||
JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }),
|
JSON.stringify({ type: "reasoning-delta", text: "unfinished thought" }),
|
||||||
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
JSON.stringify({ type: "finish", finishReason: "stop" }),
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
);
|
)
|
||||||
|
|
||||||
const done = events.at(-1);
|
const done = events.at(-1)
|
||||||
if (done?.type !== "done") throw new Error("expected done");
|
if (done?.type !== "done") throw new Error("expected done")
|
||||||
assert.equal(done.message.content[0]?.type, "thinking");
|
assert.equal(done.message.content[0]?.type, "thinking")
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("streamCommandCode — request serialization", () => {
|
describe("streamCommandCode — request serialization", () => {
|
||||||
it("sends the expected request body and default headers", async () => {
|
it("sends the expected request body and default headers", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
const context = makeContext({
|
const context = makeContext({
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: "first" },
|
{ role: "user", content: "first" },
|
||||||
@@ -242,52 +229,40 @@ describe("streamCommandCode — request serialization", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
await collectEvents(
|
await collectEvents(
|
||||||
streamCommandCode(makeModel(), context, {
|
streamCommandCode(makeModel(), context, {
|
||||||
apiKey: "mock-key",
|
apiKey: "mock-key",
|
||||||
maxTokens: 500,
|
maxTokens: 500,
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
const body = server.lastRequestBody();
|
const body = server.lastRequestBody()
|
||||||
assert.equal(objectAt(body, ["config", "workingDir"]), "/repo");
|
assert.equal(objectAt(body, ["config", "workingDir"]), "/repo")
|
||||||
assert.equal(objectAt(body, ["config", "date"]), "2026-05-05");
|
assert.equal(objectAt(body, ["config", "date"]), "2026-05-05")
|
||||||
assert.equal(
|
assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash")
|
||||||
objectAt(body, ["params", "model"]),
|
assert.equal(objectAt(body, ["params", "stream"]), true)
|
||||||
"deepseek/deepseek-v4-flash",
|
assert.equal(objectAt(body, ["params", "max_tokens"]), 500)
|
||||||
);
|
assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant.")
|
||||||
assert.equal(objectAt(body, ["params", "stream"]), true);
|
|
||||||
assert.equal(objectAt(body, ["params", "max_tokens"]), 500);
|
|
||||||
assert.equal(
|
|
||||||
objectAt(body, ["params", "system"]),
|
|
||||||
"You are a test assistant.",
|
|
||||||
);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
objectAt(body, ["params", "messages", "1", "content", "0", "text"]),
|
objectAt(body, ["params", "messages", "1", "content", "0", "text"]),
|
||||||
"first response",
|
"first response",
|
||||||
);
|
)
|
||||||
assert.equal(
|
assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather")
|
||||||
objectAt(body, ["params", "tools", "0", "name"]),
|
|
||||||
"get_weather",
|
|
||||||
);
|
|
||||||
|
|
||||||
const headers = server.lastRequestHeaders();
|
const headers = server.lastRequestHeaders()
|
||||||
assert.equal(headers.authorization, "Bearer mock-key");
|
assert.equal(headers.authorization, "Bearer mock-key")
|
||||||
assert.equal(headers["x-command-code-version"], "0.24.1");
|
assert.equal(headers["x-command-code-version"], "0.24.1")
|
||||||
assert.equal(
|
assert.equal(headers["x-session-id"], "00000000-0000-4000-8000-000000000000")
|
||||||
headers["x-session-id"],
|
})
|
||||||
"00000000-0000-4000-8000-000000000000",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps maxTokens and passes custom headers", async () => {
|
it("caps maxTokens and passes custom headers", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
await collectEvents(
|
await collectEvents(
|
||||||
streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), {
|
streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), {
|
||||||
@@ -295,53 +270,50 @@ describe("streamCommandCode — request serialization", () => {
|
|||||||
maxTokens: 500_000,
|
maxTokens: 500_000,
|
||||||
headers: { "x-custom": "value" },
|
headers: { "x-custom": "value" },
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 200_000)
|
||||||
objectAt(server.lastRequestBody(), ["params", "max_tokens"]),
|
assert.equal(server.lastRequestHeaders()["x-custom"], "value")
|
||||||
200_000,
|
})
|
||||||
);
|
|
||||||
assert.equal(server.lastRequestHeaders()["x-custom"], "value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs onPayload and onResponse hooks", async () => {
|
it("runs onPayload and onResponse hooks", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
let responseStatus = 0;
|
let responseStatus = 0
|
||||||
|
|
||||||
await collectEvents(
|
await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), {
|
streamCommandCode(makeModel(), makeContext(), {
|
||||||
apiKey: "mock-key",
|
apiKey: "mock-key",
|
||||||
onPayload: () => ({ replaced: true }),
|
onPayload: () => ({ replaced: true }),
|
||||||
onResponse: (response) => {
|
onResponse: (response) => {
|
||||||
responseStatus = response.status;
|
responseStatus = response.status
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true);
|
assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true)
|
||||||
assert.equal(responseStatus, 200);
|
assert.equal(responseStatus, 200)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
describe("streamCommandCode — upstream errors and malformed streams", () => {
|
describe("streamCommandCode — upstream errors and malformed streams", () => {
|
||||||
it("emits error for HTTP failures", async () => {
|
it("emits error for HTTP failures", async () => {
|
||||||
server.mockResponse({ type: "error", status: 429, body: "rate limited" });
|
server.mockResponse({ type: "error", status: 429, body: "rate limited" })
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
);
|
)
|
||||||
|
|
||||||
assert.deepEqual(eventTypes(events), ["start", "error"]);
|
assert.deepEqual(eventTypes(events), ["start", "error"])
|
||||||
const error = events.at(-1);
|
const error = events.at(-1)
|
||||||
assert.equal(error?.type, "error");
|
assert.equal(error?.type, "error")
|
||||||
if (error?.type !== "error") throw new Error("expected error");
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
assert.match(error.error.errorMessage ?? "", /429/);
|
assert.match(error.error.errorMessage ?? "", /429/)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("emits error for provider error events", async () => {
|
it("emits error for provider error events", async () => {
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
@@ -352,25 +324,25 @@ describe("streamCommandCode — upstream errors and malformed streams", () => {
|
|||||||
error: { message: "provider failed" },
|
error: { message: "provider failed" },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
);
|
)
|
||||||
|
|
||||||
const error = events.at(-1);
|
const error = events.at(-1)
|
||||||
assert.equal(error?.type, "error");
|
assert.equal(error?.type, "error")
|
||||||
if (error?.type !== "error") throw new Error("expected error");
|
if (error?.type !== "error") throw new Error("expected error")
|
||||||
assert.equal(error.error.errorMessage, "provider failed");
|
assert.equal(error.error.errorMessage, "provider failed")
|
||||||
});
|
})
|
||||||
|
|
||||||
it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => {
|
it("handles SSE lines, malformed lines, split chunks, and final line without newline", async () => {
|
||||||
const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n`;
|
const textEvent = `data: ${JSON.stringify({ type: "text-delta", text: "split" })}\n`
|
||||||
const finishEvent = JSON.stringify({
|
const finishEvent = JSON.stringify({
|
||||||
type: "finish",
|
type: "finish",
|
||||||
finishReason: "max_tokens",
|
finishReason: "max_tokens",
|
||||||
});
|
})
|
||||||
server.mockResponse({
|
server.mockResponse({
|
||||||
type: "success",
|
type: "success",
|
||||||
chunks: [
|
chunks: [
|
||||||
@@ -381,21 +353,19 @@ describe("streamCommandCode — upstream errors and malformed streams", () => {
|
|||||||
"data: [DONE]\n",
|
"data: [DONE]\n",
|
||||||
finishEvent,
|
finishEvent,
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
|
||||||
|
|
||||||
const events = await collectEvents(
|
const events = await collectEvents(
|
||||||
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }),
|
||||||
);
|
)
|
||||||
|
|
||||||
const done = events.at(-1);
|
const done = events.at(-1)
|
||||||
if (done?.type !== "done") throw new Error("expected done");
|
if (done?.type !== "done") throw new Error("expected done")
|
||||||
assert.equal(done.reason, "length");
|
assert.equal(done.reason, "length")
|
||||||
assert.equal(
|
assert.equal(
|
||||||
done.message.content[0]?.type === "text"
|
done.message.content[0]?.type === "text" ? done.message.content[0].text : "",
|
||||||
? done.message.content[0].text
|
|
||||||
: "",
|
|
||||||
"split",
|
"split",
|
||||||
);
|
)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user