style: configure prettier (no semicolons, trailing commas)

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