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