feat(FN-2978): add droid-cli package with provider registration and model d
This merge introduces a new `packages/droid-cli` package that bridges AI agent execution as a subprocess, including process management, event streaming, tool mapping, MCP configuration, and thinking-mode support. It also adds the `DroidCliProviderCard` dashboard UI component with onboarding flows an Fusion-Task-Id: FN-2978
This commit is contained in:
164
packages/droid-cli/src/__tests__/control-handler.test.ts
Normal file
164
packages/droid-cli/src/__tests__/control-handler.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ClaudeControlRequest } from "../types";
|
||||
import {
|
||||
handleControlRequest,
|
||||
TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
MCP_PREFIX,
|
||||
} from "../control-handler";
|
||||
|
||||
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", () => {
|
||||
const msg = makeControlRequest("mcp__custom-tools__weather");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.response.response.response.behavior).toBe("deny");
|
||||
expect(result.response.response.response.message).toBe(
|
||||
TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies mcp__custom-tools__deploy", () => {
|
||||
const msg = makeControlRequest("mcp__custom-tools__deploy");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.response.response.response.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("allows user MCP tools and other tools", () => {
|
||||
it("allows user MCP tool mcp__database__query", () => {
|
||||
const msg = makeControlRequest("mcp__database__query");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows built-in tool Read", () => {
|
||||
const msg = makeControlRequest("Read");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows internal tools like ToolSearch", () => {
|
||||
const msg = makeControlRequest("ToolSearch");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("allows unknown tools", () => {
|
||||
const msg = makeControlRequest("SomeUnknownTool");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.response.response.response.behavior).toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("response format", () => {
|
||||
it("includes matching request_id", () => {
|
||||
const msg = makeControlRequest("Read", "custom-req-id-42");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.response.request_id).toBe("custom-req-id-42");
|
||||
});
|
||||
|
||||
it("returns a JSON-serializable response object", () => {
|
||||
const msg = makeControlRequest("Read");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
const serialized = JSON.stringify(result.response);
|
||||
|
||||
expect(() => JSON.parse(serialized)).not.toThrow();
|
||||
});
|
||||
|
||||
it("deny response includes message field", () => {
|
||||
const msg = makeControlRequest("mcp__custom-tools__foo");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.response.response.response.message).toBe(
|
||||
TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("allow response does not include a message field", () => {
|
||||
const msg = makeControlRequest("mcp__database__query");
|
||||
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.response.response.response.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("malformed input", () => {
|
||||
it("returns denied decision object for missing request_id", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const msg = {
|
||||
type: "control_request",
|
||||
} as unknown as ClaudeControlRequest;
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.response.response.response.behavior).toBe("deny");
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns denied decision object for missing request object", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const msg = {
|
||||
type: "control_request",
|
||||
request_id: "req-001",
|
||||
} as unknown as ClaudeControlRequest;
|
||||
const result = handleControlRequest(msg);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.response.response.response.behavior).toBe("deny");
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
1318
packages/droid-cli/src/__tests__/event-bridge.test.ts
Normal file
1318
packages/droid-cli/src/__tests__/event-bridge.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
310
packages/droid-cli/src/__tests__/mcp-config.test.ts
Normal file
310
packages/droid-cli/src/__tests__/mcp-config.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
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, toolsFromContext } 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("toolsFromContext", () => {
|
||||
it("keeps ls as a custom tool (not filtered as built-in)", () => {
|
||||
const defs = toolsFromContext([
|
||||
{ name: "read", description: "builtin", parameters: { type: "object" } },
|
||||
{ name: "ls", description: "list files", parameters: { type: "object" } },
|
||||
{
|
||||
name: "fn_task_list",
|
||||
description: "list tasks",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(defs.map((d) => d.name)).toEqual(["ls", "fn_task_list"]);
|
||||
});
|
||||
});
|
||||
|
||||
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(/droid-cli-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(/droid-cli-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(/droid-cli-mcp-config/);
|
||||
expect(result).toMatch(/\.json$/);
|
||||
});
|
||||
|
||||
it("includes cacheKey in filenames when provided so distinct tool sets do not collide", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
const pathA = writeMcpConfig(toolDefs, "aaaaaaaaaaaa");
|
||||
const pathB = writeMcpConfig(toolDefs, "bbbbbbbbbbbb");
|
||||
|
||||
expect(pathA).toContain("aaaaaaaaaaaa");
|
||||
expect(pathB).toContain("bbbbbbbbbbbb");
|
||||
expect(pathA).not.toBe(pathB);
|
||||
|
||||
const schemaPathA = mocks.writeFileSync.mock.calls[0][0];
|
||||
const schemaPathB = mocks.writeFileSync.mock.calls[2][0];
|
||||
expect(schemaPathA).toContain("aaaaaaaaaaaa");
|
||||
expect(schemaPathB).toContain("bbbbbbbbbbbb");
|
||||
});
|
||||
});
|
||||
818
packages/droid-cli/src/__tests__/process-manager.test.ts
Normal file
818
packages/droid-cli/src/__tests__/process-manager.test.ts
Normal file
@@ -0,0 +1,818 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
// Mock child_process.spawn before importing process-manager
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: 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;
|
||||
}),
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
tmpdir: vi.fn(() => "/mock-tmp"),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
writeFileSync: mocks.writeFileSync,
|
||||
unlinkSync: mocks.unlinkSync,
|
||||
existsSync: mocks.existsSync,
|
||||
readFileSync: mocks.readFileSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
tmpdir: mocks.tmpdir,
|
||||
}));
|
||||
|
||||
import { spawn, execSync } from "node:child_process";
|
||||
import {
|
||||
spawnDroid,
|
||||
buildDroidSpawnArgs,
|
||||
writeUserMessage,
|
||||
cleanupProcess,
|
||||
captureStderr,
|
||||
validateCliPresence,
|
||||
validateCliAuth,
|
||||
validateCliPresenceAsync,
|
||||
validateCliAuthAsync,
|
||||
forceKillProcess,
|
||||
registerProcess,
|
||||
killAllProcesses,
|
||||
cleanupSystemPromptFile,
|
||||
discoverDroidModels,
|
||||
} from "../process-manager";
|
||||
|
||||
describe("buildDroidSpawnArgs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.writeFileSync.mockReset();
|
||||
mocks.tmpdir.mockReset();
|
||||
mocks.tmpdir.mockReturnValue("/mock-tmp");
|
||||
});
|
||||
|
||||
it("builds args including model and optional session/mcp flags", () => {
|
||||
const args = buildDroidSpawnArgs("claude-sonnet-4-6", undefined, {
|
||||
resumeSessionId: "sess-1",
|
||||
effort: "high",
|
||||
mcpConfigPath: "/tmp/mcp.json",
|
||||
});
|
||||
|
||||
expect(args).toContain("--model");
|
||||
expect(args).toContain("claude-sonnet-4-6");
|
||||
expect(args).toContain("--resume");
|
||||
expect(args).toContain("sess-1");
|
||||
expect(args).toContain("--effort");
|
||||
expect(args).toContain("high");
|
||||
expect(args).toContain("--mcp-config");
|
||||
expect(args).toContain("/tmp/mcp.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnDroid", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.writeFileSync.mockReset();
|
||||
mocks.existsSync.mockReset();
|
||||
mocks.readFileSync.mockReset();
|
||||
mocks.tmpdir.mockReset();
|
||||
mocks.tmpdir.mockReturnValue("/mock-tmp");
|
||||
});
|
||||
|
||||
it("spawns claude with all required CLI flags", () => {
|
||||
spawnDroid("claude-sonnet-4-5-20250929");
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = (spawn as any).mock.calls[0];
|
||||
|
||||
expect(cmd).toBe("droid");
|
||||
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).not.toContain("--permission-prompt-tool");
|
||||
expect(args).not.toContain("stdio");
|
||||
});
|
||||
|
||||
it("passes stream-json for both input-format and output-format", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
const expectedTmpFile = `/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`;
|
||||
|
||||
expect(mocks.writeFileSync).toHaveBeenCalledWith(
|
||||
expectedTmpFile,
|
||||
"You are a helpful assistant.",
|
||||
"utf-8",
|
||||
);
|
||||
expect(args).toContain("--append-system-prompt");
|
||||
const idx = args.indexOf("--append-system-prompt");
|
||||
expect(args[idx + 1]).toContain("droid-cli-sysprompt-");
|
||||
expect(args[idx + 1]).toBe(expectedTmpFile);
|
||||
});
|
||||
|
||||
it("temp file contains the system prompt text", () => {
|
||||
spawnDroid("claude-sonnet-4-5-20250929", "You are a helpful assistant.");
|
||||
|
||||
expect(mocks.writeFileSync).toHaveBeenCalledWith(
|
||||
`/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`,
|
||||
"You are a helpful assistant.",
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include --append-system-prompt when no system prompt", () => {
|
||||
spawnDroid("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 = spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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("calls stdin.end() after writing user message", () => {
|
||||
const mockStdin = { write: vi.fn(), end: vi.fn() };
|
||||
const proc = { stdin: mockStdin } as unknown as ChildProcess;
|
||||
|
||||
writeUserMessage(proc, "test");
|
||||
|
||||
expect(mockStdin.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 droid --version succeeds", () => {
|
||||
(execSync as any).mockReturnValue(Buffer.from("1.0.0"));
|
||||
expect(() => validateCliPresence()).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws with install instructions when droid --version fails", () => {
|
||||
(execSync as any).mockImplementation(() => {
|
||||
throw new Error("command not found");
|
||||
});
|
||||
|
||||
expect(() => validateCliPresence()).toThrow();
|
||||
try {
|
||||
validateCliPresence();
|
||||
} catch (e: any) {
|
||||
expect(e.message).toContain("Droid CLI not found");
|
||||
expect(e.message).toContain("Install Droid CLI");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCliAuth", () => {
|
||||
it("returns true when droid auth status succeeds", () => {
|
||||
(execSync as any).mockReturnValue(Buffer.from("Logged in"));
|
||||
expect(validateCliAuth()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false and warns when droid 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("validateCliPresenceAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves ok=true when droid --version exits 0", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => proc.emit("exit", 0));
|
||||
return proc;
|
||||
});
|
||||
|
||||
const result = await validateCliPresenceAsync();
|
||||
expect(result).toEqual({ ok: true });
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
expect(args).toEqual(["--version"]);
|
||||
});
|
||||
|
||||
it("resolves ok=false with install message when spawn errors", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => proc.emit("error", new Error("ENOENT")));
|
||||
return proc;
|
||||
});
|
||||
|
||||
const result = await validateCliPresenceAsync();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain("Droid CLI not found");
|
||||
expect(result.error.message).toContain("Install Droid CLI");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves ok=false when droid --version exits non-zero", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => proc.emit("exit", 1));
|
||||
return proc;
|
||||
});
|
||||
|
||||
const result = await validateCliPresenceAsync();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCliAuthAsync", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves true when droid auth status exits 0", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => proc.emit("exit", 0));
|
||||
return proc;
|
||||
});
|
||||
|
||||
expect(await validateCliAuthAsync()).toBe(true);
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
expect(args).toEqual(["auth", "status"]);
|
||||
});
|
||||
|
||||
it("resolves false and warns when droid auth status fails", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
setImmediate(() => proc.emit("exit", 1));
|
||||
return proc;
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(await validateCliAuthAsync()).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not authenticated"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI flags", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("spawnDroid does NOT include --permission-mode or dontAsk in args", () => {
|
||||
spawnDroid("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("spawnDroid does NOT include --permission-prompt-tool in args", () => {
|
||||
spawnDroid("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--permission-prompt-tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcp-config flag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("spawnDroid with mcpConfigPath includes --mcp-config followed by the path", () => {
|
||||
spawnDroid("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("spawnDroid without mcpConfigPath does NOT include --mcp-config in args", () => {
|
||||
spawnDroid("claude-sonnet-4-5-20250929");
|
||||
const args = (spawn as any).mock.calls[0][1] as string[];
|
||||
|
||||
expect(args).not.toContain("--mcp-config");
|
||||
});
|
||||
|
||||
it("spawnDroid NEVER includes --strict-mcp-config in args", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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).not.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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
spawnDroid("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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.unlinkSync.mockReset();
|
||||
mocks.tmpdir.mockReset();
|
||||
mocks.tmpdir.mockReturnValue("/mock-tmp");
|
||||
});
|
||||
|
||||
it("deletes the temp file when it exists", () => {
|
||||
cleanupSystemPromptFile();
|
||||
|
||||
expect(mocks.unlinkSync).toHaveBeenCalledWith(
|
||||
`/mock-tmp/droid-cli-sysprompt-${process.pid}.txt`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when file does not exist", () => {
|
||||
mocks.unlinkSync.mockImplementation(() => {
|
||||
throw new Error("ENOENT");
|
||||
});
|
||||
|
||||
expect(() => cleanupSystemPromptFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverDroidModels", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parses model ids from JSON output", async () => {
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
setTimeout(() => {
|
||||
proc.stdout.emit("data", Buffer.from('[{"id":"droid-pro"},{"name":"droid-max"}]'));
|
||||
proc.emit("exit", 0);
|
||||
}, 0);
|
||||
return proc;
|
||||
});
|
||||
|
||||
await expect(discoverDroidModels()).resolves.toEqual(["droid-pro", "droid-max"]);
|
||||
});
|
||||
|
||||
it("falls back across attempts and parses newline output", async () => {
|
||||
(spawn as any)
|
||||
.mockImplementationOnce(() => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
setTimeout(() => proc.emit("exit", 1), 0);
|
||||
return proc;
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
setTimeout(() => proc.emit("exit", 1), 0);
|
||||
return proc;
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
const EventEmitter = require("node:events");
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
setTimeout(() => {
|
||||
proc.stdout.emit("data", Buffer.from("droid-lite\ndroid-lite\ndroid-pro\n"));
|
||||
proc.emit("exit", 0);
|
||||
}, 0);
|
||||
return proc;
|
||||
});
|
||||
|
||||
await expect(discoverDroidModels()).resolves.toEqual(["droid-lite", "droid-pro"]);
|
||||
});
|
||||
});
|
||||
1170
packages/droid-cli/src/__tests__/prompt-builder.test.ts
Normal file
1170
packages/droid-cli/src/__tests__/prompt-builder.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
65
packages/droid-cli/src/__tests__/provider.test.ts
Normal file
65
packages/droid-cli/src/__tests__/provider.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const registerProvider = vi.fn();
|
||||
const on = vi.fn();
|
||||
const getAllTools = vi.fn(() => [{ name: "read" }, { name: "bash" }, { name: "custom_tool", description: "c", parameters: {} }]);
|
||||
const setActiveTools = vi.fn();
|
||||
const streamViaCli = vi.fn(() => ({ mocked: true }));
|
||||
const writeMcpConfig = vi.fn(() => "/tmp/droid-mcp.json");
|
||||
const toolsFromContext = vi.fn(() => [{ name: "custom_tool", description: "c", inputSchema: {} }]);
|
||||
|
||||
vi.mock("../../src/provider.js", () => ({ streamViaCli }));
|
||||
vi.mock("../../src/mcp-config.js", () => ({
|
||||
getCustomToolDefs: vi.fn(() => [{ name: "custom_tool", description: "c", inputSchema: {} }]),
|
||||
toolsFromContext,
|
||||
writeMcpConfig,
|
||||
}));
|
||||
vi.mock("../../src/process-manager.js", () => ({
|
||||
validateCliPresenceAsync: vi.fn(async () => ({ ok: true })),
|
||||
validateCliAuthAsync: vi.fn(async () => true),
|
||||
killAllProcesses: vi.fn(),
|
||||
discoverDroidModels: vi.fn(async () => ["droid-pro", "droid-max", "droid-pro"]),
|
||||
}));
|
||||
|
||||
describe("droid-cli extension", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers provider id droid-cli with deduped discovered models", async () => {
|
||||
const mod = await import("../../index.js");
|
||||
mod.default({ registerProvider, on, getAllTools, setActiveTools } as never);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(1);
|
||||
const [providerId, config] = registerProvider.mock.calls[0];
|
||||
expect(providerId).toBe("droid-cli");
|
||||
expect(config.models.map((m: { id: string }) => m.id)).toEqual(["droid-pro", "droid-max"]);
|
||||
});
|
||||
|
||||
it("wires MCP config into streamSimple and reuses cache for same tool set", async () => {
|
||||
const mod = await import("../../index.js");
|
||||
mod.default({ registerProvider, on, getAllTools, setActiveTools } as never);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const config = registerProvider.mock.calls[0][1];
|
||||
const model = { provider: "droid-cli", id: "droid-pro" };
|
||||
const context = { tools: [{ name: "custom_tool", description: "c", parameters: {} }] };
|
||||
config.streamSimple(model, context, { sessionId: "s1" });
|
||||
config.streamSimple(model, context, { sessionId: "s2" });
|
||||
|
||||
expect(streamViaCli).toHaveBeenCalledTimes(2);
|
||||
const firstCall = (streamViaCli as unknown as { mock: { calls: Array<unknown[]> } }).mock.calls[0] ?? [];
|
||||
const firstCallOptions = firstCall[2] as { mcpConfigPath?: string } | undefined;
|
||||
expect(firstCallOptions?.mcpConfigPath).toBe("/tmp/droid-mcp.json");
|
||||
expect(writeMcpConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("activates all tools on session_start", async () => {
|
||||
const mod = await import("../../index.js");
|
||||
mod.default({ registerProvider, on, getAllTools, setActiveTools } as never);
|
||||
const sessionStart = on.mock.calls.find((c) => c[0] === "session_start")?.[1];
|
||||
await sessionStart();
|
||||
expect(setActiveTools).toHaveBeenCalledWith(["read", "bash", "custom_tool"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const TEMP_HOME_PREFIX = "fn-test-home-";
|
||||
|
||||
describe("test isolation setup", () => {
|
||||
it("overrides process.env.HOME to a temp directory", () => {
|
||||
const home = process.env.HOME;
|
||||
const userProfile = process.env.USERPROFILE;
|
||||
|
||||
expect(home).toBeDefined();
|
||||
expect(home).toContain(tmpdir());
|
||||
expect(home).toContain(TEMP_HOME_PREFIX);
|
||||
expect(userProfile).toBe(home);
|
||||
});
|
||||
|
||||
it("resolves homedir() to the temp HOME", () => {
|
||||
const home = homedir();
|
||||
|
||||
expect(home).toContain(tmpdir());
|
||||
expect(home).toContain(TEMP_HOME_PREFIX);
|
||||
});
|
||||
|
||||
it("resolves ~/.pi/agent/AGENTS.md under the temp HOME", () => {
|
||||
const agentsPath = join(homedir(), ".pi", "agent", "AGENTS.md");
|
||||
|
||||
expect(agentsPath).toContain(tmpdir());
|
||||
expect(agentsPath).toContain(TEMP_HOME_PREFIX);
|
||||
expect(agentsPath).toMatch(/fn-test-home-.*[\\/]\.pi[\\/]agent[\\/]AGENTS\.md$/);
|
||||
});
|
||||
});
|
||||
14
packages/droid-cli/src/__tests__/setup-test-isolation.ts
Normal file
14
packages/droid-cli/src/__tests__/setup-test-isolation.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
if (process.platform === "win32") {
|
||||
const match = tempHome.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (match) {
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
}
|
||||
188
packages/droid-cli/src/__tests__/stream-parser.test.ts
Normal file
188
packages/droid-cli/src/__tests__/stream-parser.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
141
packages/droid-cli/src/__tests__/thinking-config.test.ts
Normal file
141
packages/droid-cli/src/__tests__/thinking-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
253
packages/droid-cli/src/__tests__/tool-mapping.test.ts
Normal file
253
packages/droid-cli/src/__tests__/tool-mapping.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
TOOL_MAPPINGS,
|
||||
CUSTOM_TOOLS_MCP_PREFIX,
|
||||
mapDroidToolNameToPi,
|
||||
mapPiToolNameToDroid,
|
||||
translateDroidArgsToPi,
|
||||
translatePiArgsToDroid,
|
||||
isCustomToolName,
|
||||
} from "../tool-mapping";
|
||||
|
||||
describe("tool-mapping", () => {
|
||||
describe("TOOL_MAPPINGS", () => {
|
||||
it("exports 6 tool mappings", () => {
|
||||
expect(TOOL_MAPPINGS).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapDroidToolNameToPi", () => {
|
||||
it("maps Read to read", () => {
|
||||
expect(mapDroidToolNameToPi("Read")).toBe("read");
|
||||
});
|
||||
|
||||
it("maps Write to write", () => {
|
||||
expect(mapDroidToolNameToPi("Write")).toBe("write");
|
||||
});
|
||||
|
||||
it("maps Edit to edit", () => {
|
||||
expect(mapDroidToolNameToPi("Edit")).toBe("edit");
|
||||
});
|
||||
|
||||
it("maps Bash to bash", () => {
|
||||
expect(mapDroidToolNameToPi("Bash")).toBe("bash");
|
||||
});
|
||||
|
||||
it("maps Grep to grep", () => {
|
||||
expect(mapDroidToolNameToPi("Grep")).toBe("grep");
|
||||
});
|
||||
|
||||
it("maps Glob to find", () => {
|
||||
expect(mapDroidToolNameToPi("Glob")).toBe("find");
|
||||
});
|
||||
|
||||
it("passes through unknown tool names unchanged", () => {
|
||||
expect(mapDroidToolNameToPi("UnknownTool")).toBe("UnknownTool");
|
||||
});
|
||||
|
||||
it("is case-insensitive for Claude tool names", () => {
|
||||
expect(mapDroidToolNameToPi("read")).toBe("read");
|
||||
expect(mapDroidToolNameToPi("READ")).toBe("read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapPiToolNameToDroid", () => {
|
||||
it("maps read to Read", () => {
|
||||
expect(mapPiToolNameToDroid("read")).toBe("Read");
|
||||
});
|
||||
|
||||
it("maps write to Write", () => {
|
||||
expect(mapPiToolNameToDroid("write")).toBe("Write");
|
||||
});
|
||||
|
||||
it("maps edit to Edit", () => {
|
||||
expect(mapPiToolNameToDroid("edit")).toBe("Edit");
|
||||
});
|
||||
|
||||
it("maps bash to Bash", () => {
|
||||
expect(mapPiToolNameToDroid("bash")).toBe("Bash");
|
||||
});
|
||||
|
||||
it("maps grep to Grep", () => {
|
||||
expect(mapPiToolNameToDroid("grep")).toBe("Grep");
|
||||
});
|
||||
|
||||
it("maps find to Glob", () => {
|
||||
expect(mapPiToolNameToDroid("find")).toBe("Glob");
|
||||
});
|
||||
|
||||
it("maps glob to Glob (asymmetry: both find and glob map to Glob)", () => {
|
||||
expect(mapPiToolNameToDroid("glob")).toBe("Glob");
|
||||
});
|
||||
|
||||
it("passes through unknown tool names unchanged", () => {
|
||||
expect(mapPiToolNameToDroid("unknownTool")).toBe("unknownTool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateDroidArgsToPi", () => {
|
||||
it("renames file_path to path for Read", () => {
|
||||
const result = translateDroidArgsToPi("Read", {
|
||||
file_path: "/foo",
|
||||
offset: 10,
|
||||
});
|
||||
expect(result).toEqual({ path: "/foo", offset: 10 });
|
||||
});
|
||||
|
||||
it("renames file_path to path for Write", () => {
|
||||
const result = translateDroidArgsToPi("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 = translateDroidArgsToPi("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 = translateDroidArgsToPi("Bash", { command: "ls" });
|
||||
expect(result).toEqual({ command: "ls" });
|
||||
});
|
||||
|
||||
it("renames head_limit to limit for Grep", () => {
|
||||
const result = translateDroidArgsToPi("Grep", {
|
||||
pattern: "x",
|
||||
head_limit: 5,
|
||||
});
|
||||
expect(result).toEqual({ pattern: "x", limit: 5 });
|
||||
});
|
||||
|
||||
it("passes through Glob args unchanged (no renames)", () => {
|
||||
const result = translateDroidArgsToPi("Glob", { pattern: "*.ts" });
|
||||
expect(result).toEqual({ pattern: "*.ts" });
|
||||
});
|
||||
|
||||
it("passes through args for unknown tools unchanged", () => {
|
||||
const result = translateDroidArgsToPi("UnknownTool", {
|
||||
foo: 1,
|
||||
bar: "baz",
|
||||
});
|
||||
expect(result).toEqual({ foo: 1, bar: "baz" });
|
||||
});
|
||||
|
||||
it("preserves unknown args alongside renamed args", () => {
|
||||
const result = translateDroidArgsToPi("Read", {
|
||||
file_path: "/foo",
|
||||
offset: 10,
|
||||
limit: 50,
|
||||
extra_arg: true,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
path: "/foo",
|
||||
offset: 10,
|
||||
limit: 50,
|
||||
extra_arg: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("translatePiArgsToDroid", () => {
|
||||
it("renames path to file_path for read", () => {
|
||||
const result = translatePiArgsToDroid("read", { path: "/foo" });
|
||||
expect(result).toEqual({ file_path: "/foo" });
|
||||
});
|
||||
|
||||
it("renames path, oldText, newText for edit", () => {
|
||||
const result = translatePiArgsToDroid("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 = translatePiArgsToDroid("grep", {
|
||||
pattern: "x",
|
||||
limit: 5,
|
||||
});
|
||||
expect(result).toEqual({ pattern: "x", head_limit: 5 });
|
||||
});
|
||||
|
||||
it("passes through unknown args alongside renamed args", () => {
|
||||
const result = translatePiArgsToDroid("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 = translatePiArgsToDroid("unknownTool", { foo: 1 });
|
||||
expect(result).toEqual({ foo: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP prefix stripping", () => {
|
||||
it("strips mcp__custom-tools__ prefix from myTool", () => {
|
||||
expect(mapDroidToolNameToPi("mcp__custom-tools__myTool")).toBe("myTool");
|
||||
});
|
||||
|
||||
it("strips mcp__custom-tools__ prefix from deploy", () => {
|
||||
expect(mapDroidToolNameToPi("mcp__custom-tools__deploy")).toBe("deploy");
|
||||
});
|
||||
|
||||
it("handles empty name after prefix", () => {
|
||||
expect(mapDroidToolNameToPi("mcp__custom-tools__")).toBe("");
|
||||
});
|
||||
|
||||
it("does NOT strip other MCP server prefixes", () => {
|
||||
expect(mapDroidToolNameToPi("mcp__other-server__foo")).toBe(
|
||||
"mcp__other-server__foo",
|
||||
);
|
||||
});
|
||||
|
||||
it("built-in mappings still work alongside MCP prefix stripping", () => {
|
||||
expect(mapDroidToolNameToPi("Read")).toBe("read");
|
||||
expect(mapDroidToolNameToPi("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);
|
||||
expect(isCustomToolName("ls")).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("translateDroidArgsToPi with MCP prefix", () => {
|
||||
it("MCP-prefixed custom tool args pass through unchanged", () => {
|
||||
const result = translateDroidArgsToPi("mcp__custom-tools__myTool", {
|
||||
foo: 1,
|
||||
bar: "baz",
|
||||
});
|
||||
expect(result).toEqual({ foo: 1, bar: "baz" });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user