From e9853eb4a41e0fcd590334220b056c3e87dbba71 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 5 May 2026 13:56:41 +0200 Subject: [PATCH] chore: add CI workflow (typecheck + prettier format check), tsconfig, and prettier formatting --- .github/workflows/ci.yml | 31 +++++ README.md | 10 +- index.ts | 162 +++++++++++++++++++--- package-lock.json | 17 +++ package.json | 5 +- src/auth-server.ts | 155 +++++++++++++++++++++ src/converters.ts | 110 ++++++++++++--- src/core.ts | 144 +++++++++++++++----- src/oauth.ts | 98 ++++++++++++++ src/types.ts | 57 ++++++-- tests/helpers.ts | 17 ++- tests/test-abort.ts | 25 +++- tests/test-oauth.ts | 252 +++++++++++++++++++++++++++++++++++ tests/test-pi-local.mjs | 128 +++++++++++++----- tests/test-pure-functions.ts | 120 ++++++++++++++--- tests/test-smoke.mjs | 190 ++++++++++++++++++-------- tests/test-stream.ts | 204 +++++++++++++++++++++------- tsconfig.json | 13 ++ 18 files changed, 1488 insertions(+), 250 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/auth-server.ts create mode 100644 src/oauth.ts create mode 100644 tests/test-oauth.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06b6455 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run format:check diff --git a/README.md b/README.md index c1b4df4..af110ed 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to > **Disclaimer:** This is an unofficial, community-maintained package. I am not affiliated with, endorsed by, or connected to Command Code in any way. This provider simply forwards requests to the public Command Code API using your own API key. -> **Note:** This package only provides a model *provider*. It does **not** include an API key. You must bring your own Command Code API key or subscription. +> **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own Command Code API key or subscription. > 💰 **Current offer:** Command Code offers [4× usage of DeepSeek V4](https://commandcode.ai/docs/resources/pricing-limits#deepseek-v4-pro-4x-usage) (Pro and Flash) at no extra cost. @@ -12,10 +12,10 @@ A [pi](https://github.com/badlogic/pi-mono) custom provider that connects pi to 18 models across premium and open-source providers: -| Category | Models | -|----------|--------| -| **Anthropic** | Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, Claude Haiku 4.5 | -| **OpenAI** | GPT-5.5, GPT-5.4, GPT-5.3 Codex, GPT-5.4 Mini | +| Category | Models | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **Anthropic** | Claude Opus 4.7, Claude Opus 4.6, Claude Sonnet 4.6, Claude Haiku 4.5 | +| **OpenAI** | GPT-5.5, GPT-5.4, GPT-5.3 Codex, GPT-5.4 Mini | | **Open-source** | DeepSeek V4, DeepSeek V4 Pro, DeepSeek V4 Flash, Kimi K2.6, Kimi K2.5, GLM-5.1, GLM-5, MiniMax M2.7, MiniMax M2.5, Qwen 3.6 Max, Qwen 3.6 Plus | ## Install diff --git a/index.ts b/index.ts index 31f2484..2b433d4 100644 --- a/index.ts +++ b/index.ts @@ -2,9 +2,12 @@ * Command Code provider for pi. * * Connects pi to Command Code's API (https://api.commandcode.ai/alpha/generate). - * Requires a Command Code API key. Set via: - * 1. COMMANDCODE_API_KEY environment variable, or - * 2. `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` + * + * Authentication (pick one): + * 1. Run `/login commandcode` — opens browser to commandcode.ai, auto-stores API key + * 2. Set COMMANDCODE_API_KEY environment variable + * 3. Place API key in `~/.commandcode/auth.json` or `~/.pi/agent/auth.json` + * as {"apiKey": "user_..."} or {"commandcode": "user_..."} * * Models: deepseek-v4-pro, deepseek-v4-flash, claude-sonnet-4-6, claude-opus-4-7, etc. */ @@ -16,6 +19,7 @@ import { import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts"; +import { getApiKey, login, refreshToken } from "./src/oauth.ts"; const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE; @@ -25,26 +29,134 @@ const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE; const MODELS = [ // Premium (Anthropic) - { id: "claude-opus-4-7", name: "Claude Opus 4.7 (CC)", reasoning: true, contextWindow: 200_000, maxTokens: 32_000 }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6 (CC)", reasoning: true, contextWindow: 200_000, maxTokens: 32_000 }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (CC)", reasoning: true, contextWindow: 200_000, maxTokens: 16_384 }, - { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5 (CC)", reasoning: true, contextWindow: 200_000, maxTokens: 8_192 }, + { + id: "claude-opus-4-7", + name: "Claude Opus 4.7 (CC)", + reasoning: true, + contextWindow: 200_000, + maxTokens: 32_000, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6 (CC)", + reasoning: true, + contextWindow: 200_000, + maxTokens: 32_000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (CC)", + reasoning: true, + contextWindow: 200_000, + maxTokens: 16_384, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude Haiku 4.5 (CC)", + reasoning: true, + contextWindow: 200_000, + maxTokens: 8_192, + }, // Premium (OpenAI) - { id: "gpt-5.5", name: "GPT-5.5 (CC)", reasoning: true, contextWindow: 256_000, maxTokens: 128_000 }, - { id: "gpt-5.4", name: "GPT-5.4 (CC)", reasoning: true, contextWindow: 256_000, maxTokens: 128_000 }, - { id: "gpt-5.3-codex", name: "GPT-5.3 Codex (CC)", reasoning: true, contextWindow: 256_000, maxTokens: 128_000 }, - { id: "gpt-5.4-mini", name: "GPT-5.4 Mini (CC)", reasoning: false, contextWindow: 256_000, maxTokens: 128_000 }, + { + id: "gpt-5.5", + name: "GPT-5.5 (CC)", + reasoning: true, + contextWindow: 256_000, + maxTokens: 128_000, + }, + { + id: "gpt-5.4", + name: "GPT-5.4 (CC)", + reasoning: true, + contextWindow: 256_000, + maxTokens: 128_000, + }, + { + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex (CC)", + reasoning: true, + contextWindow: 256_000, + maxTokens: 128_000, + }, + { + id: "gpt-5.4-mini", + name: "GPT-5.4 Mini (CC)", + reasoning: false, + contextWindow: 256_000, + maxTokens: 128_000, + }, // Open-source - { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro (CC)", reasoning: true, contextWindow: 1_000_000, maxTokens: 384_000 }, - { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (CC)", reasoning: true, contextWindow: 1_000_000, maxTokens: 384_000 }, - { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6 (CC)", reasoning: true, contextWindow: 262_144, maxTokens: 131_072 }, - { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5 (CC)", reasoning: true, contextWindow: 262_144, maxTokens: 131_072 }, - { id: "zai-org/GLM-5.1", name: "GLM-5.1 (CC)", reasoning: true, contextWindow: 200_000, maxTokens: 131_072 }, - { id: "zai-org/GLM-5", name: "GLM-5 (CC)", reasoning: true, contextWindow: 200_000, maxTokens: 131_072 }, - { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7 (CC)", reasoning: true, contextWindow: 1_048_576, maxTokens: 131_072 }, - { id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5 (CC)", reasoning: true, contextWindow: 1_048_576, maxTokens: 131_072 }, - { id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max (CC)", reasoning: true, contextWindow: 1_000_000, maxTokens: 131_072 }, - { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus (CC)", reasoning: true, contextWindow: 1_000_000, maxTokens: 131_072 }, + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro (CC)", + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 384_000, + }, + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash (CC)", + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 384_000, + }, + { + id: "moonshotai/Kimi-K2.6", + name: "Kimi K2.6 (CC)", + reasoning: true, + contextWindow: 262_144, + maxTokens: 131_072, + }, + { + id: "moonshotai/Kimi-K2.5", + name: "Kimi K2.5 (CC)", + reasoning: true, + contextWindow: 262_144, + maxTokens: 131_072, + }, + { + id: "zai-org/GLM-5.1", + name: "GLM-5.1 (CC)", + reasoning: true, + contextWindow: 200_000, + maxTokens: 131_072, + }, + { + id: "zai-org/GLM-5", + name: "GLM-5 (CC)", + reasoning: true, + contextWindow: 200_000, + maxTokens: 131_072, + }, + { + id: "MiniMaxAI/MiniMax-M2.7", + name: "MiniMax M2.7 (CC)", + reasoning: true, + contextWindow: 1_048_576, + maxTokens: 131_072, + }, + { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax M2.5 (CC)", + reasoning: true, + contextWindow: 1_048_576, + maxTokens: 131_072, + }, + { + id: "Qwen/Qwen3.6-Max-Preview", + name: "Qwen 3.6 Max (CC)", + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 131_072, + }, + { + id: "Qwen/Qwen3.6-Plus", + name: "Qwen 3.6 Plus (CC)", + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 131_072, + }, ]; const streamCommandCode = createStreamCommandCode({ @@ -61,7 +173,7 @@ export default function (pi: ExtensionAPI) { pi.registerProvider("commandcode", { name: "Command Code", baseUrl: API_BASE, - apiKey: "!python3 -c 'import json,pathlib; key=\"\"; paths=[pathlib.Path.home()/\".commandcode/auth.json\", pathlib.Path.home()/\".pi/agent/auth.json\"];\nfor p in paths:\n try:\n data=json.loads(p.read_text()); key=data.get(\"apiKey\") or data.get(\"commandcode\") or key\n if key: break\n except Exception: pass\nprint(key)'", + apiKey: "COMMANDCODE_API_KEY", authHeader: true, api: "commandcode-custom", streamSimple: streamCommandCode, @@ -69,6 +181,12 @@ export default function (pi: ExtensionAPI) { "x-command-code-version": "0.24.1", "x-cli-environment": "production", }, + oauth: { + name: "Command Code", + login, + refreshToken, + getApiKey, + }, models: MODELS.map((model) => ({ id: model.id, name: model.name, diff --git a/package-lock.json b/package-lock.json index 5c70c53..070f3f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@mariozechner/pi-coding-agent": "0.72.0", "@types/node": "25.6.0", + "prettier": "^3.5.0", "tsx": "4.21.0", "typescript": "6.0.3" } @@ -3621,6 +3622,22 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", diff --git a/package.json b/package.json index de49081..5d2d727 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ ], "scripts": { "test": "npm run typecheck && tsx tests/test-pure-functions.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && node tests/test-pi-local.mjs", - "typecheck": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --allowImportingTsExtensions --skipLibCheck --types node src/core.ts tests/*.ts", + "typecheck": "tsc --noEmit", + "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", + "format": "prettier --write '**/*.{ts,mjs,json,md}'", "test:unit": "tsx tests/test-pure-functions.ts", "test:abort": "tsx tests/test-abort.ts", "test:stream": "tsx tests/test-stream.ts", @@ -41,6 +43,7 @@ "devDependencies": { "@mariozechner/pi-coding-agent": "0.72.0", "@types/node": "25.6.0", + "prettier": "^3.5.0", "tsx": "4.21.0", "typescript": "6.0.3" }, diff --git a/src/auth-server.ts b/src/auth-server.ts new file mode 100644 index 0000000..cd92a20 --- /dev/null +++ b/src/auth-server.ts @@ -0,0 +1,155 @@ +/** + * Local HTTP callback server for the Command Code browser auth flow. + * + * Starts a one-shot server on a random port. The Command Code Studio + * website POSTs the user's API key to /callback after they authenticate. + */ + +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +export interface AuthCallback { + apiKey: string; + state: string; + userId: string; + userName: string; + keyName: string; +} + +export interface AuthServer { + server: Server; + port: number; + waitForCallback: Promise; +} + +/** + * Start a local HTTP server that listens for the Command Code Studio + * to POST the API key after the user authenticates in their browser. + * + * The server accepts exactly one valid POST to /callback and then closes. + */ +export function startAuthServer(): Promise { + let resolveCallback: (value: AuthCallback) => void; + let rejectCallback: (error: Error) => void; + + const waitForCallback = new Promise((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + + const server = createServer((req, res) => { + // CORS: allow requests from Command Code domains and localhost for dev + const origin = req.headers.origin || ""; + const allowedOrigins = [ + "http://localhost:3000", + "https://staging.commandcode.ai", + "https://commandcode.ai", + ]; + const responseOrigin = allowedOrigins.includes(origin) + ? origin + : allowedOrigins[0]; + + res.setHeader("Access-Control-Allow-Origin", responseOrigin); + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Content-Type", "application/json"); + + // Handle CORS preflight + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.url !== "/callback") { + res.writeHead(404); + res.end(JSON.stringify({ success: false, error: "Not found" })); + return; + } + + if (req.method !== "POST") { + res.writeHead(405); + res.end( + JSON.stringify({ + success: false, + error: "Method not allowed. Use POST.", + }), + ); + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + if (body.length > 10_000) req.destroy(); + }); + + req.on("end", () => { + try { + const parsed = JSON.parse(body) as Record; + + if (parsed.error) { + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + const description = + typeof parsed.error_description === "string" + ? parsed.error_description + : String(parsed.error); + if (parsed.error === "access_denied") { + rejectCallback( + new Error(description || "Authorization was denied by the user"), + ); + } else { + rejectCallback(new Error(description || String(parsed.error))); + } + server.close(); + return; + } + + const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : ""; + const state = typeof parsed.state === "string" ? parsed.state : ""; + const userId = typeof parsed.userId === "string" ? parsed.userId : ""; + const userName = + typeof parsed.userName === "string" ? parsed.userName : ""; + const keyName = + typeof parsed.keyName === "string" ? parsed.keyName : ""; + + if (!apiKey || !state || !userId || !userName || !keyName) { + res.writeHead(400); + res.end( + JSON.stringify({ + success: false, + error: "Missing required fields", + }), + ); + return; + } + + res.writeHead(200); + res.end(JSON.stringify({ success: true })); + + resolveCallback({ apiKey, state, userId, userName, keyName }); + server.close(); + } catch { + res.writeHead(400); + res.end(JSON.stringify({ success: false, error: "Invalid JSON" })); + } + }); + + req.on("error", () => { + res.writeHead(500); + res.end(JSON.stringify({ success: false, error: "Request error" })); + }); + }); + + return new Promise((resolve) => { + server.on("error", (err: NodeJS.ErrnoException) => { + rejectCallback(new Error(`Failed to start auth server: ${err.message}`)); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve({ server, port: address.port, waitForCallback }); + }); + }); +} diff --git a/src/converters.ts b/src/converters.ts index 16d8e57..867b97b 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -16,17 +16,30 @@ function booleanValue(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } -export function recordArray(value: unknown): readonly Record[] { +export function recordArray( + value: unknown, +): readonly Record[] { if (!Array.isArray(value)) return []; return value.filter(isRecord); } export function recordOrEmpty(value: unknown): Record { - return isRecord(value) ? value : {}; + if (isRecord(value)) return value; + if (typeof value === "string") { + try { + const parsed: unknown = JSON.parse(value); + if (isRecord(parsed)) return parsed; + } catch { + // Some providers stream incomplete JSON argument fragments. + } + } + return {}; } export function numberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; } function defaultAuthPaths(home: string): string[] { @@ -36,11 +49,13 @@ function defaultAuthPaths(home: string): string[] { ]; } -export function getApiKey(options: { - env?: NodeJS.ProcessEnv; - authPaths?: readonly string[]; - homeDir?: () => string; -} = {}): string | undefined { +export function getApiKey( + options: { + env?: NodeJS.ProcessEnv; + authPaths?: readonly string[]; + homeDir?: () => string; + } = {}, +): string | undefined { const env = options.env ?? process.env; if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY; @@ -52,10 +67,21 @@ export function getApiKey(options: { if (!existsSync(authPath)) continue; const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")); if (!isRecord(parsed)) continue; + + // Legacy: direct apiKey or commandcode field const apiKey = stringValue(parsed.apiKey); if (apiKey) return apiKey; const commandcode = stringValue(parsed.commandcode); if (commandcode) return commandcode; + + // OAuth: pi stores OAuth credentials as {"commandcode": {"type":"oauth","access":"...","refresh":"...","expires":...}} + const providerKey = isRecord(parsed.commandcode) + ? parsed.commandcode + : undefined; + if (providerKey && stringValue(providerKey.type) === "oauth") { + const access = stringValue(providerKey.access); + if (access) return access; + } } catch { // Ignore malformed or unreadable auth files. } @@ -98,23 +124,32 @@ export function toJsonSchema(schema: unknown): unknown { case "Object": { const properties: Record = {}; const inferredRequired: string[] = []; - const sourceProperties = isRecord(schema.properties) ? schema.properties : undefined; + const sourceProperties = isRecord(schema.properties) + ? schema.properties + : undefined; const optional = Array.isArray(schema.optional) - ? schema.optional.filter((item): item is string => typeof item === "string") + ? schema.optional.filter( + (item): item is string => typeof item === "string", + ) : []; if (sourceProperties) { for (const [key, value] of Object.entries(sourceProperties)) { properties[key] = toJsonSchema(value); const valueRecord = isRecord(value) ? value : undefined; - if (booleanValue(valueRecord?.optional) !== true && !optional.includes(key)) { + if ( + booleanValue(valueRecord?.optional) !== true && + !optional.includes(key) + ) { inferredRequired.push(key); } } } const explicitRequired = Array.isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === "string") + ? schema.required.filter( + (item): item is string => typeof item === "string", + ) : undefined; const required = explicitRequired ?? inferredRequired; const out: Record = { type: "object" }; @@ -124,7 +159,10 @@ export function toJsonSchema(schema: unknown): unknown { } case "array": case "Array": - return { type: "array", items: toJsonSchema(schema.items ?? schema.element) }; + return { + type: "array", + items: toJsonSchema(schema.items ?? schema.element), + }; case "union": case "Union": { const variants = Array.isArray(schema.variants) @@ -134,7 +172,8 @@ export function toJsonSchema(schema: unknown): unknown { : []; for (const variant of variants) { const converted = toJsonSchema(variant); - if (isRecord(converted) && Object.keys(converted).length > 0) return converted; + if (isRecord(converted) && Object.keys(converted).length > 0) + return converted; } return {}; } @@ -156,13 +195,38 @@ export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { })); } +function completeToolCallIds(messages?: readonly MessageLike[]): Set { + const callIds = new Set(); + const resultIds = new Set(); + + for (const message of messages ?? []) { + if (message.role === "assistant") { + for (const content of recordArray(message.content)) { + if (content.type === "toolCall") { + const id = stringValue(content.id); + if (id) callIds.add(id); + } + } + } else if (message.role === "toolResult") { + if (message.toolCallId) resultIds.add(message.toolCallId); + } + } + + return new Set([...callIds].filter((id) => resultIds.has(id))); +} + export function messagesToCC(messages?: readonly MessageLike[]): unknown[] { const out: unknown[] = []; + const pairedToolCallIds = completeToolCallIds(messages); + for (const message of messages ?? []) { if (message.role === "user") { out.push({ role: "user", - content: typeof message.content === "string" ? message.content : message.content, + content: + typeof message.content === "string" + ? message.content + : message.content, }); } else if (message.role === "assistant") { const parts: unknown[] = []; @@ -170,18 +234,25 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] { if (content.type === "text") { parts.push({ type: "text", text: stringValue(content.text) ?? "" }); } else if (content.type === "thinking") { - parts.push({ type: "reasoning", text: stringValue(content.thinking) ?? "" }); + parts.push({ + type: "reasoning", + text: stringValue(content.thinking) ?? "", + }); } else if (content.type === "toolCall") { + const toolCallId = stringValue(content.id) ?? ""; + if (!pairedToolCallIds.has(toolCallId)) continue; parts.push({ type: "tool-call", - toolCallId: stringValue(content.id) ?? "", + toolCallId, toolName: stringValue(content.name) ?? "", input: recordOrEmpty(content.arguments), }); } } - out.push({ role: "assistant", content: parts }); + if (parts.length > 0) out.push({ role: "assistant", content: parts }); } else if (message.role === "toolResult") { + if (!message.toolCallId || !pairedToolCallIds.has(message.toolCallId)) + continue; out.push({ role: "tool", content: [ @@ -202,7 +273,8 @@ export function messagesToCC(messages?: readonly MessageLike[]): unknown[] { export function parseStreamEventLine(line: string): unknown | undefined { let trimmed = line.trim(); - if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) return undefined; + if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:")) + return undefined; if (trimmed.startsWith("data:")) trimmed = trimmed.slice(5).trim(); if (!trimmed || trimmed === "[DONE]") return undefined; diff --git a/src/core.ts b/src/core.ts index f70a445..e0c5c70 100644 --- a/src/core.ts +++ b/src/core.ts @@ -50,12 +50,18 @@ function defaultUsage(): Usage { }; } -function commandCodeUsage(event: Record): Record | undefined { +function commandCodeUsage( + event: Record, +): Record | undefined { return isRecord(event.totalUsage) ? event.totalUsage : undefined; } -function commandCodeInputTokenDetails(usage: Record): Record | undefined { - return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined; +function commandCodeInputTokenDetails( + usage: Record, +): Record | undefined { + return isRecord(usage.inputTokenDetails) + ? usage.inputTokenDetails + : undefined; } function headersToRecord(headers: Headers): Record { @@ -109,11 +115,13 @@ export function createStreamCommandCode(deps: CoreDependencies) { const stream = deps.createStream(); async function run() { - const apiKey = options?.apiKey ?? getApiKey({ - env: deps.env, - authPaths: deps.authPaths, - homeDir: deps.homeDir, - }); + const apiKey = + options?.apiKey ?? + getApiKey({ + env: deps.env, + authPaths: deps.authPaths, + homeDir: deps.homeDir, + }); if (!apiKey) { const msg: AssistantMessageLike = { @@ -125,7 +133,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { usage: defaultUsage(), stopReason: "error", errorMessage: - "No Command Code API key. 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(), }; stream.push({ type: "error", reason: "error", error: msg }); @@ -163,7 +171,9 @@ export function createStreamCommandCode(deps: CoreDependencies) { if (options?.signal?.aborted) { abortUpstream(); } else { - options?.signal?.addEventListener("abort", abortUpstream, { once: true }); + options?.signal?.addEventListener("abort", abortUpstream, { + once: true, + }); } const endTextBlock = () => { @@ -184,9 +194,23 @@ export function createStreamCommandCode(deps: CoreDependencies) { thinkingBlock = []; output.content.push({ type: "thinking", thinking: thinkingText }); const idx = output.content.length - 1; - stream.push({ type: "thinking_start", contentIndex: idx, partial: output }); - stream.push({ type: "thinking_delta", contentIndex: idx, delta: thinkingText, partial: output }); - stream.push({ type: "thinking_end", contentIndex: idx, content: thinkingText, partial: output }); + stream.push({ + type: "thinking_start", + contentIndex: idx, + partial: output, + }); + stream.push({ + type: "thinking_delta", + contentIndex: idx, + delta: thinkingText, + partial: output, + }); + stream.push({ + type: "thinking_end", + contentIndex: idx, + content: thinkingText, + partial: output, + }); }; const handleEvent = (event: unknown) => { @@ -198,11 +222,20 @@ export function createStreamCommandCode(deps: CoreDependencies) { textBlock = { type: "text", text: "" }; output.content.push(textBlock); currentTextIdx = output.content.length - 1; - stream.push({ type: "text_start", contentIndex: currentTextIdx, partial: output }); + stream.push({ + type: "text_start", + contentIndex: currentTextIdx, + partial: output, + }); } const delta = stringValue(event.text) ?? ""; textBlock.text += delta; - stream.push({ type: "text_delta", contentIndex: currentTextIdx, delta, partial: output }); + stream.push({ + type: "text_delta", + contentIndex: currentTextIdx, + delta, + partial: output, + }); break; } @@ -222,12 +255,23 @@ export function createStreamCommandCode(deps: CoreDependencies) { type: "toolCall", id: stringValue(event.toolCallId) ?? "", name: stringValue(event.toolName) ?? "", - arguments: recordOrEmpty(event.input ?? event.args), + arguments: recordOrEmpty( + event.input ?? event.args ?? event.arguments, + ), }; output.content.push(toolCall); const idx = output.content.length - 1; - stream.push({ type: "toolcall_start", contentIndex: idx, partial: output }); - stream.push({ type: "toolcall_end", contentIndex: idx, toolCall, partial: output }); + stream.push({ + type: "toolcall_start", + contentIndex: idx, + partial: output, + }); + stream.push({ + type: "toolcall_end", + contentIndex: idx, + toolCall, + partial: output, + }); break; } @@ -237,8 +281,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { const details = commandCodeInputTokenDetails(usage); output.usage.input = numberValue(usage.inputTokens) ?? 0; output.usage.output = numberValue(usage.outputTokens) ?? 0; - output.usage.cacheRead = numberValue(details?.cacheReadTokens) ?? 0; - output.usage.cacheWrite = numberValue(details?.cacheWriteTokens) ?? 0; + output.usage.cacheRead = + numberValue(details?.cacheReadTokens) ?? 0; + output.usage.cacheWrite = + numberValue(details?.cacheWriteTokens) ?? 0; output.usage.totalTokens = output.usage.input + output.usage.output + @@ -253,7 +299,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { case "error": { const errorRecord = isRecord(event.error) ? event.error : undefined; - const message = stringValue(errorRecord?.message) ?? stringValue(event.error) ?? "Stream error"; + const message = + stringValue(errorRecord?.message) ?? + stringValue(event.error) ?? + "Stream error"; output.stopReason = "error"; output.errorMessage = message; throw new Error(message); @@ -285,12 +334,18 @@ export function createStreamCommandCode(deps: CoreDependencies) { messages: messagesToCC(context.messages), tools: toolsToJson(context.tools), system: context.systemPrompt ?? "", - max_tokens: Math.min(options?.maxTokens ?? model.maxTokens, 200_000), + max_tokens: Math.min( + options?.maxTokens ?? model.maxTokens, + 200_000, + ), stream: true, }, }; - const nextBody = await raceAbort(Promise.resolve(options?.onPayload?.(body, model)), controller.signal); + const nextBody = await raceAbort( + Promise.resolve(options?.onPayload?.(body, model)), + controller.signal, + ); if (nextBody !== undefined) body = nextBody; const response = await raceAbort( @@ -314,13 +369,26 @@ export function createStreamCommandCode(deps: CoreDependencies) { ); await raceAbort( - Promise.resolve(options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model)), + Promise.resolve( + options?.onResponse?.( + { + status: response.status, + headers: headersToRecord(response.headers), + }, + model, + ), + ), controller.signal, ); if (!response.ok) { - const errBody = await raceAbort(response.text().catch(() => ""), controller.signal); - throw new Error(`Command Code API error ${response.status}: ${errBody.slice(0, 500)}`); + const errBody = await raceAbort( + response.text().catch(() => ""), + controller.signal, + ); + throw new Error( + `Command Code API error ${response.status}: ${errBody.slice(0, 500)}`, + ); } reader = response.body?.getReader(); @@ -331,7 +399,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { readLoop: for (;;) { if (controller.signal.aborted) throw abortError("Aborted"); - const { done, value } = await raceAbort(reader.read(), controller.signal); + const { done, value } = await raceAbort( + reader.read(), + controller.signal, + ); if (done) { if (buffer.trim()) handleEvent(parseStreamEventLine(buffer)); break; @@ -352,14 +423,23 @@ export function createStreamCommandCode(deps: CoreDependencies) { endTextBlock(); flushThinkingBlock(); - stream.push({ type: "done", reason: successStopReason(output.stopReason), message: output }); + stream.push({ + type: "done", + reason: successStopReason(output.stopReason), + message: output, + }); stream.end(); } catch (error: unknown) { - const reason: ErrorReason = controller.signal.aborted ? "aborted" : "error"; + const reason: ErrorReason = controller.signal.aborted + ? "aborted" + : "error"; output.stopReason = reason; - output.errorMessage = reason === "aborted" - ? "Request aborted" - : error instanceof Error ? error.message : String(error); + output.errorMessage = + reason === "aborted" + ? "Request aborted" + : error instanceof Error + ? error.message + : String(error); stream.push({ type: "error", reason, error: output }); stream.end(); } finally { diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..8da2e26 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,98 @@ +/** + * Command Code OAuth provider for pi's /login flow. + * + * Implements a browser-assisted API key retrieval flow: + * 1. Starts a local HTTP server on a random port + * 2. Opens the Command Code Studio auth page in the browser + * 3. The user authenticates on the Command Code website + * 4. The website POSTs the API key back to the local server + * 5. The API key is stored in pi's auth.json as OAuth credentials + * + * Since Command Code API keys don't expire, we store them as + * OAuth credentials with a far-future expiry. + */ + +import { randomBytes } from "node:crypto"; +import { startAuthServer } from "./auth-server.ts"; + +const STUDIO_BASE_URL = "https://commandcode.ai"; +const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000; // API keys don't expire + +export interface OAuthLoginCallbacks { + onAuth(params: { url: string }): void; + onPrompt(params: { message: string }): Promise; +} + +export interface OAuthCredentials { + refresh: string; + access: string; + expires: number; +} + +function generateStateToken(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * Starts the browser-based login flow for Command Code. + * + * Returns OAuth credentials where access == refresh == the user's API key. + * The keys don't expire, so we set a far-future expiry. + */ +export async function login( + callbacks: OAuthLoginCallbacks, +): Promise { + const authServer = await startAuthServer(); + const stateToken = generateStateToken(); + + const authUrl = `${STUDIO_BASE_URL}/studio/auth/cli?callback=${encodeURIComponent(`http://localhost:${authServer.port}/callback`)}&state=${encodeURIComponent(stateToken)}`; + + // Tell pi to open the browser + callbacks.onAuth({ url: authUrl }); + + // Wait for the Command Code Studio to POST the API key back + let callback: { apiKey: string; state: string }; + try { + callback = await authServer.waitForCallback; + } catch (error) { + // Clean up server on error + authServer.server.close(); + throw error; + } + + // Validate state token to prevent CSRF + if (callback.state !== stateToken) { + throw new Error( + "State token mismatch. Authentication may have been tampered with.", + ); + } + + // Return as OAuth credentials. Since CC API keys don't expire, + // we set a far-future expiry and use the API key as both access and refresh. + return { + refresh: callback.apiKey, + access: callback.apiKey, + expires: Date.now() + TEN_YEARS_MS, + }; +} + +/** + * Command Code API keys don't expire, so "refresh" is a no-op. + * Returns the same credentials with an updated far-future expiry. + */ +export async function refreshToken( + credentials: OAuthCredentials, +): Promise { + return { + refresh: credentials.refresh, + access: credentials.access, + expires: Date.now() + TEN_YEARS_MS, + }; +} + +/** + * Returns the access token (API key) from OAuth credentials. + */ +export function getApiKey(credentials: OAuthCredentials): string { + return credentials.access; +} diff --git a/src/types.ts b/src/types.ts index 4af022d..667eb49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,20 +87,59 @@ export interface StreamOptions { signal?: AbortSignal; headers?: Record; maxTokens?: number; - onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise; - onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise; + onPayload?: ( + payload: unknown, + model: ModelLike, + ) => unknown | Promise; + onResponse?: ( + response: ProviderResponseInfo, + model: ModelLike, + ) => void | Promise; } export type AssistantMessageEvent = | { type: "start"; partial: AssistantMessageLike } | { type: "text_start"; contentIndex: number; partial: AssistantMessageLike } - | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessageLike } - | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessageLike } - | { type: "thinking_start"; contentIndex: number; partial: AssistantMessageLike } - | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessageLike } - | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessageLike } - | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessageLike } - | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCallContent; partial: AssistantMessageLike } + | { + type: "text_delta"; + contentIndex: number; + delta: string; + partial: AssistantMessageLike; + } + | { + type: "text_end"; + contentIndex: number; + content: string; + partial: AssistantMessageLike; + } + | { + type: "thinking_start"; + contentIndex: number; + partial: AssistantMessageLike; + } + | { + type: "thinking_delta"; + contentIndex: number; + delta: string; + partial: AssistantMessageLike; + } + | { + type: "thinking_end"; + contentIndex: number; + content: string; + partial: AssistantMessageLike; + } + | { + type: "toolcall_start"; + contentIndex: number; + partial: AssistantMessageLike; + } + | { + type: "toolcall_end"; + contentIndex: number; + toolCall: ToolCallContent; + partial: AssistantMessageLike; + } | { type: "done"; reason: StopReason; message: AssistantMessageLike } | { type: "error"; reason: ErrorReason; error: AssistantMessageLike }; diff --git a/tests/helpers.ts b/tests/helpers.ts index 668411f..058944f 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -65,7 +65,15 @@ export async function collectEvents( return await Promise.race([ collect(), new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), timeoutMs); + setTimeout( + () => + reject( + new Error( + `Timed out collecting stream events after ${timeoutMs}ms`, + ), + ), + timeoutMs, + ); }), ]); } @@ -94,7 +102,9 @@ export interface TestDepsResult { calculatedUsages: Usage[]; } -export function createTestDeps(overrides: Partial = {}): TestDepsResult { +export function createTestDeps( + overrides: Partial = {}, +): TestDepsResult { const calculatedUsages: Usage[] = []; const streamCommandCode = createStreamCommandCode({ createStream: createTestEventStream, @@ -197,7 +207,8 @@ export async function startMockCommandCodeServer(): Promise `${event}\n`); + const chunks = + plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`); const delays = plan.delays ?? chunks.map(() => 0); let index = 0; diff --git a/tests/test-abort.ts b/tests/test-abort.ts index f957121..0f83e6e 100644 --- a/tests/test-abort.ts +++ b/tests/test-abort.ts @@ -34,12 +34,17 @@ describe("streamCommandCode — abort behavior", () => { controller.abort(); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + signal: controller.signal, + }), + ); - assert.deepEqual(events.map((event) => event.type), ["start", "error"]); + assert.deepEqual( + events.map((event) => event.type), + ["start", "error"], + ); const error = events.at(-1); assert.equal(error?.type, "error"); if (error?.type !== "error") throw new Error("expected error"); @@ -65,13 +70,19 @@ describe("streamCommandCode — abort behavior", () => { setTimeout(() => controller.abort(), 50); const events = await collectEvents(stream, 2_000); - assert.ok(events.some((event) => event.type === "text_delta"), "stream should process data before abort"); + assert.ok( + events.some((event) => event.type === "text_delta"), + "stream should process data before abort", + ); const error = events.at(-1); assert.equal(error?.type, "error"); if (error?.type !== "error") throw new Error("expected error"); assert.equal(error.reason, "aborted"); assert.equal(error.error.errorMessage, "Request aborted"); await new Promise((resolve) => setTimeout(resolve, 50)); - assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response"); + assert.ok( + server.responseClosedBeforeEnd(), + "abort should close the hanging upstream response", + ); }); }); diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts new file mode 100644 index 0000000..44e376c --- /dev/null +++ b/tests/test-oauth.ts @@ -0,0 +1,252 @@ +/** + * Tests for the Command Code OAuth / browser auth flow. + * + * Tests the local callback server (src/auth-server.ts) and the OAuth + * integration functions (src/oauth.ts). + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { startAuthServer, type AuthCallback } from "../src/auth-server.ts"; +import { getApiKey, login, refreshToken } from "../src/oauth.ts"; + +describe("startAuthServer()", () => { + it("starts on a random port and accepts a valid callback POST", async () => { + const { server, port, waitForCallback } = await startAuthServer(); + + const callbackData: AuthCallback = { + apiKey: "user_testKey123", + state: "test-state-token", + userId: "user_123", + userName: "Test User", + keyName: "test-key", + }; + + // Simulate the Command Code Studio posting the API key back + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://commandcode.ai", + }, + body: JSON.stringify(callbackData), + }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { success: boolean }; + assert.equal(body.success, true); + + const result = await waitForCallback; + assert.equal(result.apiKey, "user_testKey123"); + assert.equal(result.state, "test-state-token"); + assert.equal(result.userId, "user_123"); + assert.equal(result.userName, "Test User"); + assert.equal(result.keyName, "test-key"); + + // Server should close after successful callback + await new Promise((resolve) => server.on("close", resolve)); + }); + + it("rejects when the callback indicates access_denied", async () => { + const { server, port, waitForCallback } = await startAuthServer(); + + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://commandcode.ai", + }, + body: JSON.stringify({ + error: "access_denied", + error_description: "User cancelled", + }), + }); + + assert.equal(response.status, 200); + + await assert.rejects(() => waitForCallback, /User cancelled/); + + await new Promise((resolve) => server.on("close", resolve)); + }); + + it("returns 400 for missing required fields", async () => { + const { server, port } = await startAuthServer(); + + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://commandcode.ai", + }, + body: JSON.stringify({ apiKey: "key", state: "s" }), + }); + + assert.equal(response.status, 400); + + await new Promise((resolve) => setTimeout(resolve, 100)); + server.close(); + }); + + it("handles CORS preflight OPTIONS request", async () => { + const { server, port } = await startAuthServer(); + + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "OPTIONS", + headers: { Origin: "https://commandcode.ai" }, + }); + + assert.equal(response.status, 204); + + await new Promise((resolve) => setTimeout(resolve, 100)); + server.close(); + }); + + it("returns 404 for non-callback paths", async () => { + const { server, port } = await startAuthServer(); + + const response = await fetch(`http://127.0.0.1:${port}/other`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + assert.equal(response.status, 404); + + await new Promise((resolve) => setTimeout(resolve, 100)); + server.close(); + }); + + it("returns 405 for GET on /callback", async () => { + const { server, port } = await startAuthServer(); + + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "GET", + headers: { Origin: "https://commandcode.ai" }, + }); + + assert.equal(response.status, 405); + + await new Promise((resolve) => setTimeout(resolve, 100)); + server.close(); + }); +}); + +describe("OAuth functions", () => { + it("getApiKey returns the access token", () => { + const creds = { + refresh: "refresh-key", + access: "access-key", + expires: Date.now() + 3600000, + }; + assert.equal(getApiKey(creds), "access-key"); + }); + + it("refreshToken returns updated far-future expiry", async () => { + const creds = { + refresh: "my-api-key", + access: "my-api-key", + expires: Date.now() - 1000, // already expired + }; + const result = await refreshToken(creds); + assert.equal(result.access, "my-api-key"); + assert.equal(result.refresh, "my-api-key"); + assert.ok(result.expires > Date.now(), "expiry should be in the future"); + }); +}); + +describe("login()", () => { + it("completes the full browser login flow via the local server", async () => { + let authUrl = ""; + const callbacks = { + onAuth(params: { url: string }) { + authUrl = params.url; + }, + onPrompt(params: { message: string }): Promise { + throw new Error("onPrompt should not be called in browser flow"); + }, + }; + + // Start login in the background + const loginPromise = login(callbacks); + + // Verify the auth URL was passed to callbacks + assert.match( + authUrl, + /^https:\/\/commandcode\.ai\/studio\/auth\/cli\?callback=http:\/\/localhost:\d+\/callback&state=/, + ); + + // Extract port and state from the URL + const url = new URL(authUrl); + const port = parseInt( + url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0", + ); + const state = url.searchParams.get("state") ?? ""; + + assert.ok(port > 0, "auth server should be on a non-zero port"); + assert.ok(state.length > 0, "state token should not be empty"); + + // Simulate the Command Code Studio posting the API key back + const response = await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://commandcode.ai", + }, + body: JSON.stringify({ + apiKey: "user_browserApiKey", + state, + userId: "user_456", + userName: "Browser User", + keyName: "browser-key", + }), + }); + + assert.equal(response.status, 200); + + const result = await loginPromise; + assert.equal(result.access, "user_browserApiKey"); + assert.equal(result.refresh, "user_browserApiKey"); + assert.ok( + result.expires > Date.now(), + "expiry should be far in the future", + ); + }); + + it("rejects on state token mismatch", async () => { + let authUrl = ""; + const callbacks = { + onAuth(params: { url: string }) { + authUrl = params.url; + }, + onPrompt(params: { message: string }): Promise { + throw new Error("should not prompt"); + }, + }; + + const loginPromise = login(callbacks); + + const url = new URL(authUrl); + const port = parseInt( + url.searchParams.get("callback")?.match(/localhost:(\d+)/)?.[1] ?? "0", + ); + + // Post back with a wrong state token + await fetch(`http://127.0.0.1:${port}/callback`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://commandcode.ai", + }, + body: JSON.stringify({ + apiKey: "user_badState", + state: "wrong-state-token", + userId: "user_789", + userName: "Attacker", + keyName: "evil-key", + }), + }); + + await assert.rejects(() => loginPromise, /State token mismatch/); + }); +}); diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index a2281df..38ecb67 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -6,7 +6,15 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; -import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + accessSync, + constants, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createServer } from "node:http"; import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; @@ -59,7 +67,12 @@ const server = createServer((req, res) => { } requestCount += 1; - lastRequestHeaders = Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])); + lastRequestHeaders = Object.fromEntries( + Object.entries(req.headers).map(([key, value]) => [ + key, + Array.isArray(value) ? value.join(", ") : (value ?? ""), + ]), + ); let body = ""; req.on("data", (chunk) => { @@ -76,8 +89,12 @@ const server = createServer((req, res) => { "Content-Type": "text/plain; charset=utf-8", "Transfer-Encoding": "chunked", }); - res.write(`${JSON.stringify({ type: "text-delta", text: "mock-pi-ok" })}\n`); - res.write(`${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`); + res.write( + `${JSON.stringify({ type: "text-delta", text: "mock-pi-ok" })}\n`, + ); + res.write( + `${JSON.stringify({ type: "finish", finishReason: "stop", totalUsage: { inputTokens: 1, outputTokens: 1 } })}\n`, + ); res.end(); }); }); @@ -88,9 +105,11 @@ const port = typeof address === "object" && address ? address.port : 0; const apiBase = `http://127.0.0.1:${port}`; function hasLivePiAuth() { - return !!process.env.COMMANDCODE_API_KEY || + return ( + !!process.env.COMMANDCODE_API_KEY || existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")); + existsSync(join(homedir(), ".pi", "agent", "auth.json")) + ); } let tempHome; @@ -105,7 +124,10 @@ if (hasLivePiAuth()) { console.log("[pi-local] live pi auth not found; using mock auth fallback"); tempHome = mkdtempSync(join(tmpdir(), "pi-cc-home-")); mkdirSync(join(tempHome, ".commandcode"), { recursive: true }); - writeFileSync(join(tempHome, ".commandcode", "auth.json"), JSON.stringify({ apiKey: "mock-key" })); + writeFileSync( + join(tempHome, ".commandcode", "auth.json"), + JSON.stringify({ apiKey: "mock-key" }), + ); env.HOME = tempHome; env.USERPROFILE = tempHome; env.COMMANDCODE_API_KEY = "mock-key"; @@ -122,7 +144,11 @@ function runPi(args, timeoutMs = 30_000) { let stderr = ""; const timer = setTimeout(() => { child.kill(); - resolve({ code: -1, stdout, stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms` }); + resolve({ + code: -1, + stdout, + stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms`, + }); }, timeoutMs); child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf-8"); @@ -138,16 +164,24 @@ function runPi(args, timeoutMs = 30_000) { } async function runRpcQuery(timeoutMs = 30_000) { - const child = spawn(PI_BIN, [ - "--mode", "rpc", - "-e", EXT_PATH, - "--provider", "commandcode", - "--model", TEST_MODEL, - ], { - cwd: PROJECT_DIR, - env, - stdio: ["pipe", "pipe", "pipe"], - }); + const child = spawn( + PI_BIN, + [ + "--mode", + "rpc", + "-e", + EXT_PATH, + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + { + cwd: PROJECT_DIR, + env, + stdio: ["pipe", "pipe", "pipe"], + }, + ); let stdout = ""; let stderr = ""; @@ -174,7 +208,9 @@ async function runRpcQuery(timeoutMs = 30_000) { resolve(ok); }; - child.stdin.write(`${JSON.stringify({ id: "prompt-1", type: "prompt", message: "say mock token" })}\n`); + child.stdin.write( + `${JSON.stringify({ id: "prompt-1", type: "prompt", message: "say mock token" })}\n`, + ); child.stdout.on("data", (chunk) => { const text = chunk.toString("utf-8"); @@ -188,13 +224,23 @@ async function runRpcQuery(timeoutMs = 30_000) { try { const event = JSON.parse(trimmed); events.push(event); - if (event.type === "response" && event.id === "prompt-1" && event.success === true) { + if ( + event.type === "response" && + event.id === "prompt-1" && + event.success === true + ) { sawPromptAccepted = true; } - if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") { + if ( + event.type === "message_update" && + event.assistantMessageEvent?.type === "text_delta" + ) { sawTextDelta = true; } - if (event.type === "message_end" && event.message?.role === "assistant") { + if ( + event.type === "message_end" && + event.message?.role === "assistant" + ) { sawAssistantMessage = true; finish(true); } @@ -212,7 +258,15 @@ async function runRpcQuery(timeoutMs = 30_000) { }); const ok = await done; - return { ok, stdout, stderr, events, sawPromptAccepted, sawAssistantMessage, sawTextDelta }; + return { + ok, + stdout, + stderr, + events, + sawPromptAccepted, + sawAssistantMessage, + sawTextDelta, + }; } try { @@ -224,17 +278,25 @@ try { console.log("[pi-local] print mode through real extension and mock API"); requestCount = 0; - const print = await runPi([ - "-e", EXT_PATH, - "-p", "say mock token", - "--provider", "commandcode", - "--model", TEST_MODEL, - ], 30_000); + const print = await runPi( + [ + "-e", + EXT_PATH, + "-p", + "say mock token", + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + 30_000, + ); assert.equal(print.code, 0, print.stderr); assert.match(print.stdout, /mock-pi-ok/); assert.equal(requestCount, 1); assert.ok( - typeof lastRequestHeaders.authorization === "string" && lastRequestHeaders.authorization.startsWith("Bearer "), + typeof lastRequestHeaders.authorization === "string" && + lastRequestHeaders.authorization.startsWith("Bearer "), "should send a bearer Authorization header", ); assert.equal(lastRequestBody?.params?.model, TEST_MODEL); @@ -245,7 +307,11 @@ try { assert.equal( rpc.ok, true, - JSON.stringify({ stderr: rpc.stderr, stdout: rpc.stdout, events: rpc.events.slice(-10) }, null, 2), + JSON.stringify( + { stderr: rpc.stderr, stdout: rpc.stdout, events: rpc.events.slice(-10) }, + null, + 2, + ), ); assert.equal(rpc.sawPromptAccepted, true); assert.equal(rpc.sawAssistantMessage, true); diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index a11a627..c888a89 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -24,18 +24,40 @@ import { objectAt } from "./helpers.ts"; describe("getApiKey()", () => { it("uses COMMANDCODE_API_KEY from provided env", () => { - assert.equal(getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), "env-key"); + assert.equal( + getApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), + "env-key", + ); }); - it("reads apiKey and commandcode 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-")); try { const first = join(dir, "first.json"); const second = join(dir, "second.json"); + const oauth = join(dir, "oauth.json"); writeFileSync(first, JSON.stringify({ apiKey: "file-key" })); writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })); - assert.equal(getApiKey({ env: {}, authPaths: [first, second] }), "file-key"); + writeFileSync( + oauth, + JSON.stringify({ + commandcode: { + type: "oauth", + access: "oauth-access-key", + refresh: "oauth-refresh-key", + expires: Date.now() + 3600000, + }, + }), + ); + assert.equal( + getApiKey({ env: {}, authPaths: [first, second] }), + "file-key", + ); assert.equal(getApiKey({ env: {}, authPaths: [second] }), "fallback-key"); + assert.equal( + getApiKey({ env: {}, authPaths: [oauth] }), + "oauth-access-key", + ); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -57,7 +79,10 @@ describe("getApiKey()", () => { try { const authDir = join(dir, ".pi", "agent"); mkdirSync(authDir, { recursive: true }); - writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify({ commandcode: "pi-key" }), + ); assert.equal(getApiKey({ env: {}, homeDir: () => dir }), "pi-key"); } finally { rmSync(dir, { recursive: true, force: true }); @@ -68,7 +93,13 @@ describe("getApiKey()", () => { describe("textContent()", () => { it("extracts and joins text blocks", () => { assert.equal( - textContent({ content: [{ type: "text", text: "hello" }, { type: "image", data: "x" }, { type: "text", text: "world" }] }), + textContent({ + content: [ + { type: "text", text: "hello" }, + { type: "image", data: "x" }, + { type: "text", text: "world" }, + ], + }), "hello\nworld", ); }); @@ -92,10 +123,13 @@ describe("toJsonSchema()", () => { assert.deepEqual(toJsonSchema({ kind: "string" }), { type: "string" }); assert.deepEqual(toJsonSchema({ kind: "Number" }), { type: "number" }); assert.deepEqual(toJsonSchema({ kind: "boolean" }), { type: "boolean" }); - assert.deepEqual(toJsonSchema({ kind: "string", enum: ["left", "right"] }), { - type: "string", - enum: ["left", "right"], - }); + assert.deepEqual( + toJsonSchema({ kind: "string", enum: ["left", "right"] }), + { + type: "string", + enum: ["left", "right"], + }, + ); assert.deepEqual( toJsonSchema({ kind: "object", @@ -106,12 +140,21 @@ describe("toJsonSchema()", () => { }), { type: "object", - properties: { name: { type: "string" }, tags: { type: "array", items: { type: "string" } } }, + properties: { + name: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, required: ["name"], }, ); - assert.deepEqual(toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), { type: "string" }); - assert.deepEqual(toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), { type: "number" }); + assert.deepEqual( + toJsonSchema({ kind: "optional", wrapped: { kind: "string" } }), + { type: "string" }, + ); + assert.deepEqual( + toJsonSchema({ kind: "union", variants: [{}, { kind: "number" }] }), + { type: "number" }, + ); }); it("preserves explicit required arrays and handles unknown values", () => { @@ -174,7 +217,12 @@ describe("messagesToCC()", () => { content: [ { type: "thinking", thinking: "I will read" }, { type: "text", text: "Sure" }, - { type: "toolCall", id: "c1", name: "read", arguments: { path: "/tmp/test" } }, + { + type: "toolCall", + id: "c1", + name: "read", + arguments: { path: "/tmp/test" }, + }, ], }, { @@ -182,7 +230,10 @@ describe("messagesToCC()", () => { toolCallId: "c1", toolName: "read", isError: false, - content: [{ type: "text", text: "hello" }, { type: "text", text: "world" }], + content: [ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ], }, ]); @@ -191,7 +242,32 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["1", "content", "0", "type"]), "reasoning"); assert.equal(objectAt(result, ["1", "content", "2", "type"]), "tool-call"); assert.equal(objectAt(result, ["2", "role"]), "tool"); - assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld"); + assert.equal( + objectAt(result, ["2", "content", "0", "output", "value"]), + "hello\nworld", + ); + }); + + it("drops orphaned tool calls that have no matching tool result", () => { + const result = messagesToCC([ + { role: "user", content: "edit a file" }, + { + role: "assistant", + content: [ + { type: "text", text: "I will edit it" }, + { + type: "toolCall", + id: "missing-result", + name: "edit", + arguments: { path: "x" }, + }, + ], + }, + ]); + + assert.equal(objectAt(result, ["1", "role"]), "assistant"); + assert.equal(objectAt(result, ["1", "content", "0", "type"]), "text"); + assert.equal(objectAt(result, ["1", "content", "1"]), undefined); }); it("handles empty conversations", () => { @@ -201,11 +277,17 @@ describe("messagesToCC()", () => { describe("parseStreamEventLine()", () => { it("parses plain JSON and SSE data lines", () => { - assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { type: "text-delta", text: "x" }); - assert.deepEqual(parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), { - type: "finish", - finishReason: "stop", + assert.deepEqual(parseStreamEventLine('{"type":"text-delta","text":"x"}'), { + type: "text-delta", + text: "x", }); + assert.deepEqual( + parseStreamEventLine('data: {"type":"finish","finishReason":"stop"}'), + { + type: "finish", + finishReason: "stop", + }, + ); }); it("ignores comments, event labels, done markers, and malformed JSON", () => { diff --git a/tests/test-smoke.mjs b/tests/test-smoke.mjs index 064ae59..c3e95e8 100644 --- a/tests/test-smoke.mjs +++ b/tests/test-smoke.mjs @@ -47,9 +47,11 @@ const RPC_START_TIMEOUT = 15_000; const RPC_QUERY_TIMEOUT = 60_000; function hasCommandCodeAuth() { - return !!process.env.COMMANDCODE_API_KEY || + return ( + !!process.env.COMMANDCODE_API_KEY || existsSync(join(homedir(), ".commandcode", "auth.json")) || - existsSync(join(homedir(), ".pi", "agent", "auth.json")); + existsSync(join(homedir(), ".pi", "agent", "auth.json")) + ); } const HAS_AUTH = hasCommandCodeAuth(); @@ -76,7 +78,9 @@ function kill(child) { async function runPrintMode() { if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping print mode test\n"); + console.log( + "[smoke] SKIP — Command Code auth not found, skipping print mode test\n", + ); skipped++; return; } @@ -87,23 +91,37 @@ async function runPrintMode() { } console.log(`[smoke] Running pi in print mode with extension: ${EXT_PATH}`); - console.log(`[smoke] ${PI_BIN} -e ${EXT_PATH} -p "say hi" --provider commandcode --model ${TEST_MODEL}\n`); + console.log( + `[smoke] ${PI_BIN} -e ${EXT_PATH} -p "say hi" --provider commandcode --model ${TEST_MODEL}\n`, + ); - const child = spawn(PI_BIN, [ - "-e", EXT_PATH, - "-p", "say hi in one word", - "--provider", "commandcode", - "--model", TEST_MODEL, - ], { - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - }); + const child = spawn( + PI_BIN, + [ + "-e", + EXT_PATH, + "-p", + "say hi in one word", + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + { + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); let stdout = ""; let stderr = ""; - child.stdout.on("data", (d) => { stdout += d.toString(); }); - child.stderr.on("data", (d) => { stderr += d.toString(); }); + child.stdout.on("data", (d) => { + stdout += d.toString(); + }); + child.stderr.on("data", (d) => { + stderr += d.toString(); + }); const done = new Promise((resolve) => { const timer = setTimeout(() => { @@ -115,11 +133,17 @@ async function runPrintMode() { child.on("close", (code) => { clearTimeout(timer); if (code === 0) { - console.log("[smoke] PASS — extension loaded and agent ran without crash"); - console.log(`[smoke] stdout (last 300 chars): ${stdout.slice(-300).trim()}`); + console.log( + "[smoke] PASS — extension loaded and agent ran without crash", + ); + console.log( + `[smoke] stdout (last 300 chars): ${stdout.slice(-300).trim()}`, + ); } else { console.log(`[smoke] FAIL — exit code ${code}`); - console.log(`[smoke] stderr (last 500 chars): ${stderr.slice(-500).trim()}`); + console.log( + `[smoke] stderr (last 500 chars): ${stderr.slice(-500).trim()}`, + ); } resolve(code === 0); }); @@ -136,7 +160,9 @@ async function runPrintMode() { async function runListModels() { if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping model list test\n"); + console.log( + "[smoke] SKIP — Command Code auth not found, skipping model list test\n", + ); skipped++; return; } @@ -146,19 +172,20 @@ async function runListModels() { return; } - console.log(`[smoke] Checking that models are discoverable via pi --list-models\n`); + console.log( + `[smoke] Checking that models are discoverable via pi --list-models\n`, + ); - const child = spawn(PI_BIN, [ - "-e", EXT_PATH, - "--list-models", - ], { + const child = spawn(PI_BIN, ["-e", EXT_PATH, "--list-models"], { env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; - child.stdout.on("data", (d) => { stdout += d.toString(); }); + child.stdout.on("data", (d) => { + stdout += d.toString(); + }); const done = new Promise((resolve) => { const timer = setTimeout(() => { @@ -172,8 +199,12 @@ async function runListModels() { if (code === 0 && stdout.includes("commandcode")) { console.log("[smoke] PASS — commandcode provider models are listed"); } else { - console.log("[smoke] FAIL — commandcode models not found or error listing"); - console.log(`[smoke] stdout (last 500 chars): ${stdout.slice(-500).trim()}`); + console.log( + "[smoke] FAIL — commandcode models not found or error listing", + ); + console.log( + `[smoke] stdout (last 500 chars): ${stdout.slice(-500).trim()}`, + ); } resolve(code === 0 && stdout.includes("commandcode")); }); @@ -190,12 +221,16 @@ async function runListModels() { async function runRpcStartup() { if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping RPC startup test\n"); + console.log( + "[smoke] SKIP — Command Code auth not found, skipping RPC startup test\n", + ); skipped++; return; } if (!HAS_PI) { - console.log("[smoke] SKIP — pi is not on PATH, skipping RPC startup test\n"); + console.log( + "[smoke] SKIP — pi is not on PATH, skipping RPC startup test\n", + ); skipped++; return; } @@ -203,10 +238,7 @@ async function runRpcStartup() { console.log(`[smoke] Testing RPC mode startup with extension\n`); console.log(`[smoke] ${PI_BIN} --mode rpc -e ${EXT_PATH}\n`); - const child = spawn(PI_BIN, [ - "--mode", "rpc", - "-e", EXT_PATH, - ], { + const child = spawn(PI_BIN, ["--mode", "rpc", "-e", EXT_PATH], { env: { ...process.env }, stdio: ["pipe", "pipe", "pipe"], }); @@ -226,13 +258,20 @@ async function runRpcStartup() { try { const msg = JSON.parse(trimmed); events.push(msg); - if (msg.type === "response" && msg.id === "state-1" && msg.command === "get_state" && msg.success === true) { + if ( + msg.type === "response" && + msg.id === "state-1" && + msg.command === "get_state" && + msg.success === true + ) { sawStateResponse = true; console.log("[smoke] RPC received get_state response"); } if (msg.type === "error" || msg.type === "fatal") { sawError = true; - console.error(`[smoke] RPC error: ${JSON.stringify(msg).slice(0, 300)}`); + console.error( + `[smoke] RPC error: ${JSON.stringify(msg).slice(0, 300)}`, + ); } } catch { // ignore non-JSON @@ -241,7 +280,9 @@ async function runRpcStartup() { }); const result = new Promise((resolve) => { - child.stdin.write(JSON.stringify({ id: "state-1", type: "get_state" }) + "\n"); + child.stdin.write( + JSON.stringify({ id: "state-1", type: "get_state" }) + "\n", + ); const timer = setTimeout(async () => { if (sawStateResponse) { @@ -252,14 +293,18 @@ async function runRpcStartup() { resolve(false); } // Send quit - try { child.stdin.write(JSON.stringify({ type: "quit" }) + "\n"); } catch {} + try { + child.stdin.write(JSON.stringify({ type: "quit" }) + "\n"); + } catch {} kill(child); }, RPC_START_TIMEOUT); child.on("close", (code) => { clearTimeout(timer); if (!sawStateResponse && !sawError) { - console.log(`[smoke] FAIL — pi exited with code ${code} before get_state response`); + console.log( + `[smoke] FAIL — pi exited with code ${code} before get_state response`, + ); resolve(false); } }); @@ -276,7 +321,9 @@ async function runRpcStartup() { async function runRpcQuery() { if (!HAS_AUTH) { - console.log("[smoke] SKIP — Command Code auth not found, skipping RPC prompt test\n"); + console.log( + "[smoke] SKIP — Command Code auth not found, skipping RPC prompt test\n", + ); skipped++; return; } @@ -287,17 +334,27 @@ async function runRpcQuery() { } console.log(`[smoke] Testing RPC prompt flow\n`); - console.log(`[smoke] pi --mode rpc -e ${EXT_PATH} → prompt "say hi" → expect response\n`); + console.log( + `[smoke] pi --mode rpc -e ${EXT_PATH} → prompt "say hi" → expect response\n`, + ); - const child = spawn(PI_BIN, [ - "--mode", "rpc", - "-e", EXT_PATH, - "--provider", "commandcode", - "--model", TEST_MODEL, - ], { - env: { ...process.env }, - stdio: ["pipe", "pipe", "pipe"], - }); + const child = spawn( + PI_BIN, + [ + "--mode", + "rpc", + "-e", + EXT_PATH, + "--provider", + "commandcode", + "--model", + TEST_MODEL, + ], + { + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); let sawPromptAccepted = false; let sawAssistantMessage = false; @@ -312,12 +369,19 @@ async function runRpcQuery() { if (!trimmed) continue; try { const msg = JSON.parse(trimmed); - if (msg.type === "response" && msg.id === "prompt-1" && msg.command === "prompt" && msg.success === true) { + if ( + msg.type === "response" && + msg.id === "prompt-1" && + msg.command === "prompt" && + msg.success === true + ) { sawPromptAccepted = true; } if (msg.type === "message_end" && msg.message?.role === "assistant") { sawAssistantMessage = true; - console.log("[smoke] PASS — received assistant message_end in RPC mode"); + console.log( + "[smoke] PASS — received assistant message_end in RPC mode", + ); } } catch { // ignore @@ -326,7 +390,13 @@ async function runRpcQuery() { }); const result = new Promise((resolve) => { - child.stdin.write(JSON.stringify({ id: "prompt-1", type: "prompt", message: "say hi in one word" }) + "\n"); + child.stdin.write( + JSON.stringify({ + id: "prompt-1", + type: "prompt", + message: "say hi in one word", + }) + "\n", + ); console.log("[smoke] Sent RPC prompt"); const timer = setTimeout(() => { @@ -334,10 +404,14 @@ async function runRpcQuery() { console.log("[smoke] PASS — full RPC prompt/response cycle works"); resolve(true); } else { - console.log("[smoke] WARN — no assistant message_end received (may still be streaming)"); + console.log( + "[smoke] WARN — no assistant message_end received (may still be streaming)", + ); resolve(false); } - try { child.stdin.write(JSON.stringify({ type: "quit" }) + "\n"); } catch {} + try { + child.stdin.write(JSON.stringify({ type: "quit" }) + "\n"); + } catch {} kill(child); }, RPC_QUERY_TIMEOUT); @@ -362,7 +436,9 @@ async function runRpcQuery() { console.log("=".repeat(60)); console.log(" pi-commandcode-provider Integration Smoke Test"); console.log("=".repeat(60)); -console.log(` Auth: ${HAS_AUTH ? "✓ found" : "✗ not found (tests will be skipped)"}`); +console.log( + ` Auth: ${HAS_AUTH ? "✓ found" : "✗ not found (tests will be skipped)"}`, +); console.log(` Extension: ${EXT_PATH}`); console.log("=".repeat(60)); console.log(""); @@ -374,7 +450,9 @@ await runRpcQuery(); console.log(""); console.log("=".repeat(60)); -console.log(` SUITE RESULT: ${passed} passed, ${failed} failed, ${skipped} skipped`); +console.log( + ` SUITE RESULT: ${passed} passed, ${failed} failed, ${skipped} skipped`, +); console.log("=".repeat(60)); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 1f25cfc..7a90680 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -37,8 +37,14 @@ function eventTypes(events: readonly AssistantMessageEvent[]): string[] { describe("streamCommandCode — auth", () => { it("emits a missing-key error without touching the network", async () => { - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl(), env: {}, authPaths: [] }); - const stream = streamCommandCode(makeModel(), makeContext(), { apiKey: "" }); + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + env: {}, + authPaths: [], + }); + const stream = streamCommandCode(makeModel(), makeContext(), { + apiKey: "", + }); const events = await collectEvents(stream); assert.deepEqual(eventTypes(events), ["error"]); @@ -53,11 +59,19 @@ describe("streamCommandCode — auth", () => { type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })], }); - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl(), env: { COMMANDCODE_API_KEY: "env-key" } }); + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + env: { COMMANDCODE_API_KEY: "env-key" }, + }); - await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" })); + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "option-key" }), + ); - assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key"); + assert.equal( + server.lastRequestHeaders().authorization, + "Bearer option-key", + ); }); }); @@ -79,17 +93,33 @@ describe("streamCommandCode — successful streams", () => { }), ], }); - const { streamCommandCode, calculatedUsages } = createTestDeps({ apiBase: server.baseUrl() }); + const { streamCommandCode, calculatedUsages } = createTestDeps({ + apiBase: server.baseUrl(), + }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ); - assert.deepEqual(eventTypes(events), ["start", "text_start", "text_delta", "text_delta", "text_end", "done"]); + assert.deepEqual(eventTypes(events), [ + "start", + "text_start", + "text_delta", + "text_delta", + "text_end", + "done", + ]); const done = events.at(-1); assert.equal(done?.type, "done"); if (done?.type !== "done") throw new Error("expected done"); assert.equal(done.reason, "stop"); assert.equal(done.message.content[0]?.type, "text"); - assert.equal(done.message.content[0]?.type === "text" ? done.message.content[0].text : "", "Hello"); + assert.equal( + done.message.content[0]?.type === "text" + ? done.message.content[0].text + : "", + "Hello", + ); assert.equal(done.message.usage.totalTokens, 11); assert.equal(calculatedUsages.length, 1); }); @@ -105,11 +135,17 @@ describe("streamCommandCode — successful streams", () => { }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), 500); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + 500, + ); assert.equal(events.at(-1)?.type, "done"); await new Promise((resolve) => setTimeout(resolve, 50)); - assert.ok(server.responseClosedBeforeEnd(), "client should cancel the still-open response body"); + assert.ok( + server.responseClosedBeforeEnd(), + "client should cancel the still-open response body", + ); }); it("emits reasoning and tool-call blocks in order", async () => { @@ -119,13 +155,20 @@ describe("streamCommandCode — successful streams", () => { JSON.stringify({ type: "reasoning-delta", text: "think" }), JSON.stringify({ type: "reasoning-end" }), JSON.stringify({ type: "text-delta", text: "Using tool" }), - JSON.stringify({ type: "tool-call", toolCallId: "call_1", toolName: "read_file", input: { path: "/tmp/x" } }), + JSON.stringify({ + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: JSON.stringify({ path: "/tmp/x" }), + }), JSON.stringify({ type: "finish", finishReason: "tool-calls" }), ], }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ); assert.deepEqual(eventTypes(events), [ "start", @@ -142,9 +185,15 @@ describe("streamCommandCode — successful streams", () => { const done = events.at(-1); if (done?.type !== "done") throw new Error("expected done"); assert.equal(done.reason, "toolUse"); - assert.deepEqual(done.message.content.map((content) => content.type), ["thinking", "text", "toolCall"]); + assert.deepEqual( + done.message.content.map((content) => content.type), + ["thinking", "text", "toolCall"], + ); const toolCall = done.message.content[2]; - assert.equal(toolCall?.type === "toolCall" ? toolCall.name : "", "read_file"); + assert.equal( + toolCall?.type === "toolCall" ? toolCall.name : "", + "read_file", + ); }); it("flushes reasoning if finish arrives without reasoning-end", async () => { @@ -157,7 +206,9 @@ describe("streamCommandCode — successful streams", () => { }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ); const done = events.at(-1); if (done?.type !== "done") throw new Error("expected done"); @@ -167,67 +218,109 @@ describe("streamCommandCode — successful streams", () => { describe("streamCommandCode — request serialization", () => { it("sends the expected request body and default headers", async () => { - server.mockResponse({ type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })] }); + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); const context = makeContext({ messages: [ { role: "user", content: "first" }, - { role: "assistant", content: [{ type: "text", text: "first response" }] }, + { + role: "assistant", + content: [{ type: "text", text: "first response" }], + }, { role: "user", content: "second" }, ], tools: [ { name: "get_weather", description: "Get weather", - parameters: { kind: "object", properties: { city: { kind: "string" } } }, + parameters: { + kind: "object", + properties: { city: { kind: "string" } }, + }, }, ], }); - await collectEvents(streamCommandCode(makeModel(), context, { apiKey: "mock-key", maxTokens: 500 })); + await collectEvents( + streamCommandCode(makeModel(), context, { + apiKey: "mock-key", + maxTokens: 500, + }), + ); const body = server.lastRequestBody(); assert.equal(objectAt(body, ["config", "workingDir"]), "/repo"); assert.equal(objectAt(body, ["config", "date"]), "2026-05-05"); - assert.equal(objectAt(body, ["params", "model"]), "deepseek/deepseek-v4-flash"); + assert.equal( + objectAt(body, ["params", "model"]), + "deepseek/deepseek-v4-flash", + ); assert.equal(objectAt(body, ["params", "stream"]), true); assert.equal(objectAt(body, ["params", "max_tokens"]), 500); - assert.equal(objectAt(body, ["params", "system"]), "You are a test assistant."); - assert.equal(objectAt(body, ["params", "messages", "1", "content", "0", "text"]), "first response"); - assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "get_weather"); + assert.equal( + objectAt(body, ["params", "system"]), + "You are a test assistant.", + ); + assert.equal( + objectAt(body, ["params", "messages", "1", "content", "0", "text"]), + "first response", + ); + assert.equal( + objectAt(body, ["params", "tools", "0", "name"]), + "get_weather", + ); const headers = server.lastRequestHeaders(); assert.equal(headers.authorization, "Bearer mock-key"); assert.equal(headers["x-command-code-version"], "0.24.1"); - assert.equal(headers["x-session-id"], "00000000-0000-4000-8000-000000000000"); + assert.equal( + headers["x-session-id"], + "00000000-0000-4000-8000-000000000000", + ); }); it("caps maxTokens and passes custom headers", async () => { - server.mockResponse({ type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })] }); + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - await collectEvents(streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { - apiKey: "mock-key", - maxTokens: 500_000, - headers: { "x-custom": "value" }, - })); + await collectEvents( + streamCommandCode(makeModel({ maxTokens: 500_000 }), makeContext(), { + apiKey: "mock-key", + maxTokens: 500_000, + headers: { "x-custom": "value" }, + }), + ); - assert.equal(objectAt(server.lastRequestBody(), ["params", "max_tokens"]), 200_000); + assert.equal( + objectAt(server.lastRequestBody(), ["params", "max_tokens"]), + 200_000, + ); assert.equal(server.lastRequestHeaders()["x-custom"], "value"); }); it("runs onPayload and onResponse hooks", async () => { - server.mockResponse({ type: "success", events: [JSON.stringify({ type: "finish", finishReason: "stop" })] }); + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); let responseStatus = 0; - await collectEvents(streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - onPayload: () => ({ replaced: true }), - onResponse: (response) => { - responseStatus = response.status; - }, - })); + await collectEvents( + streamCommandCode(makeModel(), makeContext(), { + apiKey: "mock-key", + onPayload: () => ({ replaced: true }), + onResponse: (response) => { + responseStatus = response.status; + }, + }), + ); assert.equal(objectAt(server.lastRequestBody(), ["replaced"]), true); assert.equal(responseStatus, 200); @@ -239,7 +332,9 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { server.mockResponse({ type: "error", status: 429, body: "rate limited" }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ); assert.deepEqual(eventTypes(events), ["start", "error"]); const error = events.at(-1); @@ -251,11 +346,18 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { it("emits error for provider error events", async () => { server.mockResponse({ type: "success", - events: [JSON.stringify({ type: "error", error: { message: "provider failed" } })], + events: [ + JSON.stringify({ + type: "error", + error: { message: "provider failed" }, + }), + ], }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ); const error = events.at(-1); assert.equal(error?.type, "error"); @@ -265,7 +367,10 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { 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 finishEvent = JSON.stringify({ type: "finish", finishReason: "max_tokens" }); + const finishEvent = JSON.stringify({ + type: "finish", + finishReason: "max_tokens", + }); server.mockResponse({ type: "success", chunks: [ @@ -279,11 +384,18 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { }); const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }); - const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" })); + const events = await collectEvents( + streamCommandCode(makeModel(), makeContext(), { apiKey: "mock-key" }), + ); const done = events.at(-1); if (done?.type !== "done") throw new Error("expected done"); assert.equal(done.reason, "length"); - assert.equal(done.message.content[0]?.type === "text" ? done.message.content[0].text : "", "split"); + assert.equal( + done.message.content[0]?.type === "text" + ? done.message.content[0].text + : "", + "split", + ); }); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c691796 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +}