chore: add pi-claude-cli + plugin-sdk __tests__/ dirs (test consolidation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 19:38:50 -07:00
parent 6685d832fa
commit 60e5899686
10 changed files with 6144 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
import { describe, it, expect, vi } from "vitest";
import { PassThrough } from "node:stream";
import type { ClaudeControlRequest } from "../types";
import {
handleControlRequest,
TOOL_EXECUTION_DENIED_MESSAGE,
MCP_PREFIX,
} from "../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();
});
});
});

File diff suppressed because it is too large Load Diff

View 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 "../mcp-config";
import type { McpToolDef } from "../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$/);
});
});

View 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 "../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();
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, vi } from "vitest";
import { parseLine } from "../stream-parser";
import type {
ClaudeStreamEventMessage,
ClaudeResultMessage,
ClaudeSystemMessage,
} from "../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();
}
});
});
});

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { mapThinkingEffort, isOpusModel } from "../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");
});
});
});

View File

@@ -0,0 +1,252 @@
import { describe, it, expect } from "vitest";
import {
TOOL_MAPPINGS,
CUSTOM_TOOLS_MCP_PREFIX,
mapClaudeToolNameToPi,
mapPiToolNameToClaude,
translateClaudeArgsToPi,
translatePiArgsToClaude,
isCustomToolName,
} from "../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" });
});
});
});