feat: add "Anthropic — via Claude CLI" as a first-class provider
Replaces the stray useClaudeCli settings checkbox + onboarding question
with a proper provider-card UX. The card lives next to OAuth + API-key
cards in onboarding and settings, with Enable/Disable + Test actions.
Backend:
- Vendors rchern/pi-claude-cli@0.3.1 as packages/pi-claude-cli
(MIT, attribution in UPSTREAM.md). Lets us bump peer-dep on
pi-coding-agent in lockstep with Fusion (upstream pinned ^0.52.0
vs ours ^0.62.0) and fix bugs without waiting on upstream.
- Adds @fusion/pi-claude-cli as a workspace dep of @runfusion/fusion
so users don't have to `npm install -g pi-claude-cli` manually.
- serve/daemon/dashboard conditionally load the extension via
discoverAndLoadExtensions() when GlobalSettings.useClaudeCli is on;
no side-effects on user ~/.fusion/agent/settings.json.
- New GET /api/providers/claude-cli/status: claude --version probe
+ toggle state + cached extension resolution.
- New POST /api/auth/claude-cli: flips useClaudeCli, refuses if the
claude binary is missing, fires the existing skill-backfill hook.
- /api/auth/status now injects a synthetic {id:"claude-cli", type:"cli"}
provider entry so onboarding + settings see a consistent list.
Frontend:
- New ClaudeCliProviderCard component shared between ModelOnboardingModal
and SettingsModal's Authentication section.
- New AuthProvider.type = "cli" variant.
- Removed the old "Route AI calls through the Claude CLI" checkbox from
Global Models settings and the opt-in step from the onboarding wizard.
- ProviderIcon gets a composite Anthropic-mark-plus-terminal glyph for
the claude-cli provider id.
Tests:
- 8 unit tests for extension resolution (@fusion/pi-claude-cli is
workspace-linked so these run in-tree).
- 2 unit tests for the binary probe.
- Existing /auth/status tests filter out the new synthetic entry so
they keep asserting structural OAuth/API-key behavior in isolation.
- The vendored package's own 296 tests still pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
191
packages/pi-claude-cli/tests/control-handler.test.ts
Normal file
191
packages/pi-claude-cli/tests/control-handler.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { ClaudeControlRequest } from "../src/types";
|
||||
import {
|
||||
handleControlRequest,
|
||||
TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
MCP_PREFIX,
|
||||
} from "../src/control-handler";
|
||||
|
||||
function createMockStdin() {
|
||||
const stream = new PassThrough();
|
||||
const chunks: string[] = [];
|
||||
stream.on("data", (data: Buffer) => chunks.push(data.toString()));
|
||||
return { stream, chunks };
|
||||
}
|
||||
|
||||
function makeControlRequest(
|
||||
toolName: string,
|
||||
requestId = "req-test-001",
|
||||
input: Record<string, unknown> = {},
|
||||
): ClaudeControlRequest {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: requestId,
|
||||
request: {
|
||||
subtype: "can_use_tool",
|
||||
tool_name: toolName,
|
||||
input,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("control-handler", () => {
|
||||
describe("exported constants", () => {
|
||||
it("exports TOOL_EXECUTION_DENIED_MESSAGE", () => {
|
||||
expect(TOOL_EXECUTION_DENIED_MESSAGE).toBe(
|
||||
"Tool execution is unavailable in this environment.",
|
||||
);
|
||||
});
|
||||
|
||||
it("exports MCP_PREFIX", () => {
|
||||
expect(MCP_PREFIX).toBe("mcp__");
|
||||
});
|
||||
});
|
||||
|
||||
describe("denies custom MCP tools (mcp__custom-tools__*)", () => {
|
||||
it("denies mcp__custom-tools__weather and returns false", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("mcp__custom-tools__weather");
|
||||
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(false);
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.behavior).toBe("deny");
|
||||
expect(response.response.response.message).toBe(
|
||||
TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies mcp__custom-tools__deploy", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("mcp__custom-tools__deploy");
|
||||
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(false);
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("allows user MCP tools and other tools", () => {
|
||||
it("allows user MCP tool mcp__database__query and returns true", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("mcp__database__query");
|
||||
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows built-in tool Read", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("Read");
|
||||
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows internal tools like ToolSearch", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("ToolSearch");
|
||||
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows unknown tools", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("SomeUnknownTool");
|
||||
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("response format", () => {
|
||||
it("includes matching request_id", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("Read", "custom-req-id-42");
|
||||
|
||||
handleControlRequest(msg, stream);
|
||||
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.request_id).toBe("custom-req-id-42");
|
||||
});
|
||||
|
||||
it("writes response as NDJSON (JSON + newline)", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("Read");
|
||||
|
||||
handleControlRequest(msg, stream);
|
||||
|
||||
expect(chunks[0].endsWith("\n")).toBe(true);
|
||||
expect(() => JSON.parse(chunks[0].trim())).not.toThrow();
|
||||
});
|
||||
|
||||
it("deny response includes message field", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("mcp__custom-tools__foo");
|
||||
|
||||
handleControlRequest(msg, stream);
|
||||
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.message).toBe(
|
||||
TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("allow response does not include a message field", () => {
|
||||
const { stream, chunks } = createMockStdin();
|
||||
const msg = makeControlRequest("mcp__database__query");
|
||||
|
||||
handleControlRequest(msg, stream);
|
||||
|
||||
const response = JSON.parse(chunks[0].trim());
|
||||
expect(response.response.response.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed input", () => {
|
||||
it("returns false for missing request_id", () => {
|
||||
const { stream } = createMockStdin();
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const msg = {
|
||||
type: "control_request",
|
||||
} as unknown as ClaudeControlRequest;
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns false for missing request object", () => {
|
||||
const { stream } = createMockStdin();
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const msg = {
|
||||
type: "control_request",
|
||||
request_id: "req-001",
|
||||
} as unknown as ClaudeControlRequest;
|
||||
const result = handleControlRequest(msg, stream);
|
||||
|
||||
expect(result).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
1244
packages/pi-claude-cli/tests/event-bridge.test.ts
Normal file
1244
packages/pi-claude-cli/tests/event-bridge.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
272
packages/pi-claude-cli/tests/mcp-config.test.ts
Normal file
272
packages/pi-claude-cli/tests/mcp-config.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Hoist mock references so they survive vi.mock hoisting
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
tmpdir: vi.fn(() => "/tmp"),
|
||||
}));
|
||||
|
||||
// Mock node:fs writeFileSync to avoid disk I/O
|
||||
vi.mock("node:fs", () => ({
|
||||
writeFileSync: mocks.writeFileSync,
|
||||
}));
|
||||
|
||||
// Mock node:os tmpdir
|
||||
vi.mock("node:os", () => ({
|
||||
tmpdir: mocks.tmpdir,
|
||||
}));
|
||||
|
||||
import { getCustomToolDefs, writeMcpConfig } from "../src/mcp-config";
|
||||
import type { McpToolDef } from "../src/mcp-config";
|
||||
|
||||
describe("getCustomToolDefs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("filters out all 6 built-in tools and returns only custom tools", () => {
|
||||
const mockPi = {
|
||||
getAllTools: vi.fn(() => [
|
||||
{
|
||||
name: "read",
|
||||
description: "Read file",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
description: "Write file",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "edit",
|
||||
description: "Edit file",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "bash",
|
||||
description: "Run bash",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{ name: "grep", description: "Search", parameters: { type: "object" } },
|
||||
{
|
||||
name: "find",
|
||||
description: "Find files",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
description: "Custom search tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deploy",
|
||||
description: "Deploy app",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { target: { type: "string" } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const result = getCustomToolDefs(mockPi);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe("search");
|
||||
expect(result[1].name).toBe("deploy");
|
||||
});
|
||||
|
||||
it("returns empty array when all tools are built-in", () => {
|
||||
const mockPi = {
|
||||
getAllTools: vi.fn(() => [
|
||||
{
|
||||
name: "read",
|
||||
description: "Read file",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "write",
|
||||
description: "Write file",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "edit",
|
||||
description: "Edit file",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{
|
||||
name: "bash",
|
||||
description: "Run bash",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
{ name: "grep", description: "Search", parameters: { type: "object" } },
|
||||
{
|
||||
name: "find",
|
||||
description: "Find files",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const result = getCustomToolDefs(mockPi);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes custom tool with correct name, description, inputSchema from parameters", () => {
|
||||
const customParams = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
limit: { type: "number" },
|
||||
},
|
||||
required: ["query"],
|
||||
};
|
||||
|
||||
const mockPi = {
|
||||
getAllTools: vi.fn(() => [
|
||||
{
|
||||
name: "custom_search",
|
||||
description: "Search the codebase",
|
||||
parameters: customParams,
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const result = getCustomToolDefs(mockPi);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("custom_search");
|
||||
expect(result[0].description).toBe("Search the codebase");
|
||||
expect(result[0].inputSchema).toBe(customParams);
|
||||
});
|
||||
|
||||
it("handles pi.getAllTools() returning empty array", () => {
|
||||
const mockPi = {
|
||||
getAllTools: vi.fn(() => []),
|
||||
};
|
||||
|
||||
const result = getCustomToolDefs(mockPi);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when pi.getAllTools() returns undefined", () => {
|
||||
const mockPi = {
|
||||
getAllTools: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
const result = getCustomToolDefs(mockPi);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when pi.getAllTools() returns null", () => {
|
||||
const mockPi = {
|
||||
getAllTools: vi.fn(() => null),
|
||||
};
|
||||
|
||||
const result = getCustomToolDefs(mockPi);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeMcpConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.tmpdir.mockReturnValue("/tmp");
|
||||
});
|
||||
|
||||
it("writes schema file to tmpdir with correct content (JSON array of tool defs)", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
writeMcpConfig(toolDefs);
|
||||
|
||||
// First writeFileSync call is the schema file
|
||||
const schemaCall = mocks.writeFileSync.mock.calls[0];
|
||||
expect(schemaCall[0]).toMatch(/pi-claude-mcp-schemas/);
|
||||
expect(JSON.parse(schemaCall[1])).toEqual(toolDefs);
|
||||
});
|
||||
|
||||
it("writes config file to tmpdir with mcpServers.custom-tools entry", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
writeMcpConfig(toolDefs);
|
||||
|
||||
// Second writeFileSync call is the config file
|
||||
const configCall = mocks.writeFileSync.mock.calls[1];
|
||||
const config = JSON.parse(configCall[1]);
|
||||
expect(config).toHaveProperty("mcpServers");
|
||||
expect(config.mcpServers).toHaveProperty("custom-tools");
|
||||
});
|
||||
|
||||
it("config uses 'command': 'node' format (not 'type': 'http')", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
writeMcpConfig(toolDefs);
|
||||
|
||||
const configCall = mocks.writeFileSync.mock.calls[1];
|
||||
const config = JSON.parse(configCall[1]);
|
||||
const server = config.mcpServers["custom-tools"];
|
||||
|
||||
expect(server.command).toBe("node");
|
||||
expect(server).not.toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("config args include path to mcp-schema-server.cjs and schema file path", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
writeMcpConfig(toolDefs);
|
||||
|
||||
const configCall = mocks.writeFileSync.mock.calls[1];
|
||||
const config = JSON.parse(configCall[1]);
|
||||
const server = config.mcpServers["custom-tools"];
|
||||
|
||||
expect(server.args).toHaveLength(2);
|
||||
// First arg should be the server .cjs path (normalize separators for Windows)
|
||||
expect(server.args[0].replace(/\\/g, "/")).toContain(
|
||||
"mcp-schema-server.cjs",
|
||||
);
|
||||
// Second arg should be the schema file path
|
||||
expect(server.args[1]).toMatch(/pi-claude-mcp-schemas/);
|
||||
});
|
||||
|
||||
it("returns the config file path", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = writeMcpConfig(toolDefs);
|
||||
|
||||
expect(result).toMatch(/pi-claude-mcp-config/);
|
||||
expect(result).toMatch(/\.json$/);
|
||||
});
|
||||
});
|
||||
619
packages/pi-claude-cli/tests/process-manager.test.ts
Normal file
619
packages/pi-claude-cli/tests/process-manager.test.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
// Mock cross-spawn before importing process-manager
|
||||
vi.mock("cross-spawn", () => ({
|
||||
default: vi.fn(() => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.stdin = { write: vi.fn(), end: vi.fn() };
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.killed = false;
|
||||
proc.kill = vi.fn(() => {
|
||||
proc.killed = true;
|
||||
});
|
||||
proc.pid = 12345;
|
||||
return proc;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child_process.execSync for validation tests
|
||||
vi.mock("node:child_process", () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import spawn from "cross-spawn";
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
spawnClaude,
|
||||
writeUserMessage,
|
||||
cleanupProcess,
|
||||
captureStderr,
|
||||
validateCliPresence,
|
||||
validateCliAuth,
|
||||
forceKillProcess,
|
||||
registerProcess,
|
||||
killAllProcesses,
|
||||
cleanupSystemPromptFile,
|
||||
} from "../src/process-manager";
|
||||
|
||||
describe("spawnClaude", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("spawns claude with all required CLI flags", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = (spawn as any).mock.calls[0];
|
||||
|
||||
expect(cmd).toBe("claude");
|
||||
expect(args).toContain("-p");
|
||||
expect(args).toContain("--input-format");
|
||||
expect(args).toContain("stream-json");
|
||||
expect(args).toContain("--output-format");
|
||||
expect(args).toContain("--verbose");
|
||||
expect(args).toContain("--include-partial-messages");
|
||||
expect(args).not.toContain("--no-session-persistence");
|
||||
expect(args).toContain("--model");
|
||||
expect(args).toContain("claude-sonnet-4-5-20250929");
|
||||
expect(args).toContain("--permission-prompt-tool");
|
||||
expect(args).toContain("stdio");
|
||||
});
|
||||
|
||||
it("passes stream-json for both input-format and output-format", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
const inputFormatIdx = args.indexOf("--input-format");
|
||||
expect(args[inputFormatIdx + 1]).toBe("stream-json");
|
||||
|
||||
const outputFormatIdx = args.indexOf("--output-format");
|
||||
expect(args[outputFormatIdx + 1]).toBe("stream-json");
|
||||
});
|
||||
|
||||
it("sets stdio to pipe for stdin, stdout, and stderr", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const options = (spawn as any).mock.calls[0][2];
|
||||
expect(options.stdio).toEqual(["pipe", "pipe", "pipe"]);
|
||||
});
|
||||
|
||||
it("passes cwd from options when provided", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
|
||||
cwd: "/custom/path",
|
||||
});
|
||||
const options = (spawn as any).mock.calls[0][2];
|
||||
expect(options.cwd).toBe("/custom/path");
|
||||
});
|
||||
|
||||
it("writes system prompt to temp file and passes path via --append-system-prompt", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--append-system-prompt");
|
||||
const idx = args.indexOf("--append-system-prompt");
|
||||
expect(args[idx + 1]).toContain("pi-claude-cli-sysprompt-");
|
||||
});
|
||||
|
||||
it("temp file contains the system prompt text", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
|
||||
const tmpFile = join(
|
||||
tmpdir(),
|
||||
`pi-claude-cli-sysprompt-${process.pid}.txt`,
|
||||
);
|
||||
expect(existsSync(tmpFile)).toBe(true);
|
||||
expect(readFileSync(tmpFile, "utf-8")).toBe("You are a helpful assistant.");
|
||||
});
|
||||
|
||||
it("does not include --append-system-prompt when no system prompt", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
expect(args).not.toContain("--append-system-prompt");
|
||||
});
|
||||
|
||||
it("returns the spawned ChildProcess", () => {
|
||||
const proc = spawnClaude("claude-sonnet-4-5-20250929");
|
||||
expect(proc).toBeDefined();
|
||||
expect(proc.pid).toBe(12345);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effort flag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("includes --effort and high in args when effort is high", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, { effort: "high" });
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--effort");
|
||||
const idx = args.indexOf("--effort");
|
||||
expect(args[idx + 1]).toBe("high");
|
||||
});
|
||||
|
||||
it("includes --effort and max in args when effort is max", () => {
|
||||
spawnClaude("claude-opus-4-6-20260301", undefined, { effort: "max" });
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--effort");
|
||||
const idx = args.indexOf("--effort");
|
||||
expect(args[idx + 1]).toBe("max");
|
||||
});
|
||||
|
||||
it("includes --effort and low in args when effort is low", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, { effort: "low" });
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--effort");
|
||||
const idx = args.indexOf("--effort");
|
||||
expect(args[idx + 1]).toBe("low");
|
||||
});
|
||||
|
||||
it("does NOT include --effort when effort is undefined", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, { cwd: "/some/path" });
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--effort");
|
||||
});
|
||||
|
||||
it("does NOT include --effort when options is undefined", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--effort");
|
||||
});
|
||||
|
||||
it("is backward compatible - existing calls without effort still work", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", "system prompt", {
|
||||
cwd: "/path",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--append-system-prompt");
|
||||
expect(args).not.toContain("--effort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeUserMessage", () => {
|
||||
it("writes correct NDJSON user message to stdin", () => {
|
||||
const mockStdin = { write: vi.fn(), end: vi.fn() };
|
||||
const proc = { stdin: mockStdin } as unknown as ChildProcess;
|
||||
|
||||
writeUserMessage(proc, "Hello Claude");
|
||||
|
||||
expect(mockStdin.write).toHaveBeenCalledTimes(1);
|
||||
const written = mockStdin.write.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(written.trim());
|
||||
expect(parsed.type).toBe("user");
|
||||
expect(parsed.message.role).toBe("user");
|
||||
expect(parsed.message.content).toBe("Hello Claude");
|
||||
});
|
||||
|
||||
it("appends newline to the JSON", () => {
|
||||
const mockStdin = { write: vi.fn(), end: vi.fn() };
|
||||
const proc = { stdin: mockStdin } as unknown as ChildProcess;
|
||||
|
||||
writeUserMessage(proc, "test");
|
||||
|
||||
const written = mockStdin.write.mock.calls[0][0] as string;
|
||||
expect(written.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT call stdin.end()", () => {
|
||||
const mockStdin = { write: vi.fn(), end: vi.fn() };
|
||||
const proc = { stdin: mockStdin } as unknown as ChildProcess;
|
||||
|
||||
writeUserMessage(proc, "test");
|
||||
|
||||
expect(mockStdin.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends string content in NDJSON when given string", () => {
|
||||
const mockStdin = { write: vi.fn(), end: vi.fn() };
|
||||
const proc = { stdin: mockStdin } as unknown as ChildProcess;
|
||||
|
||||
writeUserMessage(proc, "hello");
|
||||
|
||||
const written = mockStdin.write.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(written.trim());
|
||||
expect(typeof parsed.message.content).toBe("string");
|
||||
expect(parsed.message.content).toBe("hello");
|
||||
});
|
||||
|
||||
it("sends array content in NDJSON when given ContentBlock[]", () => {
|
||||
const mockStdin = { write: vi.fn(), end: vi.fn() };
|
||||
const proc = { stdin: mockStdin } as unknown as ChildProcess;
|
||||
|
||||
const blocks = [
|
||||
{ type: "text", text: "hello" },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "abc" },
|
||||
},
|
||||
];
|
||||
writeUserMessage(proc, blocks as any);
|
||||
|
||||
const written = mockStdin.write.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(written.trim());
|
||||
expect(Array.isArray(parsed.message.content)).toBe(true);
|
||||
expect(parsed.message.content).toEqual(blocks);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupProcess", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("kills the process with SIGKILL after 500ms grace period", () => {
|
||||
const mockProc: any = {
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
kill: vi.fn(() => {
|
||||
mockProc.killed = true;
|
||||
}),
|
||||
};
|
||||
|
||||
cleanupProcess(mockProc as ChildProcess);
|
||||
|
||||
// Not killed immediately
|
||||
expect(mockProc.kill).not.toHaveBeenCalled();
|
||||
|
||||
// Not killed at 400ms
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(mockProc.kill).not.toHaveBeenCalled();
|
||||
|
||||
// Killed after 500ms grace period
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(mockProc.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("does not kill if process is already killed", () => {
|
||||
const proc = {
|
||||
killed: true,
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
cleanupProcess(proc);
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureStderr", () => {
|
||||
it("returns a function that accumulates stderr data", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const stderr = new EventEmitter();
|
||||
const proc = { stderr } as unknown as ChildProcess;
|
||||
|
||||
const getStderr = captureStderr(proc);
|
||||
|
||||
stderr.emit("data", Buffer.from("error line 1\n"));
|
||||
stderr.emit("data", Buffer.from("error line 2\n"));
|
||||
|
||||
expect(getStderr()).toBe("error line 1\nerror line 2\n");
|
||||
});
|
||||
|
||||
it("returns empty string when no stderr data", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const stderr = new EventEmitter();
|
||||
const proc = { stderr } as unknown as ChildProcess;
|
||||
|
||||
const getStderr = captureStderr(proc);
|
||||
expect(getStderr()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCliPresence", () => {
|
||||
it("does not throw when claude --version succeeds", () => {
|
||||
(execSync as any).mockReturnValue(Buffer.from("1.0.0"));
|
||||
expect(() => validateCliPresence()).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws with install instructions when claude --version fails", () => {
|
||||
(execSync as any).mockImplementation(() => {
|
||||
throw new Error("command not found");
|
||||
});
|
||||
|
||||
expect(() => validateCliPresence()).toThrow();
|
||||
try {
|
||||
validateCliPresence();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("Claude Code CLI not found");
|
||||
expect(e.message).toContain("npm install");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCliAuth", () => {
|
||||
it("returns true when claude auth status succeeds", () => {
|
||||
(execSync as any).mockReturnValue(Buffer.from("Logged in"));
|
||||
expect(validateCliAuth()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false and warns when claude auth status fails", () => {
|
||||
(execSync as any).mockImplementation(() => {
|
||||
throw new Error("not authenticated");
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(validateCliAuth()).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not authenticated"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI flags", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("spawnClaude does NOT include --permission-mode or dontAsk in args", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--permission-mode");
|
||||
expect(args).not.toContain("dontAsk");
|
||||
});
|
||||
|
||||
it("spawnClaude includes --permission-prompt-tool followed by stdio in args", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--permission-prompt-tool");
|
||||
const idx = args.indexOf("--permission-prompt-tool");
|
||||
expect(args[idx + 1]).toBe("stdio");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp-config flag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("spawnClaude with mcpConfigPath includes --mcp-config followed by the path", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
|
||||
mcpConfigPath: "/tmp/mcp-config.json",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--mcp-config");
|
||||
const idx = args.indexOf("--mcp-config");
|
||||
expect(args[idx + 1]).toBe("/tmp/mcp-config.json");
|
||||
});
|
||||
|
||||
it("spawnClaude without mcpConfigPath does NOT include --mcp-config in args", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--mcp-config");
|
||||
});
|
||||
|
||||
it("spawnClaude NEVER includes --strict-mcp-config in args", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
|
||||
mcpConfigPath: "/tmp/mcp-config.json",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--strict-mcp-config");
|
||||
});
|
||||
|
||||
it("backward compatibility - existing calls with only effort/cwd still work", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", "system prompt", {
|
||||
cwd: "/path",
|
||||
effort: "high",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--append-system-prompt");
|
||||
expect(args).toContain("--effort");
|
||||
expect(args).not.toContain("--mcp-config");
|
||||
expect(args).toContain("--permission-prompt-tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forceKillProcess", () => {
|
||||
it("calls proc.kill('SIGKILL') on live process", () => {
|
||||
const proc = {
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
forceKillProcess(proc);
|
||||
|
||||
expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("no-ops when proc.killed is true", () => {
|
||||
const proc = {
|
||||
killed: true,
|
||||
exitCode: null,
|
||||
kill: vi.fn(),
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
forceKillProcess(proc);
|
||||
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when proc.exitCode is not null", () => {
|
||||
const proc = {
|
||||
killed: false,
|
||||
exitCode: 0,
|
||||
kill: vi.fn(),
|
||||
} as unknown as ChildProcess;
|
||||
|
||||
forceKillProcess(proc);
|
||||
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("process registry", () => {
|
||||
beforeEach(() => {
|
||||
// Clear registry between tests
|
||||
killAllProcesses();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registerProcess adds proc and killAllProcesses kills it", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.killed = false;
|
||||
proc.exitCode = null;
|
||||
proc.kill = vi.fn(() => {
|
||||
proc.killed = true;
|
||||
});
|
||||
|
||||
registerProcess(proc as unknown as ChildProcess);
|
||||
killAllProcesses();
|
||||
|
||||
expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("proc exit event removes from registry", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.killed = false;
|
||||
proc.exitCode = null;
|
||||
proc.kill = vi.fn(() => {
|
||||
proc.killed = true;
|
||||
});
|
||||
|
||||
registerProcess(proc as unknown as ChildProcess);
|
||||
|
||||
// Simulate natural exit
|
||||
proc.exitCode = 0;
|
||||
proc.emit("exit", 0, null);
|
||||
|
||||
// Clear mock to check killAllProcesses doesn't call kill again
|
||||
proc.kill.mockClear();
|
||||
proc.killed = false;
|
||||
proc.exitCode = null;
|
||||
|
||||
killAllProcesses();
|
||||
|
||||
// Should NOT have been killed since it was removed on exit
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("killAllProcesses clears set and handles already-dead processes", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc1 = new EventEmitter();
|
||||
proc1.killed = true; // already dead
|
||||
proc1.exitCode = null;
|
||||
proc1.kill = vi.fn();
|
||||
|
||||
const proc2 = new EventEmitter();
|
||||
proc2.killed = false;
|
||||
proc2.exitCode = 1; // already exited
|
||||
proc2.kill = vi.fn();
|
||||
|
||||
const proc3 = new EventEmitter();
|
||||
proc3.killed = false;
|
||||
proc3.exitCode = null; // alive
|
||||
proc3.kill = vi.fn(() => {
|
||||
proc3.killed = true;
|
||||
});
|
||||
|
||||
registerProcess(proc1 as unknown as ChildProcess);
|
||||
registerProcess(proc2 as unknown as ChildProcess);
|
||||
registerProcess(proc3 as unknown as ChildProcess);
|
||||
|
||||
killAllProcesses();
|
||||
|
||||
// Already dead -- forceKillProcess should no-op
|
||||
expect(proc1.kill).not.toHaveBeenCalled();
|
||||
expect(proc2.kill).not.toHaveBeenCalled();
|
||||
// Live process should be killed
|
||||
expect(proc3.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
|
||||
// Calling again should not kill anything (set was cleared)
|
||||
proc3.kill.mockClear();
|
||||
proc3.killed = false;
|
||||
proc3.exitCode = null;
|
||||
killAllProcesses();
|
||||
expect(proc3.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resume session flag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("includes --resume followed by session ID when resumeSessionId is provided", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
|
||||
resumeSessionId: "session-abc-123",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--resume");
|
||||
const idx = args.indexOf("--resume");
|
||||
expect(args[idx + 1]).toBe("session-abc-123");
|
||||
});
|
||||
|
||||
it("does NOT include --resume when resumeSessionId is undefined", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--resume");
|
||||
});
|
||||
|
||||
it("includes both --resume and --effort when both are provided", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
|
||||
resumeSessionId: "session-abc",
|
||||
effort: "high",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--resume");
|
||||
expect(args).toContain("--effort");
|
||||
});
|
||||
|
||||
it("includes both --resume and --mcp-config when both are provided", () => {
|
||||
spawnClaude("claude-sonnet-4-5-20250929", undefined, {
|
||||
resumeSessionId: "session-abc",
|
||||
mcpConfigPath: "/tmp/mcp.json",
|
||||
});
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).toContain("--resume");
|
||||
expect(args).toContain("--mcp-config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupSystemPromptFile", () => {
|
||||
const tmpFile = join(tmpdir(), `pi-claude-cli-sysprompt-${process.pid}.txt`);
|
||||
|
||||
it("deletes the temp file when it exists", () => {
|
||||
// Create the file by spawning with a system prompt
|
||||
spawnClaude("claude-sonnet-4-5-20250929", "test prompt");
|
||||
expect(existsSync(tmpFile)).toBe(true);
|
||||
|
||||
cleanupSystemPromptFile();
|
||||
expect(existsSync(tmpFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not throw when file does not exist", () => {
|
||||
// Ensure file doesn't exist
|
||||
cleanupSystemPromptFile();
|
||||
// Call again — should not throw
|
||||
expect(() => cleanupSystemPromptFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
1067
packages/pi-claude-cli/tests/prompt-builder.test.ts
Normal file
1067
packages/pi-claude-cli/tests/prompt-builder.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1902
packages/pi-claude-cli/tests/provider.test.ts
Normal file
1902
packages/pi-claude-cli/tests/provider.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
188
packages/pi-claude-cli/tests/stream-parser.test.ts
Normal file
188
packages/pi-claude-cli/tests/stream-parser.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { parseLine } from "../src/stream-parser";
|
||||
import type {
|
||||
ClaudeStreamEventMessage,
|
||||
ClaudeResultMessage,
|
||||
ClaudeSystemMessage,
|
||||
} from "../src/types";
|
||||
|
||||
describe("parseLine", () => {
|
||||
describe("valid JSON parsing", () => {
|
||||
it("parses a valid stream_event message", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "stream_event",
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: { usage: { input_tokens: 10 } },
|
||||
},
|
||||
});
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("stream_event");
|
||||
expect((result as ClaudeStreamEventMessage).event.type).toBe(
|
||||
"message_start",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses a valid result message", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Hello world",
|
||||
});
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("result");
|
||||
expect((result as ClaudeResultMessage).subtype).toBe("success");
|
||||
expect((result as ClaudeResultMessage).result).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("parses a valid system message", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "test-session",
|
||||
});
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("system");
|
||||
expect((result as ClaudeSystemMessage).subtype).toBe("init");
|
||||
});
|
||||
|
||||
it("parses a valid control_request message", () => {
|
||||
const line = JSON.stringify({
|
||||
type: "control_request",
|
||||
request_id: "req-001",
|
||||
request: {
|
||||
subtype: "can_use_tool",
|
||||
tool_name: "Read",
|
||||
input: { file_path: "/test" },
|
||||
},
|
||||
});
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("control_request");
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty and whitespace lines", () => {
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseLine("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only line", () => {
|
||||
expect(parseLine(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for tab-only line", () => {
|
||||
expect(parseLine("\t\t")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for newline-only line", () => {
|
||||
expect(parseLine("\n")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-JSON lines (debug noise)", () => {
|
||||
it("returns null for SandboxDebug output", () => {
|
||||
expect(parseLine("[SandboxDebug] loading config...")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for plain text", () => {
|
||||
expect(parseLine("Some debug message")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for lines starting with [", () => {
|
||||
expect(parseLine("[INFO] starting up")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for lines starting with #", () => {
|
||||
expect(parseLine("# comment")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed JSON", () => {
|
||||
it("returns null for truncated JSON without throwing", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(parseLine('{"type":"stream_event","event":')).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON syntax without throwing", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(parseLine("{not valid json}")).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null for JSON with trailing comma without throwing", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(parseLine('{"type":"test",}')).toBeNull();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-object JSON", () => {
|
||||
it("returns null for JSON array", () => {
|
||||
expect(parseLine("[1, 2, 3]")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for JSON string", () => {
|
||||
expect(parseLine('"hello"')).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for JSON number", () => {
|
||||
expect(parseLine("42")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for JSON null", () => {
|
||||
expect(parseLine("null")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for JSON boolean", () => {
|
||||
expect(parseLine("true")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
it("trims leading whitespace before parsing", () => {
|
||||
const line = ` ${JSON.stringify({ type: "system", subtype: "init" })}`;
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("system");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace before parsing", () => {
|
||||
const line = `${JSON.stringify({ type: "system", subtype: "init" })} `;
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("system");
|
||||
});
|
||||
|
||||
it("trims both leading and trailing whitespace", () => {
|
||||
const line = ` ${JSON.stringify({ type: "result", subtype: "success" })} `;
|
||||
const result = parseLine(line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe("result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resilience", () => {
|
||||
it("never throws regardless of input", () => {
|
||||
const inputs = [
|
||||
"",
|
||||
" ",
|
||||
"garbage",
|
||||
"{bad",
|
||||
"null",
|
||||
"undefined",
|
||||
"[1,2]",
|
||||
'{"valid": true}',
|
||||
"[SandboxDebug] test",
|
||||
'{"type":"stream_event","event":{"type":"message_start"}}',
|
||||
];
|
||||
for (const input of inputs) {
|
||||
expect(() => parseLine(input)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
141
packages/pi-claude-cli/tests/thinking-config.test.ts
Normal file
141
packages/pi-claude-cli/tests/thinking-config.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { mapThinkingEffort, isOpusModel } from "../src/thinking-config";
|
||||
import type { ThinkingBudgets } from "@mariozechner/pi-ai";
|
||||
|
||||
describe("isOpusModel", () => {
|
||||
it("returns true for claude-opus-4-6-20260301", () => {
|
||||
expect(isOpusModel("claude-opus-4-6-20260301")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for claude-sonnet-4-5-20250929", () => {
|
||||
expect(isOpusModel("claude-sonnet-4-5-20250929")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for future Opus models (forward-compatible)", () => {
|
||||
expect(isOpusModel("claude-opus-5-20270101")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-Opus model strings", () => {
|
||||
expect(isOpusModel("claude-haiku-3-5-20240307")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapThinkingEffort", () => {
|
||||
describe("undefined reasoning", () => {
|
||||
it("returns undefined when reasoning is undefined", () => {
|
||||
expect(
|
||||
mapThinkingEffort(undefined, "claude-sonnet-4-5", undefined),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined regardless of model", () => {
|
||||
expect(
|
||||
mapThinkingEffort(undefined, "claude-opus-4-6-20260301", undefined),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("standard (non-Opus) model mapping", () => {
|
||||
const model = "claude-sonnet-4-5";
|
||||
|
||||
it("maps minimal to low", () => {
|
||||
expect(mapThinkingEffort("minimal", model, undefined)).toBe("low");
|
||||
});
|
||||
|
||||
it("maps low to low", () => {
|
||||
expect(mapThinkingEffort("low", model, undefined)).toBe("low");
|
||||
});
|
||||
|
||||
it("maps medium to medium", () => {
|
||||
expect(mapThinkingEffort("medium", model, undefined)).toBe("medium");
|
||||
});
|
||||
|
||||
it("maps high to high", () => {
|
||||
expect(mapThinkingEffort("high", model, undefined)).toBe("high");
|
||||
});
|
||||
|
||||
it("maps xhigh to high (downgrade for non-Opus)", () => {
|
||||
expect(mapThinkingEffort("xhigh", model, undefined)).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Opus model mapping (elevated)", () => {
|
||||
const model = "claude-opus-4-6-20260301";
|
||||
|
||||
it("maps minimal to low", () => {
|
||||
expect(mapThinkingEffort("minimal", model, undefined)).toBe("low");
|
||||
});
|
||||
|
||||
it("maps low to low", () => {
|
||||
expect(mapThinkingEffort("low", model, undefined)).toBe("low");
|
||||
});
|
||||
|
||||
it("maps medium to high (shifted up)", () => {
|
||||
expect(mapThinkingEffort("medium", model, undefined)).toBe("high");
|
||||
});
|
||||
|
||||
it("maps high to max (shifted up)", () => {
|
||||
expect(mapThinkingEffort("high", model, undefined)).toBe("max");
|
||||
});
|
||||
|
||||
it("maps xhigh to max", () => {
|
||||
expect(mapThinkingEffort("xhigh", model, undefined)).toBe("max");
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkingBudgets warning", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("logs console.warn when thinkingBudgets is provided with entries", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const budgets: ThinkingBudgets = { high: 50000 };
|
||||
|
||||
mapThinkingEffort("high", "claude-sonnet-4-5", budgets);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("thinkingBudgets are not supported"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn when thinkingBudgets is undefined", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
mapThinkingEffort("high", "claude-sonnet-4-5", undefined);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not warn when thinkingBudgets is empty object", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
mapThinkingEffort("high", "claude-sonnet-4-5", {} as ThinkingBudgets);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still returns correct effort level when budgets trigger warning", () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const budgets: ThinkingBudgets = { high: 50000 };
|
||||
|
||||
const result = mapThinkingEffort(
|
||||
"high",
|
||||
"claude-opus-4-6-20260301",
|
||||
budgets,
|
||||
);
|
||||
expect(result).toBe("max");
|
||||
});
|
||||
});
|
||||
|
||||
describe("no modelId defaults to non-Opus behavior", () => {
|
||||
it("uses standard mapping when modelId is undefined", () => {
|
||||
expect(mapThinkingEffort("medium", undefined, undefined)).toBe("medium");
|
||||
});
|
||||
|
||||
it("does not return max for xhigh when modelId is undefined", () => {
|
||||
expect(mapThinkingEffort("xhigh", undefined, undefined)).toBe("high");
|
||||
});
|
||||
});
|
||||
});
|
||||
252
packages/pi-claude-cli/tests/tool-mapping.test.ts
Normal file
252
packages/pi-claude-cli/tests/tool-mapping.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
TOOL_MAPPINGS,
|
||||
CUSTOM_TOOLS_MCP_PREFIX,
|
||||
mapClaudeToolNameToPi,
|
||||
mapPiToolNameToClaude,
|
||||
translateClaudeArgsToPi,
|
||||
translatePiArgsToClaude,
|
||||
isCustomToolName,
|
||||
} from "../src/tool-mapping";
|
||||
|
||||
describe("tool-mapping", () => {
|
||||
describe("TOOL_MAPPINGS", () => {
|
||||
it("exports 6 tool mappings", () => {
|
||||
expect(TOOL_MAPPINGS).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapClaudeToolNameToPi", () => {
|
||||
it("maps Read to read", () => {
|
||||
expect(mapClaudeToolNameToPi("Read")).toBe("read");
|
||||
});
|
||||
|
||||
it("maps Write to write", () => {
|
||||
expect(mapClaudeToolNameToPi("Write")).toBe("write");
|
||||
});
|
||||
|
||||
it("maps Edit to edit", () => {
|
||||
expect(mapClaudeToolNameToPi("Edit")).toBe("edit");
|
||||
});
|
||||
|
||||
it("maps Bash to bash", () => {
|
||||
expect(mapClaudeToolNameToPi("Bash")).toBe("bash");
|
||||
});
|
||||
|
||||
it("maps Grep to grep", () => {
|
||||
expect(mapClaudeToolNameToPi("Grep")).toBe("grep");
|
||||
});
|
||||
|
||||
it("maps Glob to find", () => {
|
||||
expect(mapClaudeToolNameToPi("Glob")).toBe("find");
|
||||
});
|
||||
|
||||
it("passes through unknown tool names unchanged", () => {
|
||||
expect(mapClaudeToolNameToPi("UnknownTool")).toBe("UnknownTool");
|
||||
});
|
||||
|
||||
it("is case-insensitive for Claude tool names", () => {
|
||||
expect(mapClaudeToolNameToPi("read")).toBe("read");
|
||||
expect(mapClaudeToolNameToPi("READ")).toBe("read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPiToolNameToClaude", () => {
|
||||
it("maps read to Read", () => {
|
||||
expect(mapPiToolNameToClaude("read")).toBe("Read");
|
||||
});
|
||||
|
||||
it("maps write to Write", () => {
|
||||
expect(mapPiToolNameToClaude("write")).toBe("Write");
|
||||
});
|
||||
|
||||
it("maps edit to Edit", () => {
|
||||
expect(mapPiToolNameToClaude("edit")).toBe("Edit");
|
||||
});
|
||||
|
||||
it("maps bash to Bash", () => {
|
||||
expect(mapPiToolNameToClaude("bash")).toBe("Bash");
|
||||
});
|
||||
|
||||
it("maps grep to Grep", () => {
|
||||
expect(mapPiToolNameToClaude("grep")).toBe("Grep");
|
||||
});
|
||||
|
||||
it("maps find to Glob", () => {
|
||||
expect(mapPiToolNameToClaude("find")).toBe("Glob");
|
||||
});
|
||||
|
||||
it("maps glob to Glob (asymmetry: both find and glob map to Glob)", () => {
|
||||
expect(mapPiToolNameToClaude("glob")).toBe("Glob");
|
||||
});
|
||||
|
||||
it("passes through unknown tool names unchanged", () => {
|
||||
expect(mapPiToolNameToClaude("unknownTool")).toBe("unknownTool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateClaudeArgsToPi", () => {
|
||||
it("renames file_path to path for Read", () => {
|
||||
const result = translateClaudeArgsToPi("Read", {
|
||||
file_path: "/foo",
|
||||
offset: 10,
|
||||
});
|
||||
expect(result).toEqual({ path: "/foo", offset: 10 });
|
||||
});
|
||||
|
||||
it("renames file_path to path for Write", () => {
|
||||
const result = translateClaudeArgsToPi("Write", {
|
||||
file_path: "/bar",
|
||||
content: "hello",
|
||||
});
|
||||
expect(result).toEqual({ path: "/bar", content: "hello" });
|
||||
});
|
||||
|
||||
it("renames file_path, old_string, new_string for Edit", () => {
|
||||
const result = translateClaudeArgsToPi("Edit", {
|
||||
file_path: "/f",
|
||||
old_string: "a",
|
||||
new_string: "b",
|
||||
});
|
||||
expect(result).toEqual({ path: "/f", oldText: "a", newText: "b" });
|
||||
});
|
||||
|
||||
it("passes through Bash args unchanged (no renames)", () => {
|
||||
const result = translateClaudeArgsToPi("Bash", { command: "ls" });
|
||||
expect(result).toEqual({ command: "ls" });
|
||||
});
|
||||
|
||||
it("renames head_limit to limit for Grep", () => {
|
||||
const result = translateClaudeArgsToPi("Grep", {
|
||||
pattern: "x",
|
||||
head_limit: 5,
|
||||
});
|
||||
expect(result).toEqual({ pattern: "x", limit: 5 });
|
||||
});
|
||||
|
||||
it("passes through Glob args unchanged (no renames)", () => {
|
||||
const result = translateClaudeArgsToPi("Glob", { pattern: "*.ts" });
|
||||
expect(result).toEqual({ pattern: "*.ts" });
|
||||
});
|
||||
|
||||
it("passes through args for unknown tools unchanged", () => {
|
||||
const result = translateClaudeArgsToPi("UnknownTool", {
|
||||
foo: 1,
|
||||
bar: "baz",
|
||||
});
|
||||
expect(result).toEqual({ foo: 1, bar: "baz" });
|
||||
});
|
||||
|
||||
it("preserves unknown args alongside renamed args", () => {
|
||||
const result = translateClaudeArgsToPi("Read", {
|
||||
file_path: "/foo",
|
||||
offset: 10,
|
||||
limit: 50,
|
||||
extra_arg: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
path: "/foo",
|
||||
offset: 10,
|
||||
limit: 50,
|
||||
extra_arg: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("translatePiArgsToClaude", () => {
|
||||
it("renames path to file_path for read", () => {
|
||||
const result = translatePiArgsToClaude("read", { path: "/foo" });
|
||||
expect(result).toEqual({ file_path: "/foo" });
|
||||
});
|
||||
|
||||
it("renames path, oldText, newText for edit", () => {
|
||||
const result = translatePiArgsToClaude("edit", {
|
||||
path: "/f",
|
||||
oldText: "a",
|
||||
newText: "b",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
file_path: "/f",
|
||||
old_string: "a",
|
||||
new_string: "b",
|
||||
});
|
||||
});
|
||||
|
||||
it("renames limit to head_limit for grep", () => {
|
||||
const result = translatePiArgsToClaude("grep", {
|
||||
pattern: "x",
|
||||
limit: 5,
|
||||
});
|
||||
expect(result).toEqual({ pattern: "x", head_limit: 5 });
|
||||
});
|
||||
|
||||
it("passes through unknown args alongside renamed args", () => {
|
||||
const result = translatePiArgsToClaude("read", {
|
||||
path: "/foo",
|
||||
offset: 10,
|
||||
extra: "val",
|
||||
});
|
||||
expect(result).toEqual({ file_path: "/foo", offset: 10, extra: "val" });
|
||||
});
|
||||
|
||||
it("passes through args for unknown tools unchanged", () => {
|
||||
const result = translatePiArgsToClaude("unknownTool", { foo: 1 });
|
||||
expect(result).toEqual({ foo: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP prefix stripping", () => {
|
||||
it("strips mcp__custom-tools__ prefix from myTool", () => {
|
||||
expect(mapClaudeToolNameToPi("mcp__custom-tools__myTool")).toBe("myTool");
|
||||
});
|
||||
|
||||
it("strips mcp__custom-tools__ prefix from deploy", () => {
|
||||
expect(mapClaudeToolNameToPi("mcp__custom-tools__deploy")).toBe("deploy");
|
||||
});
|
||||
|
||||
it("handles empty name after prefix", () => {
|
||||
expect(mapClaudeToolNameToPi("mcp__custom-tools__")).toBe("");
|
||||
});
|
||||
|
||||
it("does NOT strip other MCP server prefixes", () => {
|
||||
expect(mapClaudeToolNameToPi("mcp__other-server__foo")).toBe(
|
||||
"mcp__other-server__foo",
|
||||
);
|
||||
});
|
||||
|
||||
it("built-in mappings still work alongside MCP prefix stripping", () => {
|
||||
expect(mapClaudeToolNameToPi("Read")).toBe("read");
|
||||
expect(mapClaudeToolNameToPi("Glob")).toBe("find");
|
||||
});
|
||||
|
||||
it("CUSTOM_TOOLS_MCP_PREFIX is the correct string", () => {
|
||||
expect(CUSTOM_TOOLS_MCP_PREFIX).toBe("mcp__custom-tools__");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCustomToolName", () => {
|
||||
it("returns true for custom tool names", () => {
|
||||
expect(isCustomToolName("myTool")).toBe(true);
|
||||
expect(isCustomToolName("deploy")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for all 6 built-in tool names", () => {
|
||||
expect(isCustomToolName("read")).toBe(false);
|
||||
expect(isCustomToolName("write")).toBe(false);
|
||||
expect(isCustomToolName("edit")).toBe(false);
|
||||
expect(isCustomToolName("bash")).toBe(false);
|
||||
expect(isCustomToolName("grep")).toBe(false);
|
||||
expect(isCustomToolName("find")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateClaudeArgsToPi with MCP prefix", () => {
|
||||
it("MCP-prefixed custom tool args pass through unchanged", () => {
|
||||
const result = translateClaudeArgsToPi("mcp__custom-tools__myTool", {
|
||||
foo: 1,
|
||||
bar: "baz",
|
||||
});
|
||||
expect(result).toEqual({ foo: 1, bar: "baz" });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user