mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
fix(paseo-agent): support empty model-provider setup
Allow catalog provider setup to persist credentials before explicit model overrides exist, while keeping unrelated provider snapshots warmable after Paseo Agent config changes.
This commit is contained in:
@@ -3,15 +3,18 @@
|
||||
/**
|
||||
* Phase 15: Provider Command Tests
|
||||
*
|
||||
* Tests provider commands for listing providers and models.
|
||||
* Provider ls data is static, while provider models are fetched via daemon integration.
|
||||
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
|
||||
* Tests provider commands for configured Paseo Agent model providers and agent
|
||||
* provider model listing. This test uses an isolated daemon to avoid coupling to
|
||||
* a user's long-running daemon.
|
||||
*
|
||||
* Tests:
|
||||
* - provider --help shows subcommands
|
||||
* - provider ls lists all providers
|
||||
* - provider ls --json outputs valid JSON
|
||||
* - provider ls --quiet outputs provider names only
|
||||
* - provider ls on a fresh daemon prints an empty configured-provider table
|
||||
* - provider add stores an API-key model provider without network validation
|
||||
* - repeated provider add updates the same provider instance
|
||||
* - provider add rejects unknown catalog ids with known ids
|
||||
* - provider rm removes a configured model provider
|
||||
* - provider add uses catalog default models when present
|
||||
* - provider models claude lists claude models
|
||||
* - provider models codex lists codex models
|
||||
* - provider models opencode lists opencode models
|
||||
@@ -20,14 +23,7 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createE2ETestContext,
|
||||
createTempDirs,
|
||||
runPaseoCli,
|
||||
startTestDaemon,
|
||||
} from "./helpers/test-daemon.ts";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Provider Commands ===\n");
|
||||
|
||||
@@ -38,10 +34,12 @@ interface ProviderModel {
|
||||
}
|
||||
|
||||
interface ProviderListRow {
|
||||
provider: string;
|
||||
name: string;
|
||||
providerType: string;
|
||||
label: string;
|
||||
status: string;
|
||||
enabled: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
@@ -131,6 +129,24 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
|
||||
return attemptRun(1);
|
||||
}
|
||||
|
||||
function parseProviderListJson(stdout: string): ProviderListRow[] {
|
||||
const data = JSON.parse(stdout.trim()) as ProviderListRow[];
|
||||
assert(Array.isArray(data), "provider ls --json output should be an array");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getProviderRows(): Promise<ProviderListRow[]> {
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls --json should exit 0");
|
||||
return parseProviderListJson(result.stdout);
|
||||
}
|
||||
|
||||
function assertProviderTableHeader(stdout: string): void {
|
||||
for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) {
|
||||
assert(stdout.includes(header), `provider ls table should include ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertClaudeModels(data: ProviderModel[]): void {
|
||||
assert.strictEqual(
|
||||
data.length,
|
||||
@@ -167,142 +183,135 @@ try {
|
||||
const result = await ctx.paseo(["provider", "--help"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
||||
assert(result.stdout.includes("ls"), "help should mention ls");
|
||||
assert(result.stdout.includes("add"), "help should mention add");
|
||||
assert(result.stdout.includes("rm"), "help should mention rm");
|
||||
assert(result.stdout.includes("models"), "help should mention models");
|
||||
console.log("✓ provider --help shows subcommands\n");
|
||||
}
|
||||
|
||||
// Test 2: provider ls lists all providers
|
||||
// Test 2: provider ls on a fresh daemon shows no configured providers
|
||||
{
|
||||
console.log("Test 2: provider ls lists all providers");
|
||||
console.log("Test 2: provider ls on a fresh daemon shows no configured providers");
|
||||
const result = await ctx.paseo(["provider", "ls"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
assert(result.stdout.includes("claude"), "output should include claude");
|
||||
assert(result.stdout.includes("codex"), "output should include codex");
|
||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
||||
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
|
||||
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
|
||||
assert(
|
||||
result.stdout.includes("available") ||
|
||||
result.stdout.includes("loading") ||
|
||||
result.stdout.includes("unavailable"),
|
||||
"output should show a provider status",
|
||||
);
|
||||
console.log("✓ provider ls lists all providers\n");
|
||||
assertProviderTableHeader(result.stdout);
|
||||
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
|
||||
|
||||
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(jsonResult.exitCode, 0, "provider ls --json should exit 0");
|
||||
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
|
||||
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
|
||||
}
|
||||
|
||||
// Test 3: provider ls --json outputs valid JSON
|
||||
// Test 3: provider add openrouter stores a dummy key without network validation
|
||||
{
|
||||
console.log("Test 3: provider ls --json outputs valid JSON");
|
||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim());
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
||||
"should include claude",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "codex"),
|
||||
"should include codex",
|
||||
);
|
||||
assert(
|
||||
data.some((p: { provider: string }) => p.provider === "opencode"),
|
||||
"should include opencode",
|
||||
);
|
||||
const rows = data as ProviderListRow[];
|
||||
for (const provider of ["claude", "codex", "opencode"] as const) {
|
||||
const row = rows.find((p) => p.provider === provider);
|
||||
assert(row, `should include ${provider}`);
|
||||
assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`);
|
||||
}
|
||||
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key\n",
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0, `provider add should exit 0\n${result.stderr}`);
|
||||
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
|
||||
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
|
||||
assert(result.stdout.includes("yes"), "add output should show the provider as available");
|
||||
|
||||
const omp = rows.find((p) => p.provider === "omp");
|
||||
assert(omp, "should include omp");
|
||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
|
||||
assert.deepStrictEqual(rows[0], {
|
||||
name: "openrouter",
|
||||
providerType: "openrouter",
|
||||
label: "OpenRouter",
|
||||
auth: "Connected",
|
||||
available: "yes",
|
||||
models: "-",
|
||||
});
|
||||
console.log("✓ provider add openrouter stores a dummy key without network validation\n");
|
||||
}
|
||||
|
||||
// Test 4: provider ls includes disabled providers
|
||||
// Test 4: provider add is idempotent for the same instance name
|
||||
{
|
||||
console.log("Test 4: provider ls includes disabled providers");
|
||||
const { paseoHome, workDir } = await createTempDirs();
|
||||
await writeFile(
|
||||
join(paseoHome, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
providers: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
console.log("Test 4: provider add is idempotent for the same instance name");
|
||||
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||
stdin: "dummy-openrouter-key-2\n",
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0, `provider add should exit 0\n${result.stderr}`);
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
|
||||
assert.strictEqual(rows[0]?.name, "openrouter");
|
||||
assert.strictEqual(rows[0]?.label, "OpenRouter");
|
||||
console.log("✓ provider add is idempotent for the same instance name\n");
|
||||
}
|
||||
|
||||
// Test 5: provider add rejects unknown catalog ids with known ids
|
||||
{
|
||||
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
|
||||
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
|
||||
stdin: "dummy-key\n",
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "provider add should fail for unknown ids");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(output.includes("nonsense-id"), "error should mention the requested id");
|
||||
assert(output.includes("Known provider ids"), "error should mention known provider ids");
|
||||
assert(output.includes("openrouter"), "known ids should include openrouter");
|
||||
assert(output.includes("kimi"), "known ids should include kimi");
|
||||
console.log("✓ provider add rejects unknown catalog ids with known ids\n");
|
||||
}
|
||||
|
||||
// Test 6: provider rm removes a configured provider
|
||||
{
|
||||
console.log("Test 6: provider rm removes a configured provider");
|
||||
const result = await ctx.paseo(["provider", "rm", "openrouter"]);
|
||||
assert.strictEqual(result.exitCode, 0, `provider rm should exit 0\n${result.stderr}`);
|
||||
assert(result.stdout.includes("openrouter"), "rm output should include the instance name");
|
||||
assert(result.stdout.includes("yes"), "rm output should report removal");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter");
|
||||
const table = await ctx.paseo(["provider", "ls"]);
|
||||
assert.strictEqual(table.exitCode, 0, "provider ls should stay successful after removal");
|
||||
assertProviderTableHeader(table.stdout);
|
||||
console.log("✓ provider rm removes a configured provider\n");
|
||||
}
|
||||
|
||||
// Test 7: provider add uses catalog default models when present
|
||||
{
|
||||
console.log("Test 7: provider add uses catalog default models when present");
|
||||
const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], {
|
||||
stdin: "dummy-kimi-key\n",
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0, `provider add kimi should exit 0\n${result.stderr}`);
|
||||
assert(result.stdout.includes("kimi"), "add output should include the instance name");
|
||||
assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label");
|
||||
|
||||
const rows = await getProviderRows();
|
||||
const kimi = rows.find((row) => row.name === "kimi");
|
||||
assert(kimi, "provider ls should include the kimi instance");
|
||||
assert.strictEqual(kimi.label, "Kimi Coding Plan");
|
||||
assert.strictEqual(kimi.auth, "Connected");
|
||||
assert.strictEqual(kimi.available, "yes");
|
||||
assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models");
|
||||
assert(
|
||||
kimi.models
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean).length > 0,
|
||||
"kimi should list at least one catalog-derived model id",
|
||||
);
|
||||
|
||||
const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 });
|
||||
try {
|
||||
const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]);
|
||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||
const data = JSON.parse(result.stdout.trim()) as ProviderListRow[];
|
||||
const claude = data.find((p) => p.provider === "claude");
|
||||
assert(claude, "disabled claude provider should stay in provider ls");
|
||||
assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled");
|
||||
|
||||
const opencode = data.find((p) => p.provider === "opencode");
|
||||
assert(opencode, "enabled opencode provider should stay in provider ls");
|
||||
assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled");
|
||||
|
||||
const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]);
|
||||
assert.notStrictEqual(
|
||||
modelsResult.exitCode,
|
||||
0,
|
||||
"provider models should fail for disabled providers",
|
||||
);
|
||||
const output = modelsResult.stdout + modelsResult.stderr;
|
||||
assert(
|
||||
output.includes("Provider claude is disabled"),
|
||||
"provider models should surface the daemon disabled error",
|
||||
);
|
||||
assert(
|
||||
!output.includes("claude-sonnet"),
|
||||
"provider models should not print fallback models for disabled providers",
|
||||
);
|
||||
} finally {
|
||||
await disabledCtx.stop();
|
||||
}
|
||||
console.log("✓ provider ls includes disabled providers\n");
|
||||
console.log("✓ provider add uses catalog default models when present\n");
|
||||
}
|
||||
|
||||
// Test 5: provider ls --quiet outputs provider names only
|
||||
// Test 8: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log("Test 5: provider ls --quiet outputs provider names only");
|
||||
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
|
||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`);
|
||||
assert(lines.includes("claude"), "should include claude");
|
||||
assert(lines.includes("codex"), "should include codex");
|
||||
assert(lines.includes("opencode"), "should include opencode");
|
||||
console.log("✓ provider ls --quiet outputs provider names only\n");
|
||||
}
|
||||
|
||||
// Test 6: provider models claude lists canonical model aliases
|
||||
{
|
||||
console.log("Test 6: provider models claude lists canonical model aliases");
|
||||
console.log("Test 8: provider models claude lists canonical model aliases");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assertClaudeModels(data);
|
||||
console.log("✓ provider models claude lists canonical model aliases\n");
|
||||
}
|
||||
|
||||
// Test 7: provider models codex includes concrete codex model IDs
|
||||
// Test 9: provider models codex includes concrete codex model IDs
|
||||
{
|
||||
console.log("Test 7: provider models codex includes concrete codex model IDs");
|
||||
console.log("Test 9: provider models codex includes concrete codex model IDs");
|
||||
const data = await runProviderModelsJson("codex");
|
||||
assert(data.length >= 1, "codex model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -322,9 +331,9 @@ try {
|
||||
console.log("✓ provider models codex includes concrete codex model IDs\n");
|
||||
}
|
||||
|
||||
// Test 8: provider models opencode returns namespaced model IDs
|
||||
// Test 10: provider models opencode returns namespaced model IDs
|
||||
{
|
||||
console.log("Test 8: provider models opencode returns namespaced model IDs");
|
||||
console.log("Test 10: provider models opencode returns namespaced model IDs");
|
||||
const data = await runProviderModelsJson("opencode");
|
||||
assert(data.length >= 1, "opencode model list should not be empty");
|
||||
const ids = data.map((m) => m.id);
|
||||
@@ -343,9 +352,9 @@ try {
|
||||
console.log("✓ provider models opencode returns namespaced model IDs\n");
|
||||
}
|
||||
|
||||
// Test 9: provider models unknown fails with error
|
||||
// Test 11: provider models unknown fails with error
|
||||
{
|
||||
console.log("Test 9: provider models unknown fails with error");
|
||||
console.log("Test 11: provider models unknown fails with error");
|
||||
const result = await ctx.paseo(["provider", "models", "unknown"]);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
@@ -356,9 +365,9 @@ try {
|
||||
console.log("✓ provider models unknown fails with error\n");
|
||||
}
|
||||
|
||||
// Test 10: provider models --json outputs valid JSON
|
||||
// Test 12: provider models --json outputs valid JSON
|
||||
{
|
||||
console.log("Test 10: provider models --json outputs valid JSON");
|
||||
console.log("Test 12: provider models --json outputs valid JSON");
|
||||
const data = await runProviderModelsJson("claude");
|
||||
assert(Array.isArray(data), "output should be an array");
|
||||
assert(
|
||||
@@ -371,9 +380,9 @@ try {
|
||||
console.log("✓ provider models --json outputs valid JSON\n");
|
||||
}
|
||||
|
||||
// Test 11: provider models --quiet outputs model IDs only
|
||||
// Test 13: provider models --quiet outputs model IDs only
|
||||
{
|
||||
console.log("Test 11: provider models --quiet outputs model IDs only");
|
||||
console.log("Test 13: provider models --quiet outputs model IDs only");
|
||||
assert(
|
||||
claudeModelIdsFromJson.length > 0,
|
||||
"claude model IDs should be captured from --json output",
|
||||
|
||||
@@ -336,6 +336,7 @@ export async function runPaseoCli(
|
||||
timeout?: number;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
const timeout = options?.timeout ?? 60000;
|
||||
@@ -354,7 +355,7 @@ export async function runPaseoCli(
|
||||
...options?.env,
|
||||
},
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
});
|
||||
|
||||
@@ -369,6 +370,10 @@ export async function runPaseoCli(
|
||||
appendOutputCapture(stderr, data);
|
||||
});
|
||||
|
||||
if (options?.stdin !== undefined) {
|
||||
proc.stdin?.end(options.stdin);
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (proc.pid) {
|
||||
signalProcessTree(proc.pid, "SIGKILL");
|
||||
@@ -406,7 +411,7 @@ export async function createE2ETestContext(options?: {
|
||||
/** Run a paseo CLI command against this daemon */
|
||||
paseo: (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
) => Promise<{
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
@@ -418,7 +423,7 @@ export async function createE2ETestContext(options?: {
|
||||
|
||||
const paseo = (
|
||||
args: string[],
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||
) => runPaseoCli(ctx, args, opts);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user