chore: add CI workflow (typecheck + prettier format check), tsconfig, and prettier formatting
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 |
|
||||
| **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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+17
@@ -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",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<AuthCallback>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AuthServer> {
|
||||
let resolveCallback: (value: AuthCallback) => void;
|
||||
let rejectCallback: (error: Error) => void;
|
||||
|
||||
const waitForCallback = new Promise<AuthCallback>((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<string, unknown>;
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
+88
-16
@@ -16,17 +16,30 @@ function booleanValue(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function recordArray(value: unknown): readonly Record<string, unknown>[] {
|
||||
export function recordArray(
|
||||
value: unknown,
|
||||
): readonly Record<string, unknown>[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isRecord);
|
||||
}
|
||||
|
||||
export function recordOrEmpty(value: unknown): Record<string, unknown> {
|
||||
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: {
|
||||
export function getApiKey(
|
||||
options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
authPaths?: readonly string[];
|
||||
homeDir?: () => string;
|
||||
} = {}): string | undefined {
|
||||
} = {},
|
||||
): 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<string, unknown> = {};
|
||||
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<string, unknown> = { 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<string> {
|
||||
const callIds = new Set<string>();
|
||||
const resultIds = new Set<string>();
|
||||
|
||||
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;
|
||||
|
||||
|
||||
+107
-27
@@ -50,12 +50,18 @@ function defaultUsage(): Usage {
|
||||
};
|
||||
}
|
||||
|
||||
function commandCodeUsage(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
function commandCodeUsage(
|
||||
event: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
return isRecord(event.totalUsage) ? event.totalUsage : undefined;
|
||||
}
|
||||
|
||||
function commandCodeInputTokenDetails(usage: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
return isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : undefined;
|
||||
function commandCodeInputTokenDetails(
|
||||
usage: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
return isRecord(usage.inputTokenDetails)
|
||||
? usage.inputTokenDetails
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
@@ -109,7 +115,9 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
const stream = deps.createStream();
|
||||
|
||||
async function run() {
|
||||
const apiKey = options?.apiKey ?? getApiKey({
|
||||
const apiKey =
|
||||
options?.apiKey ??
|
||||
getApiKey({
|
||||
env: deps.env,
|
||||
authPaths: deps.authPaths,
|
||||
homeDir: deps.homeDir,
|
||||
@@ -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"
|
||||
output.errorMessage =
|
||||
reason === "aborted"
|
||||
? "Request aborted"
|
||||
: error instanceof Error ? error.message : String(error);
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
stream.push({ type: "error", reason, error: output });
|
||||
stream.end();
|
||||
} finally {
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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<OAuthCredentials> {
|
||||
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<OAuthCredentials> {
|
||||
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;
|
||||
}
|
||||
+48
-9
@@ -87,20 +87,59 @@ export interface StreamOptions {
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
maxTokens?: number;
|
||||
onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise<unknown>;
|
||||
onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise<void>;
|
||||
onPayload?: (
|
||||
payload: unknown,
|
||||
model: ModelLike,
|
||||
) => unknown | Promise<unknown>;
|
||||
onResponse?: (
|
||||
response: ProviderResponseInfo,
|
||||
model: ModelLike,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
|
||||
+14
-3
@@ -65,7 +65,15 @@ export async function collectEvents(
|
||||
return await Promise.race([
|
||||
collect(),
|
||||
new Promise<AssistantMessageEvent[]>((_, 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<CoreDependencies> = {}): TestDepsResult {
|
||||
export function createTestDeps(
|
||||
overrides: Partial<CoreDependencies> = {},
|
||||
): TestDepsResult {
|
||||
const calculatedUsages: Usage[] = [];
|
||||
const streamCommandCode = createStreamCommandCode({
|
||||
createStream: createTestEventStream,
|
||||
@@ -197,7 +207,8 @@ export async function startMockCommandCodeServer(): Promise<MockCommandCodeServe
|
||||
if (!ended) closedBeforeEnd = true;
|
||||
});
|
||||
|
||||
const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`);
|
||||
const chunks =
|
||||
plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`);
|
||||
const delays = plan.delays ?? chunks.map(() => 0);
|
||||
let index = 0;
|
||||
|
||||
|
||||
+16
-5
@@ -34,12 +34,17 @@ describe("streamCommandCode — abort behavior", () => {
|
||||
controller.abort();
|
||||
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() });
|
||||
|
||||
const events = await collectEvents(streamCommandCode(makeModel(), makeContext(), {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
+94
-28
@@ -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,
|
||||
], {
|
||||
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);
|
||||
|
||||
@@ -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"] }), {
|
||||
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"}'), {
|
||||
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", () => {
|
||||
|
||||
+130
-52
@@ -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,
|
||||
], {
|
||||
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,
|
||||
], {
|
||||
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);
|
||||
|
||||
+150
-38
@@ -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(), {
|
||||
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(), {
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user