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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
82
packages/droid-cli/src/control-handler.ts
Normal file
82
packages/droid-cli/src/control-handler.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Control protocol handler for Droid CLI stream-json communication.
|
||||
*
|
||||
* Processes control_request messages from Droid CLI stdout and returns a
|
||||
* control_response decision object.
|
||||
*
|
||||
* - Custom MCP tools (mcp__custom-tools__*): DENIED — pi executes these
|
||||
* - Everything else (user MCP tools, internal tools): ALLOWED — Claude handles
|
||||
*/
|
||||
|
||||
import type { ClaudeControlRequest } from "./types";
|
||||
import { CUSTOM_TOOLS_MCP_PREFIX } from "./tool-mapping.js";
|
||||
|
||||
export const TOOL_EXECUTION_DENIED_MESSAGE =
|
||||
"Tool execution is unavailable in this environment.";
|
||||
|
||||
/** Prefix for MCP (Model Context Protocol) tool names. */
|
||||
export const MCP_PREFIX = "mcp__";
|
||||
|
||||
interface ControlResponse {
|
||||
type: "control_response";
|
||||
request_id: string;
|
||||
response: {
|
||||
subtype: "success";
|
||||
response: {
|
||||
behavior: "allow" | "deny";
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a control_request from the Droid CLI.
|
||||
*
|
||||
* Denies custom MCP tools (mcp__custom-tools__*) so pi can execute them.
|
||||
* Allows everything else (user MCP tools, internal Claude tools).
|
||||
*
|
||||
* Pure function: no side effects and no stdin writes.
|
||||
*
|
||||
* @returns Decision payload with allow/deny result and serialized response object
|
||||
*/
|
||||
export function handleControlRequest(
|
||||
msg: ClaudeControlRequest,
|
||||
): { allowed: boolean; response: ControlResponse } {
|
||||
if (!msg.request_id || !msg.request) {
|
||||
console.error(
|
||||
"[droid-cli] Malformed control_request: missing request_id or request object",
|
||||
msg,
|
||||
);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
response: {
|
||||
type: "control_response",
|
||||
request_id: msg.request_id ?? "",
|
||||
response: {
|
||||
subtype: "success",
|
||||
response: {
|
||||
behavior: "deny",
|
||||
message: TOOL_EXECUTION_DENIED_MESSAGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const toolName = msg.request?.tool_name ?? "";
|
||||
const isCustomTool = toolName.startsWith(CUSTOM_TOOLS_MCP_PREFIX);
|
||||
|
||||
const response: ControlResponse = {
|
||||
type: "control_response",
|
||||
request_id: msg.request_id,
|
||||
response: {
|
||||
subtype: "success",
|
||||
response: isCustomTool
|
||||
? { behavior: "deny", message: TOOL_EXECUTION_DENIED_MESSAGE }
|
||||
: { behavior: "allow" },
|
||||
},
|
||||
};
|
||||
|
||||
return { allowed: !isCustomTool, response };
|
||||
}
|
||||
397
packages/droid-cli/src/event-bridge.ts
Normal file
397
packages/droid-cli/src/event-bridge.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import type { ClaudeApiEvent, TrackedContentBlock } from "./types";
|
||||
import { calculateCost } from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
Model,
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
mapDroidToolNameToPi,
|
||||
translateDroidArgsToPi,
|
||||
isPiKnownDroidTool,
|
||||
} from "./tool-mapping.js";
|
||||
|
||||
/**
|
||||
* Extended tracking for tool_use content blocks during streaming.
|
||||
* Stores the Claude tool name for argument translation at block_stop.
|
||||
*/
|
||||
interface TrackedToolBlock {
|
||||
type: "tool_use";
|
||||
index: number;
|
||||
id: string;
|
||||
name: string; // Already mapped to pi name
|
||||
claudeName: string; // Original Claude name for arg translation
|
||||
arguments: Record<string, unknown>;
|
||||
partialJson: string;
|
||||
}
|
||||
|
||||
/** Union of tracked block types for the blocks array. */
|
||||
type TrackedBlock = TrackedContentBlock | TrackedToolBlock;
|
||||
|
||||
/**
|
||||
* The event bridge interface returned by createEventBridge.
|
||||
* handleEvent processes each Claude API streaming event and pushes
|
||||
* the appropriate pi events to the stream.
|
||||
* getOutput returns the accumulated AssistantMessage.
|
||||
*/
|
||||
export interface EventBridge {
|
||||
handleEvent(event: ClaudeApiEvent): void;
|
||||
getOutput(): AssistantMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Claude API stop reasons to pi's stop reason format.
|
||||
*/
|
||||
function mapStopReason(
|
||||
reason: string | undefined,
|
||||
): "stop" | "length" | "toolUse" {
|
||||
switch (reason) {
|
||||
case "tool_use":
|
||||
return "toolUse";
|
||||
case "max_tokens":
|
||||
return "length";
|
||||
case "end_turn":
|
||||
default:
|
||||
return "stop";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an event bridge that translates Claude API streaming events
|
||||
* into pi's AssistantMessageEventStream events.
|
||||
*
|
||||
* The bridge maintains internal state to track content blocks and
|
||||
* accumulate the final AssistantMessage. It handles:
|
||||
* - text content blocks (start/delta/stop -> text_start/text_delta/text_end)
|
||||
* - message lifecycle (message_start for usage, message_delta for stop reason, message_stop for done)
|
||||
* - unsupported block types (tool_use, thinking) with warnings
|
||||
*/
|
||||
export function createEventBridge(
|
||||
stream: AssistantMessageEventStream,
|
||||
model: Model<Api>,
|
||||
): EventBridge {
|
||||
// Tracked content blocks indexed by Claude's content_block index
|
||||
const blocks: TrackedBlock[] = [];
|
||||
|
||||
// The accumulated output message
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant" as const,
|
||||
content: [] as (TextContent | ThinkingContent | ToolCall)[],
|
||||
api: "droid-cli",
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
let started = false;
|
||||
|
||||
function handleEvent(event: ClaudeApiEvent): void {
|
||||
// Emit start event on first message — tells pi to begin incremental rendering
|
||||
if (!started) {
|
||||
stream.push({ type: "start", partial: output });
|
||||
started = true;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "message_start":
|
||||
handleMessageStart(event);
|
||||
break;
|
||||
case "content_block_start":
|
||||
handleContentBlockStart(event);
|
||||
break;
|
||||
case "content_block_delta":
|
||||
handleContentBlockDelta(event);
|
||||
break;
|
||||
case "content_block_stop":
|
||||
handleContentBlockStop(event);
|
||||
break;
|
||||
case "message_delta":
|
||||
handleMessageDelta(event);
|
||||
break;
|
||||
case "message_stop":
|
||||
handleMessageStop();
|
||||
break;
|
||||
// Unknown event types are silently ignored
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageStart(event: ClaudeApiEvent): void {
|
||||
const usage = event.message?.usage;
|
||||
if (usage) {
|
||||
output.usage.input = usage.input_tokens ?? 0;
|
||||
output.usage.output = usage.output_tokens ?? 0;
|
||||
output.usage.cacheRead = usage.cache_read_input_tokens ?? 0;
|
||||
output.usage.cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input +
|
||||
output.usage.output +
|
||||
output.usage.cacheRead +
|
||||
output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentBlockStart(event: ClaudeApiEvent): void {
|
||||
const blockType = event.content_block?.type;
|
||||
|
||||
if (blockType === "text") {
|
||||
const block: TrackedContentBlock = {
|
||||
type: "text",
|
||||
text: "",
|
||||
index: event.index ?? 0,
|
||||
};
|
||||
blocks.push(block);
|
||||
output.content.push({ type: "text" as const, text: "" });
|
||||
|
||||
stream.push({
|
||||
type: "text_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
partial: output,
|
||||
});
|
||||
} else if (blockType === "thinking") {
|
||||
const block: TrackedContentBlock = {
|
||||
type: "thinking",
|
||||
text: "",
|
||||
index: event.index ?? 0,
|
||||
};
|
||||
blocks.push(block);
|
||||
output.content.push({
|
||||
type: "thinking" as const,
|
||||
thinking: "",
|
||||
thinkingSignature: "",
|
||||
});
|
||||
|
||||
stream.push({
|
||||
type: "thinking_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
partial: output,
|
||||
});
|
||||
} else if (blockType === "tool_use") {
|
||||
const claudeName = event.content_block!.name!;
|
||||
|
||||
// Skip internal Claude Code tools (ToolSearch, Task, Agent, etc.)
|
||||
// that pi cannot execute — only emit pi-known tools
|
||||
if (!isPiKnownDroidTool(claudeName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const piName = mapDroidToolNameToPi(claudeName);
|
||||
const id = event.content_block!.id!;
|
||||
|
||||
const block: TrackedToolBlock = {
|
||||
type: "tool_use",
|
||||
index: event.index ?? 0,
|
||||
id,
|
||||
name: piName,
|
||||
claudeName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
};
|
||||
blocks.push(block);
|
||||
output.content.push({
|
||||
type: "toolCall" as const,
|
||||
id,
|
||||
name: piName,
|
||||
arguments: {},
|
||||
} as ToolCall);
|
||||
|
||||
stream.push({
|
||||
type: "toolcall_start",
|
||||
contentIndex: output.content.length - 1,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
// Unknown block types silently ignored
|
||||
}
|
||||
|
||||
function handleContentBlockDelta(event: ClaudeApiEvent): void {
|
||||
const deltaType = event.delta?.type;
|
||||
|
||||
if (deltaType === "text_delta" && event.delta!.text != null) {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "text") {
|
||||
block.text += event.delta!.text;
|
||||
const contentBlock = output.content[idx] as TextContent;
|
||||
contentBlock.text = block.text;
|
||||
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.text,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
deltaType === "thinking_delta" &&
|
||||
event.delta!.thinking != null
|
||||
) {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "thinking") {
|
||||
block.text += event.delta!.thinking;
|
||||
const contentBlock = output.content[idx] as ThinkingContent;
|
||||
contentBlock.thinking = block.text;
|
||||
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
deltaType === "input_json_delta" &&
|
||||
event.delta!.partial_json != null
|
||||
) {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "tool_use") {
|
||||
block.partialJson += event.delta!.partial_json;
|
||||
|
||||
// Try to parse accumulated JSON -- on success update args, on failure keep previous
|
||||
try {
|
||||
block.arguments = JSON.parse(block.partialJson);
|
||||
(output.content[idx] as ToolCall).arguments = block.arguments as Record<string, unknown>;
|
||||
} catch {
|
||||
// Partial JSON not yet parseable -- keep previous arguments
|
||||
}
|
||||
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.partial_json,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
deltaType === "signature_delta" &&
|
||||
event.delta!.signature != null
|
||||
) {
|
||||
// Accumulate signature on the thinking block
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
if (block.type === "thinking") {
|
||||
const contentBlock = output.content[idx] as ThinkingContent;
|
||||
contentBlock.thinkingSignature =
|
||||
(contentBlock.thinkingSignature || "") + event.delta!.signature;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleContentBlockStop(event: ClaudeApiEvent): void {
|
||||
const idx = blocks.findIndex((b) => b.index === event.index);
|
||||
if (idx === -1) return;
|
||||
|
||||
const block = blocks[idx];
|
||||
// Clean up the tracking index from the block (no longer needed)
|
||||
delete (block as unknown as Record<string, unknown>).index;
|
||||
|
||||
if (block.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: idx,
|
||||
content: block.text,
|
||||
partial: output,
|
||||
});
|
||||
} else if (block.type === "thinking") {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: idx,
|
||||
content: block.text,
|
||||
partial: output,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
// Final JSON parse with fallback to raw string.
|
||||
// Special case: parameterless MCP tools (e.g. fn_review_spec, schema
|
||||
// `{type:"object", properties:{}}`) emit ZERO input_json_delta events,
|
||||
// so `partialJson` stays "". Without this guard we'd JSON.parse("")
|
||||
// → throw → fall through to `finalArgs = ""` (raw string), and pi's
|
||||
// TypeBox validator then rejects with "root: must be object" because
|
||||
// an empty string is not an object. Default to `{}` so the call lands.
|
||||
let finalArgs: Record<string, unknown> | string;
|
||||
const trimmedJson = block.partialJson.trim();
|
||||
if (trimmedJson === "") {
|
||||
finalArgs = {};
|
||||
} else {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedJson);
|
||||
finalArgs = translateDroidArgsToPi(block.claudeName, parsed);
|
||||
} catch {
|
||||
finalArgs = block.partialJson;
|
||||
}
|
||||
}
|
||||
|
||||
// Update output.content with final arguments
|
||||
const contentBlock = output.content[idx] as ToolCall;
|
||||
// ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
|
||||
// intentionally emit a raw string when JSON parse fails completely.
|
||||
// Pi handles string arguments gracefully at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- finalArgs may be a raw string when JSON parse fails; pi-ai handles it at runtime
|
||||
(contentBlock as any).arguments = finalArgs;
|
||||
const toolCall = {
|
||||
type: "toolCall" as const,
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: finalArgs,
|
||||
} as ToolCall;
|
||||
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: idx,
|
||||
toolCall,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageDelta(event: ClaudeApiEvent): void {
|
||||
if (event.delta?.stop_reason) {
|
||||
output.stopReason = mapStopReason(event.delta.stop_reason);
|
||||
}
|
||||
|
||||
const usage = event.usage;
|
||||
if (usage) {
|
||||
if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
|
||||
if (usage.output_tokens != null)
|
||||
output.usage.output = usage.output_tokens;
|
||||
output.usage.totalTokens =
|
||||
output.usage.input +
|
||||
output.usage.output +
|
||||
output.usage.cacheRead +
|
||||
output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageStop(): void {
|
||||
// No-op: done event is pushed by the provider after readline closes.
|
||||
// Pushing done here (synchronously) prevents pi from executing tools.
|
||||
}
|
||||
|
||||
return {
|
||||
handleEvent,
|
||||
getOutput: () => output,
|
||||
};
|
||||
}
|
||||
144
packages/droid-cli/src/mcp-config.ts
Normal file
144
packages/droid-cli/src/mcp-config.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Custom tool discovery and MCP config file generation.
|
||||
*
|
||||
* Discovers non-built-in tools from pi, writes their schemas to a temp file,
|
||||
* and generates an MCP config that points to the schema-only MCP server.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* A single tool descriptor returned by pi.getAllTools().
|
||||
*/
|
||||
interface PiToolInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal duck-type interface for the pi ExtensionAPI instance.
|
||||
* We only call getAllTools(), so we only declare that method.
|
||||
* The return type is unknown to accommodate defensive runtime checks.
|
||||
*/
|
||||
interface PiInstance {
|
||||
getAllTools(): unknown;
|
||||
}
|
||||
|
||||
/** The 6 built-in tools that pi handles natively (match pi tool names). */
|
||||
const BUILT_IN_TOOL_NAMES = new Set([
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
]);
|
||||
|
||||
/** A custom tool definition with MCP-compatible schema. */
|
||||
export interface McpToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom tool definitions from pi, filtering out built-in tools.
|
||||
*
|
||||
* @param pi - The pi ExtensionAPI instance
|
||||
* @returns Array of custom tool definitions (empty if all tools are built-in)
|
||||
*/
|
||||
export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
|
||||
const allTools = pi.getAllTools();
|
||||
|
||||
if (!Array.isArray(allTools)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (allTools as PiToolInfo[])
|
||||
.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Minimal pi-ai Tool shape (the subset we need from `Context.tools`). */
|
||||
interface PiAiToolLike {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the pi-ai `Context.tools` array (the authoritative per-session tool
|
||||
* list pi-coding-agent passes to streamSimple) into MCP tool defs, filtering
|
||||
* out the 6 built-ins that pi handles natively.
|
||||
*/
|
||||
export function toolsFromContext(
|
||||
contextTools: ReadonlyArray<PiAiToolLike> | undefined,
|
||||
): McpToolDef[] {
|
||||
if (!Array.isArray(contextTools)) return [];
|
||||
return contextTools
|
||||
.filter((tool) => !BUILT_IN_TOOL_NAMES.has(tool.name))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write MCP config and tool schemas to temp files.
|
||||
*
|
||||
* Creates two temp files:
|
||||
* 1. Schema file: JSON array of tool definitions
|
||||
* 2. Config file: MCP config pointing to the schema-only server
|
||||
*
|
||||
* @param toolDefs - Array of custom tool definitions
|
||||
* @param cacheKey - Optional suffix appended to filenames so that distinct
|
||||
* tool sets (e.g. session-scoped tool registrations) get distinct files
|
||||
* and don't race on a single shared path.
|
||||
* @returns Path to the MCP config file
|
||||
*/
|
||||
export function writeMcpConfig(
|
||||
toolDefs: McpToolDef[],
|
||||
cacheKey?: string,
|
||||
): string {
|
||||
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
|
||||
|
||||
// Write tool schemas to temp file
|
||||
const schemaFilePath = join(
|
||||
tmpdir(),
|
||||
`droid-cli-mcp-schemas-${suffix}.json`,
|
||||
);
|
||||
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
|
||||
|
||||
// Resolve path to the schema server .cjs file (sibling of this module)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const serverPath = join(__dirname, "mcp-schema-server.cjs");
|
||||
|
||||
// Build MCP config
|
||||
const config = {
|
||||
mcpServers: {
|
||||
"custom-tools": {
|
||||
command: "node",
|
||||
args: [serverPath, schemaFilePath],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Write config to temp file
|
||||
const configFilePath = join(
|
||||
tmpdir(),
|
||||
`droid-cli-mcp-config-${suffix}.json`,
|
||||
);
|
||||
writeFileSync(configFilePath, JSON.stringify(config));
|
||||
|
||||
return configFilePath;
|
||||
}
|
||||
49
packages/droid-cli/src/mcp-schema-server.cjs
Normal file
49
packages/droid-cli/src/mcp-schema-server.cjs
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// Schema-only MCP server. Reads tool schemas from a JSON file.
|
||||
// Only implements initialize + tools/list. tools/call is never reached
|
||||
// because the parent process kills the Claude subprocess at message_stop
|
||||
// before tool execution (break-early pattern).
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const readline = require("readline");
|
||||
|
||||
const schemaPath = process.argv[2];
|
||||
if (!schemaPath) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let tools = [];
|
||||
try {
|
||||
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
||||
} catch {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === "initialize") {
|
||||
const resp = {
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "custom-tools", version: "1.0.0" },
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(resp) + "\n");
|
||||
} else if (msg.method === "tools/list") {
|
||||
const resp = { jsonrpc: "2.0", id: msg.id, result: { tools } };
|
||||
process.stdout.write(JSON.stringify(resp) + "\n");
|
||||
}
|
||||
// notifications/initialized: no response needed (notification)
|
||||
// tools/call: never reached (break-early kills subprocess first)
|
||||
});
|
||||
358
packages/droid-cli/src/process-manager.ts
Normal file
358
packages/droid-cli/src/process-manager.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Process manager for spawning and managing Droid CLI subprocesses.
|
||||
*
|
||||
* Handles subprocess lifecycle: spawn with correct CLI flags, write NDJSON
|
||||
* messages to stdin, force-kill after result (CLI hangs bug), and stderr capture.
|
||||
* Also provides startup validation for CLI presence and authentication.
|
||||
*/
|
||||
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.PI_DROID_CLI_DEBUG !== "1") return;
|
||||
console.error(`[droid-cli] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a Droid CLI subprocess with all required flags for stream-json communication.
|
||||
*
|
||||
* @param modelId - The model ID to pass via --model flag
|
||||
* @param systemPrompt - Optional system prompt appended via --append-system-prompt
|
||||
* @param options - Optional cwd, AbortSignal, and effort level
|
||||
* @returns The spawned ChildProcess with piped stdin/stdout/stderr
|
||||
*/
|
||||
export function buildDroidSpawnArgs(
|
||||
modelId: string,
|
||||
systemPrompt?: string,
|
||||
options?: {
|
||||
effort?: string;
|
||||
mcpConfigPath?: string;
|
||||
resumeSessionId?: string;
|
||||
newSessionId?: string;
|
||||
},
|
||||
): string[] {
|
||||
const args = [
|
||||
"-p",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--model",
|
||||
modelId,
|
||||
];
|
||||
|
||||
if (options?.resumeSessionId) {
|
||||
// Resume an existing session — CLI loads prior conversation from disk
|
||||
args.push("--resume", options.resumeSessionId);
|
||||
} else if (options?.newSessionId) {
|
||||
// First turn: create session with this ID so subsequent turns can --resume it
|
||||
args.push("--session-id", options.newSessionId);
|
||||
}
|
||||
|
||||
if (systemPrompt) {
|
||||
// Write system prompt to a temp file to avoid ENAMETOOLONG on Windows.
|
||||
// Droid CLI's --append-system-prompt accepts a file path or literal text.
|
||||
const tmpFile = join(
|
||||
tmpdir(),
|
||||
`droid-cli-sysprompt-${process.pid}.txt`,
|
||||
);
|
||||
writeFileSync(tmpFile, systemPrompt, "utf-8");
|
||||
args.push("--append-system-prompt", tmpFile);
|
||||
}
|
||||
|
||||
if (options?.effort) {
|
||||
args.push("--effort", options.effort);
|
||||
}
|
||||
|
||||
if (options?.mcpConfigPath) {
|
||||
args.push("--mcp-config", options.mcpConfigPath);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function spawnDroid(
|
||||
modelId: string,
|
||||
systemPrompt?: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
effort?: string;
|
||||
mcpConfigPath?: string;
|
||||
resumeSessionId?: string;
|
||||
newSessionId?: string;
|
||||
},
|
||||
): ChildProcess {
|
||||
const args = buildDroidSpawnArgs(modelId, systemPrompt, {
|
||||
effort: options?.effort,
|
||||
mcpConfigPath: options?.mcpConfigPath,
|
||||
resumeSessionId: options?.resumeSessionId,
|
||||
newSessionId: options?.newSessionId,
|
||||
});
|
||||
|
||||
const proc = spawn("droid", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
});
|
||||
|
||||
debugLog(`spawnDroid: pid=${proc.pid} model=${modelId}`);
|
||||
|
||||
return proc as ChildProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the temp system prompt file created by spawnDroid.
|
||||
* Safe to call multiple times or when no file exists.
|
||||
*/
|
||||
export function cleanupSystemPromptFile(): void {
|
||||
try {
|
||||
unlinkSync(join(tmpdir(), `droid-cli-sysprompt-${process.pid}.txt`));
|
||||
} catch {
|
||||
// File doesn't exist or already deleted — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a user message to the subprocess stdin as NDJSON.
|
||||
* Calls stdin.end() after writing the user message to signal EOF, allowing
|
||||
* Droid CLI to process the input and start generating.
|
||||
*
|
||||
* Accepts both string (text-only prompt) and array (ContentBlock[] with images)
|
||||
* content. JSON.stringify handles both natively. The stream-json protocol
|
||||
* supports either format in the content field.
|
||||
*
|
||||
* @param proc - The Claude subprocess
|
||||
* @param prompt - The prompt text or ContentBlock[] to send
|
||||
*/
|
||||
export function writeUserMessage(
|
||||
proc: ChildProcess,
|
||||
prompt: string | unknown[],
|
||||
): void {
|
||||
const message = {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
};
|
||||
proc.stdin!.write(JSON.stringify(message) + "\n");
|
||||
proc.stdin!.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill a subprocess immediately via SIGKILL.
|
||||
* No-ops if the process is already dead (killed or exited).
|
||||
* Cross-platform safe: Node.js treats SIGKILL as forceful termination on Windows.
|
||||
*
|
||||
* @param proc - The subprocess to force-kill
|
||||
*/
|
||||
export function forceKillProcess(proc: ChildProcess): void {
|
||||
if (proc.killed || proc.exitCode !== null) return;
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
|
||||
/** Registry of active subprocesses for cleanup on teardown. */
|
||||
const activeProcesses = new Set<ChildProcess>();
|
||||
|
||||
/**
|
||||
* Register a subprocess in the global process registry.
|
||||
* The process is automatically removed from the registry when it exits.
|
||||
*
|
||||
* @param proc - The subprocess to track
|
||||
*/
|
||||
export function registerProcess(proc: ChildProcess): void {
|
||||
activeProcesses.add(proc);
|
||||
proc.on("exit", () => activeProcesses.delete(proc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill all registered subprocesses and clear the registry.
|
||||
* Safe to call multiple times -- no-ops on already-dead processes.
|
||||
*/
|
||||
export function killAllProcesses(): void {
|
||||
for (const proc of activeProcesses) {
|
||||
forceKillProcess(proc);
|
||||
}
|
||||
activeProcesses.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill the subprocess after a 500ms grace period.
|
||||
* The Droid CLI hangs after emitting the result message (known bug).
|
||||
* Brief grace period allows final stdout flushing before force-kill.
|
||||
*
|
||||
* @param proc - The Claude subprocess to clean up
|
||||
*/
|
||||
export function cleanupProcess(proc: ChildProcess): void {
|
||||
setTimeout(() => {
|
||||
forceKillProcess(proc);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a data listener to stderr and accumulate output into a buffer.
|
||||
*
|
||||
* @param proc - The Claude subprocess
|
||||
* @returns A function that returns the accumulated stderr string
|
||||
*/
|
||||
export function captureStderr(proc: ChildProcess): () => string {
|
||||
let buffer = "";
|
||||
proc.stderr!.on("data", (data: Buffer) => {
|
||||
buffer += data.toString();
|
||||
});
|
||||
return () => buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the Droid CLI is installed and on PATH.
|
||||
* Throws with install instructions if not found.
|
||||
*/
|
||||
export function validateCliPresence(): void {
|
||||
try {
|
||||
execSync("droid --version", { stdio: "pipe", timeout: 5000 });
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Droid CLI not found on PATH. Install Droid CLI and then run: droid auth login",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the Droid CLI is authenticated.
|
||||
* Returns false and warns if not authenticated.
|
||||
*
|
||||
* @returns true if authenticated, false otherwise
|
||||
*/
|
||||
export function validateCliAuth(): boolean {
|
||||
try {
|
||||
execSync("droid auth status", { stdio: "pipe", timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
console.warn(
|
||||
"[droid-cli] Droid CLI is not authenticated. " +
|
||||
"Run 'droid auth login' to authenticate.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a one-shot `droid <args>` and resolve to the exit code.
|
||||
*
|
||||
* Why: the sync execSync variants block the Node event loop for the duration
|
||||
* of a Droid CLI cold start (1–3s, occasionally longer). When droid-cli's
|
||||
* factory is invoked from a per-request createFnAgent path (Fusion dashboard
|
||||
* does this on every chat send), those sync probes freeze every other request.
|
||||
* This async variant uses spawn so the loop keeps turning while the subprocess
|
||||
* starts up.
|
||||
*/
|
||||
function runDroidProbe(args: string[], timeoutMs = 5000): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("droid", args, { stdio: "ignore" });
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
resolve(124);
|
||||
}, timeoutMs);
|
||||
proc.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(127);
|
||||
});
|
||||
proc.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Async, non-blocking variant of validateCliPresence.
|
||||
* Resolves with `{ok: true}` on success, `{ok: false, error}` on failure —
|
||||
* never rejects, so callers can fire-and-forget without unhandled rejections.
|
||||
*/
|
||||
export async function validateCliPresenceAsync(): Promise<
|
||||
{ ok: true } | { ok: false; error: Error }
|
||||
> {
|
||||
const code = await runDroidProbe(["--version"]);
|
||||
if (code === 0) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Droid CLI not found on PATH. Install Droid CLI and then run: droid auth login",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Async, non-blocking variant of validateCliAuth.
|
||||
* Returns true if authenticated. Logs a warning (does not throw) otherwise.
|
||||
*/
|
||||
export async function validateCliAuthAsync(): Promise<boolean> {
|
||||
const code = await runDroidProbe(["auth", "status"]);
|
||||
if (code === 0) return true;
|
||||
console.warn(
|
||||
"[droid-cli] Droid CLI is not authenticated. " +
|
||||
"Run 'droid auth login' to authenticate.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function discoverDroidModels(): Promise<string[]> {
|
||||
const attempts: string[][] = [["models", "--json"], ["model", "list", "--json"], ["models"]];
|
||||
|
||||
for (const args of attempts) {
|
||||
const models = await new Promise<string[] | null>((resolve) => {
|
||||
const proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let out = "";
|
||||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||
out += chunk.toString();
|
||||
});
|
||||
proc.once("error", () => resolve(null));
|
||||
proc.once("exit", (code) => {
|
||||
if (code !== 0) return resolve(null);
|
||||
const trimmed = out.trim();
|
||||
if (!trimmed) return resolve([]);
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return resolve(
|
||||
parsed
|
||||
.map((entry) =>
|
||||
typeof entry === "string"
|
||||
? entry
|
||||
: typeof entry?.id === "string"
|
||||
? entry.id
|
||||
: typeof entry?.name === "string"
|
||||
? entry.name
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// not json, fall through to line parsing
|
||||
}
|
||||
resolve(
|
||||
trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (models && models.length > 0) {
|
||||
return Array.from(new Set(models));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
638
packages/droid-cli/src/prompt-builder.ts
Normal file
638
packages/droid-cli/src/prompt-builder.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* Prompt builder for flattening pi conversation history into a labeled text prompt.
|
||||
*
|
||||
* Follows the reference project's buildPromptBlocks() pattern:
|
||||
* - USER: / ASSISTANT: / TOOL RESULT: labels
|
||||
* - Content blocks serialized by type
|
||||
* - Images in the final user message are translated to Anthropic API format (HIST-02)
|
||||
* - Images in non-final messages get placeholder text with console.warn
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve, join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
/**
|
||||
* Minimal message shape that prompt-builder accepts.
|
||||
* Uses a wide `role: string` discriminant so tests can pass plain objects
|
||||
* without literal type annotations. Content is typed broadly as
|
||||
* `string | unknown[]` since helper functions narrow at runtime.
|
||||
*/
|
||||
export interface PiMessage {
|
||||
role: string;
|
||||
content: string | unknown[];
|
||||
toolName?: string;
|
||||
}
|
||||
/**
|
||||
* Minimal pi-ai Tool shape — the subset we read from `Context.tools` to build
|
||||
* the deferred-tools system-prompt addendum.
|
||||
*/
|
||||
export interface PiToolLike {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type PiContext = {
|
||||
systemPrompt?: string;
|
||||
messages: PiMessage[];
|
||||
tools?: ReadonlyArray<PiToolLike>;
|
||||
};
|
||||
import {
|
||||
mapPiToolNameToDroid,
|
||||
translatePiArgsToDroid,
|
||||
isCustomToolName,
|
||||
} from "./tool-mapping.js";
|
||||
|
||||
/**
|
||||
* Anthropic API content block types for image passthrough.
|
||||
* Used when the final user message contains images that need to be
|
||||
* translated from pi-ai format to Anthropic format.
|
||||
*/
|
||||
type AnthropicContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "image";
|
||||
source: { type: "base64"; media_type: string; data: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens a pi conversation context's messages array into a labeled text prompt
|
||||
* suitable for sending to the Droid CLI subprocess.
|
||||
*
|
||||
* Each message is labeled with its role:
|
||||
* - USER: for user messages
|
||||
* - ASSISTANT: for assistant messages
|
||||
* - TOOL RESULT ({toolName}): for tool result messages
|
||||
*/
|
||||
/** Module-level counter for placeholder images, reset per buildPrompt call. */
|
||||
let placeholderImageCount = 0;
|
||||
|
||||
/**
|
||||
* Translate a pi-ai image block to Anthropic API format.
|
||||
* Returns null if the block is missing required data/mimeType fields.
|
||||
*
|
||||
* pi-ai format: { type: "image", data: string (base64), mimeType: string }
|
||||
* Anthropic format: { type: "image", source: { type: "base64", media_type: string, data: string } }
|
||||
*/
|
||||
function translateImageBlock(piBlock: unknown): AnthropicContentBlock | null {
|
||||
const block = piBlock as Record<string, unknown>;
|
||||
if (typeof block.data === "string" && typeof block.mimeType === "string") {
|
||||
return {
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: block.mimeType,
|
||||
data: block.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null; // Invalid image block, will fall back to placeholder
|
||||
}
|
||||
|
||||
/**
|
||||
* Build content blocks for the final user message, translating images
|
||||
* from pi-ai format to Anthropic API format.
|
||||
*
|
||||
* @returns Array of AnthropicContentBlock with text and translated images
|
||||
*/
|
||||
function buildFinalUserContent(
|
||||
content: string | unknown[],
|
||||
): AnthropicContentBlock[] {
|
||||
if (typeof content === "string") {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return [{ type: "text", text: "" }];
|
||||
}
|
||||
|
||||
const blocks: AnthropicContentBlock[] = [];
|
||||
for (const rawBlock of content) {
|
||||
const block = rawBlock as Record<string, unknown>;
|
||||
if (block.type === "text") {
|
||||
blocks.push({ type: "text", text: typeof block.text === "string" ? block.text : "" });
|
||||
} else if (block.type === "image") {
|
||||
const translated = translateImageBlock(block);
|
||||
if (translated) {
|
||||
blocks.push(translated);
|
||||
} else {
|
||||
// Invalid image block: fall back to placeholder text
|
||||
blocks.push({
|
||||
type: "text",
|
||||
text: "[An image was shared here but could not be included]",
|
||||
});
|
||||
placeholderImageCount++;
|
||||
}
|
||||
}
|
||||
// Unknown block types silently skipped
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message content array contains image blocks.
|
||||
*/
|
||||
function contentHasImages(content: string | unknown[]): boolean {
|
||||
if (typeof content === "string" || !Array.isArray(content)) return false;
|
||||
return content.some((block) => (block as Record<string, unknown>).type === "image");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the conversation ends with a custom tool result.
|
||||
* If so, build a simplified prompt that presents the result directly
|
||||
* instead of replaying the full conversation history with tool labels.
|
||||
*/
|
||||
function buildCustomToolResultPrompt(messages: PiMessage[]): string | null {
|
||||
if (messages.length < 3) return null;
|
||||
|
||||
const last = messages[messages.length - 1];
|
||||
if (last.role !== "toolResult") return null;
|
||||
if (!last.toolName || !isCustomToolName(last.toolName)) return null;
|
||||
|
||||
// Find the original user message (scan backwards past assistant + toolResult)
|
||||
let userMessage: string | null = null;
|
||||
for (let i = messages.length - 3; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "user") {
|
||||
userMessage = userContentToText(msg.content);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!userMessage) return null;
|
||||
|
||||
const toolResult = toolResultContentToText(last.content);
|
||||
return `${userMessage}\n\n[The ${last.toolName} tool was called and returned the following result]\n${toolResult}\n\nRespond to the user using the tool result above.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a prompt for a resumed session.
|
||||
*
|
||||
* When resuming via --resume, the CLI already has the full conversation history.
|
||||
* We only need to send the new content since the last turn: the last assistant
|
||||
* response's tool results (if any) followed by the latest user message.
|
||||
*
|
||||
* For tool_use flows: pi sends [user, assistant(toolCall), toolResult, ...]
|
||||
* We need to include tool results so the resumed session sees them, plus the
|
||||
* final user message.
|
||||
*
|
||||
* Falls back to full prompt if the message structure is unexpected.
|
||||
*/
|
||||
export function buildResumePrompt(context: PiContext): string | AnthropicContentBlock[] {
|
||||
const messages = context.messages;
|
||||
if (messages.length === 0) return "";
|
||||
|
||||
// Find the last user message
|
||||
const finalUserIndex = findFinalUserMessageIndex(messages);
|
||||
if (finalUserIndex < 0) return "";
|
||||
|
||||
// Collect new messages: everything from the last assistant turn onwards
|
||||
// (tool results from the last assistant + the new user message)
|
||||
const newMessages: PiMessage[] = [];
|
||||
|
||||
// Walk backwards from finalUserIndex to find where new content starts.
|
||||
// Include trailing toolResult messages that follow the last assistant turn.
|
||||
let startIdx = finalUserIndex;
|
||||
for (let i = finalUserIndex - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "toolResult") {
|
||||
startIdx = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startIdx; i < messages.length; i++) {
|
||||
newMessages.push(messages[i]);
|
||||
}
|
||||
|
||||
// If there are only tool results + one user message, build a combined prompt
|
||||
const parts: string[] = [];
|
||||
for (const msg of newMessages) {
|
||||
if (msg.role === "toolResult") {
|
||||
if (msg.toolName && isCustomToolName(msg.toolName)) {
|
||||
parts.push(`TOOL RESULT (${msg.toolName}):`);
|
||||
} else {
|
||||
const claudeToolName = msg.toolName
|
||||
? mapPiToolNameToDroid(msg.toolName)
|
||||
: "unknown";
|
||||
parts.push(`TOOL RESULT (${claudeToolName}):`);
|
||||
}
|
||||
parts.push(toolResultContentToText(msg.content));
|
||||
} else if (msg.role === "user") {
|
||||
// Check for images in the final user message
|
||||
if (contentHasImages(msg.content)) {
|
||||
const textSoFar = parts.join("\n");
|
||||
const userContent = buildFinalUserContent(msg.content);
|
||||
const result: AnthropicContentBlock[] = [];
|
||||
if (textSoFar) {
|
||||
result.push({ type: "text", text: textSoFar });
|
||||
}
|
||||
result.push(...userContent);
|
||||
return result;
|
||||
}
|
||||
parts.push(userContentToText(msg.content));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n") || "";
|
||||
}
|
||||
|
||||
export function buildPrompt(context: PiContext): string | AnthropicContentBlock[] {
|
||||
// Reset placeholder counter for each call
|
||||
placeholderImageCount = 0;
|
||||
|
||||
// Special case: when conversation ends with a custom tool result,
|
||||
// present it directly instead of complex history replay
|
||||
const customToolPrompt = buildCustomToolResultPrompt(context.messages);
|
||||
if (customToolPrompt) {
|
||||
// customToolPrompt calls userContentToText which may increment placeholderImageCount
|
||||
if (placeholderImageCount > 0) {
|
||||
console.warn(
|
||||
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
|
||||
);
|
||||
}
|
||||
return customToolPrompt;
|
||||
}
|
||||
|
||||
// Determine if any message has images worth passing through
|
||||
const finalUserIndex = findFinalUserMessageIndex(context.messages);
|
||||
const finalUserMsg = finalUserIndex >= 0 ? context.messages[finalUserIndex] : undefined;
|
||||
const finalUserHasImages =
|
||||
finalUserMsg !== undefined &&
|
||||
finalUserMsg.role === "user" &&
|
||||
contentHasImages(finalUserMsg.content);
|
||||
const anyToolResultHasImages = context.messages.some(
|
||||
(m) => m.role === "toolResult" && toolResultHasImages(m.content),
|
||||
);
|
||||
|
||||
if (finalUserHasImages || anyToolResultHasImages) {
|
||||
// Build history as text (all messages except the final user message)
|
||||
const historyParts: string[] = [];
|
||||
const toolResultImageBlocks: AnthropicContentBlock[] = [];
|
||||
for (let i = 0; i < context.messages.length; i++) {
|
||||
if (i === finalUserIndex) continue; // Skip final user message -- handled separately
|
||||
const message = context.messages[i];
|
||||
if (message.role === "user") {
|
||||
historyParts.push("USER:");
|
||||
historyParts.push(userContentToText(message.content));
|
||||
} else if (message.role === "assistant") {
|
||||
historyParts.push("ASSISTANT:");
|
||||
historyParts.push(contentToText(message.content));
|
||||
} else if (message.role === "toolResult") {
|
||||
if (message.toolName && isCustomToolName(message.toolName)) {
|
||||
historyParts.push(`TOOL RESULT (${message.toolName}):`);
|
||||
} else {
|
||||
const claudeToolName = message.toolName
|
||||
? mapPiToolNameToDroid(message.toolName)
|
||||
: "unknown";
|
||||
historyParts.push(`TOOL RESULT (${claudeToolName}):`);
|
||||
}
|
||||
// Extract text portion of tool result
|
||||
historyParts.push(toolResultContentToText(message.content));
|
||||
// Collect image blocks from tool results for passthrough
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const rawBlock of message.content) {
|
||||
const block = rawBlock as Record<string, unknown>;
|
||||
if (block.type === "image") {
|
||||
const translated = translateImageBlock(block);
|
||||
if (translated) {
|
||||
toolResultImageBlocks.push(translated);
|
||||
// Undo the placeholder count from toolResultContentToText since we're passing through
|
||||
placeholderImageCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final user message content blocks
|
||||
const finalUserContent =
|
||||
finalUserMsg?.role === "user"
|
||||
? buildFinalUserContent(finalUserMsg.content)
|
||||
: [];
|
||||
|
||||
// Combine: history text + tool result images + final user content blocks
|
||||
const result: AnthropicContentBlock[] = [];
|
||||
const historyText = historyParts.join("\n");
|
||||
if (historyText) {
|
||||
result.push({ type: "text", text: historyText });
|
||||
}
|
||||
// Insert tool result images after history text (Claude sees them in context)
|
||||
result.push(...toolResultImageBlocks);
|
||||
result.push(...finalUserContent);
|
||||
|
||||
if (placeholderImageCount > 0) {
|
||||
console.warn(
|
||||
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// No images in final user message: standard text-only path
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const message of context.messages) {
|
||||
if (message.role === "user") {
|
||||
parts.push("USER:");
|
||||
parts.push(userContentToText(message.content));
|
||||
} else if (message.role === "assistant") {
|
||||
parts.push("ASSISTANT:");
|
||||
parts.push(contentToText(message.content));
|
||||
} else if (message.role === "toolResult") {
|
||||
if (message.toolName && isCustomToolName(message.toolName)) {
|
||||
// Custom tools: don't reference MCP tool name. Present result plainly.
|
||||
parts.push(`TOOL RESULT (${message.toolName}):`);
|
||||
} else {
|
||||
const claudeToolName = message.toolName
|
||||
? mapPiToolNameToDroid(message.toolName)
|
||||
: "unknown";
|
||||
parts.push(`TOOL RESULT (${claudeToolName}):`);
|
||||
}
|
||||
parts.push(toolResultContentToText(message.content));
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholderImageCount > 0) {
|
||||
console.warn(
|
||||
`[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join("\n") || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the last user message in the messages array.
|
||||
* Returns -1 if no user message found.
|
||||
*/
|
||||
function findFinalUserMessageIndex(messages: PiMessage[]): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the system prompt from the context's systemPrompt field,
|
||||
* appending AGENTS.md content if found (walking up from cwd, then global fallback).
|
||||
* Sanitizes .pi references to .claude for Claude Code compatibility.
|
||||
*/
|
||||
export function buildSystemPrompt(
|
||||
context: PiContext,
|
||||
cwd: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (context.systemPrompt) {
|
||||
parts.push(rewriteCustomToolReferences(context.systemPrompt, context.tools));
|
||||
}
|
||||
|
||||
// Look for AGENTS.md
|
||||
const agentsPath = resolveAgentsMdPath(cwd);
|
||||
if (agentsPath) {
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
const sanitized = sanitizeAgentsContent(content);
|
||||
parts.push(sanitized);
|
||||
} catch {
|
||||
// If we can't read it, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
// When conversation history has tool results, instruct Claude to use them
|
||||
// instead of trying to re-call tools (which may not be available).
|
||||
if (context.messages?.some((m) => m.role === "toolResult")) {
|
||||
parts.push(
|
||||
"IMPORTANT: The conversation history below contains tool results from previously executed tools. " +
|
||||
"Use these results to answer the user's question. Do NOT attempt to re-call tools that already have results.",
|
||||
);
|
||||
}
|
||||
|
||||
const customToolsAddendum = buildCustomToolsAddendum(context.tools);
|
||||
if (customToolsAddendum) {
|
||||
parts.push(customToolsAddendum);
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
/** Pi built-in tool names — these go through pi's wrapped built-ins, not MCP. */
|
||||
const BUILT_IN_PI_TOOLS = new Set([
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"grep",
|
||||
"find",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Rewrite bare references to custom pi tool names (e.g. `fn_review_spec`,
|
||||
* `fn_review_spec()`) in the system prompt so they appear as their
|
||||
* MCP-prefixed names (`mcp__custom-tools__fn_review_spec`). Engine prompts are
|
||||
* written for direct API tool calls; under droid-cli the same tools are
|
||||
* reachable only through the MCP shim. Without this rewrite, models like
|
||||
* Sonnet 4.6 inconsistently translate the names — sometimes calling MCP
|
||||
* variants, sometimes silently skipping the call (observed in triage where
|
||||
* `fn_review_spec` was never invoked even though the prompt said "MUST call").
|
||||
*
|
||||
* Only rewrites whole-word matches anchored to a non-identifier boundary, so
|
||||
* substrings inside other identifiers stay intact. Skips already-prefixed
|
||||
* occurrences (`mcp__custom-tools__fn_review_spec`) and pi built-ins.
|
||||
*/
|
||||
function rewriteCustomToolReferences(
|
||||
prompt: string,
|
||||
tools: ReadonlyArray<PiToolLike> | undefined,
|
||||
): string {
|
||||
if (!prompt || !tools || tools.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
let result = prompt;
|
||||
for (const tool of tools) {
|
||||
if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
|
||||
// \b doesn't treat `_` as a word boundary the way we want here, so anchor
|
||||
// the match between either start-of-string/non-identifier-char and either
|
||||
// end-of-string/non-identifier-char. Also negative-lookbehind for
|
||||
// `mcp__custom-tools__` so we don't double-prefix.
|
||||
const escaped = tool.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(
|
||||
`(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
|
||||
"g",
|
||||
);
|
||||
result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a system-prompt addendum that maps each custom pi tool to its
|
||||
* MCP-exposed name (`mcp__custom-tools__<name>`) and tells Claude to call
|
||||
* those names directly. We intentionally avoid a ToolSearch prerequisite:
|
||||
* requiring an internal discovery step can send the model into long internal
|
||||
* tool loops before it emits actionable pi tool calls.
|
||||
*
|
||||
* Returns an empty string when there are no custom tools so the addendum
|
||||
* doesn't pollute prompts on plain chat sessions with only built-ins.
|
||||
*/
|
||||
function buildCustomToolsAddendum(
|
||||
tools: ReadonlyArray<PiToolLike> | undefined,
|
||||
): string {
|
||||
if (!tools || tools.length === 0) return "";
|
||||
const customNames = tools
|
||||
.map((t) => t.name)
|
||||
.filter((name) => !BUILT_IN_PI_TOOLS.has(name));
|
||||
if (customNames.length === 0) return "";
|
||||
|
||||
const lines = customNames
|
||||
.sort()
|
||||
.map((name) => `- \`${name}\` is exposed as \`mcp__custom-tools__${name}\``);
|
||||
|
||||
return [
|
||||
"## Custom tool naming (MCP)",
|
||||
"",
|
||||
"The following pi extension tools are available under MCP-prefixed",
|
||||
"names. When a system prompt or task instruction asks you to call one",
|
||||
"of these by its short name, call the MCP-prefixed name directly.",
|
||||
"",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts user message content to text.
|
||||
* Handles string content and array of content blocks.
|
||||
* Image blocks are replaced with placeholder text (HIST-02).
|
||||
* Increments the module-level placeholderImageCount for each image.
|
||||
*/
|
||||
function userContentToText(content: string | unknown[]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const rawBlock of content) {
|
||||
const block = rawBlock as Record<string, unknown>;
|
||||
if (block.type === "text") {
|
||||
texts.push(typeof block.text === "string" ? block.text : "");
|
||||
} else if (block.type === "image") {
|
||||
texts.push("[An image was shared here but could not be included]");
|
||||
placeholderImageCount++;
|
||||
}
|
||||
// Unknown block types silently skipped
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts assistant message content to text.
|
||||
* Handles string content and array of content blocks (text, thinking, toolCall).
|
||||
*/
|
||||
function contentToText(content: string | unknown[]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((rawBlock) => {
|
||||
const block = rawBlock as Record<string, unknown>;
|
||||
if (block.type === "text") return typeof block.text === "string" ? block.text : "";
|
||||
if (block.type === "thinking") return ""; // Skip thinking — internal reasoning, not conversation
|
||||
if (block.type === "toolCall") {
|
||||
const name = typeof block.name === "string" ? block.name : "";
|
||||
const rawArgs = block.arguments;
|
||||
// A toolCall may carry either parsed args (object) or the raw unparsed
|
||||
// string that pi produced — preserve the raw string verbatim so callers
|
||||
// can see what the model actually sent.
|
||||
const argsObject =
|
||||
rawArgs && typeof rawArgs === "object" ? (rawArgs as Record<string, unknown>) : undefined;
|
||||
const isCustom = isCustomToolName(name);
|
||||
if (isCustom) {
|
||||
// Custom tools: don't reference the MCP tool name — Claude might try to re-call it.
|
||||
// Just note what was done. The result follows as a TOOL RESULT message.
|
||||
const argsStr = argsObject
|
||||
? JSON.stringify(argsObject)
|
||||
: typeof rawArgs === "string"
|
||||
? JSON.stringify(rawArgs)
|
||||
: "{}";
|
||||
return `[Used ${name} tool with args: ${argsStr}]`;
|
||||
}
|
||||
const claudeName = mapPiToolNameToDroid(name);
|
||||
const claudeArgs = argsObject ? translatePiArgsToDroid(name, argsObject) : undefined;
|
||||
const argsStr = claudeArgs
|
||||
? JSON.stringify(claudeArgs)
|
||||
: typeof rawArgs === "string"
|
||||
? JSON.stringify(rawArgs)
|
||||
: "{}";
|
||||
return `[Prior tool call — already executed; result follows in TOOL RESULT (${claudeName}):] args=${argsStr}`;
|
||||
}
|
||||
// Unknown block types are represented as a placeholder
|
||||
return `[${String(block.type)}]`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts tool result content to text.
|
||||
* Handles string content and array of content blocks.
|
||||
* Image blocks get placeholder text (actual image passthrough handled separately).
|
||||
*/
|
||||
function toolResultContentToText(content: string | unknown[]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const rawBlock of content) {
|
||||
const block = rawBlock as Record<string, unknown>;
|
||||
if (block.type === "text") {
|
||||
texts.push(typeof block.text === "string" ? block.text : "");
|
||||
} else if (block.type === "image") {
|
||||
texts.push("[An image was shared here but could not be included]");
|
||||
placeholderImageCount++;
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool result content array contains image blocks.
|
||||
*/
|
||||
function toolResultHasImages(content: string | unknown[]): boolean {
|
||||
if (typeof content === "string" || !Array.isArray(content)) return false;
|
||||
return content.some((block) => (block as Record<string, unknown>).type === "image");
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from cwd looking for AGENTS.md, fall back to ~/.pi/agent/AGENTS.md.
|
||||
*/
|
||||
function resolveAgentsMdPath(cwd: string): string | undefined {
|
||||
let current = resolve(cwd);
|
||||
while (true) {
|
||||
const candidate = join(current, "AGENTS.md");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
// Fall back to global path
|
||||
const globalHome = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
const globalPath = join(globalHome, ".pi", "agent", "AGENTS.md");
|
||||
if (existsSync(globalPath)) return globalPath;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize .pi references to .claude in AGENTS.md content
|
||||
* for Claude Code compatibility.
|
||||
*/
|
||||
function sanitizeAgentsContent(content: string): string {
|
||||
let sanitized = content;
|
||||
// ~/.pi -> ~/.claude
|
||||
sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
|
||||
// .pi/ -> .claude/ (at word boundary or after whitespace/quotes)
|
||||
sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
|
||||
// Remaining standalone .pi references
|
||||
sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
|
||||
return sanitized;
|
||||
}
|
||||
414
packages/droid-cli/src/provider.ts
Normal file
414
packages/droid-cli/src/provider.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Provider orchestration for bridging pi requests to the Droid CLI subprocess.
|
||||
*
|
||||
* streamViaCli is the core function that:
|
||||
* 1. Builds the prompt from conversation context
|
||||
* 2. Spawns a Droid CLI subprocess with correct flags
|
||||
* 3. Writes the user message to stdin as NDJSON
|
||||
* 4. Reads stdout line-by-line, parsing NDJSON
|
||||
* 5. Routes stream events through the event bridge to pi's stream
|
||||
* 6. Handles result/error messages and cleans up the subprocess
|
||||
* 7. Implements break-early: kills subprocess at message_stop when
|
||||
* built-in or custom-tools MCP tool_use blocks are seen
|
||||
* 8. Hardened lifecycle: inactivity timeout, subprocess exit handler,
|
||||
* streamEnded guard, abort via SIGKILL, process registry
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
AssistantMessageEventStream,
|
||||
type Api,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type TextContent,
|
||||
type ThinkingContent,
|
||||
type ToolCall,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
buildPrompt,
|
||||
buildSystemPrompt,
|
||||
buildResumePrompt,
|
||||
type PiContext,
|
||||
} from "./prompt-builder.js";
|
||||
import {
|
||||
spawnDroid,
|
||||
writeUserMessage,
|
||||
cleanupProcess,
|
||||
captureStderr,
|
||||
forceKillProcess,
|
||||
registerProcess,
|
||||
cleanupSystemPromptFile,
|
||||
buildDroidSpawnArgs,
|
||||
} from "./process-manager.js";
|
||||
import { parseLine } from "./stream-parser.js";
|
||||
import { createEventBridge } from "./event-bridge.js";
|
||||
import { mapThinkingEffort } from "./thinking-config.js";
|
||||
import { isPiKnownDroidTool } from "./tool-mapping.js";
|
||||
/**
|
||||
* Inactivity safety net for the Droid CLI subprocess.
|
||||
*
|
||||
* Set very high (30 minutes) because the caller is the authoritative source of
|
||||
* truth for "this session is stuck": Fusion's engine runs a `StuckTaskDetector`
|
||||
* with a configurable heartbeat (default 1 hour) and aborts the session via
|
||||
* `AbortSignal` when it decides the agent has gone quiet. droid-cli already
|
||||
* forwards that signal to the subprocess (`forceKillProcess` on `signal.abort`).
|
||||
*
|
||||
* A short timeout here was racing the engine: Sonnet 4.6 with extended thinking
|
||||
* on the triage prompt (~40k chars) routinely goes >3 minutes between thinking
|
||||
* deltas, and we were killing those subprocesses before they could write
|
||||
* PROMPT.md and call `fn_review_spec`. The half-hour ceiling is just a
|
||||
* last-resort guard for catastrophically hung processes when no abort signal
|
||||
* arrives (e.g. someone embeds droid-cli without a stuck detector).
|
||||
*/
|
||||
const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
|
||||
function isDebugStreamEnabled(): boolean {
|
||||
return process.env.PI_DROID_CLI_DEBUG === "1";
|
||||
}
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (!isDebugStreamEnabled()) return;
|
||||
console.error(`[droid-cli] ${message}`);
|
||||
}
|
||||
|
||||
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
|
||||
type StreamViaCLiOptions = SimpleStreamOptions & {
|
||||
cwd?: string;
|
||||
mcpConfigPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream a response from Droid CLI as an AssistantMessageEventStream.
|
||||
*
|
||||
* Orchestrates the full subprocess lifecycle: spawn, write prompt, parse NDJSON,
|
||||
* bridge events, handle result, and clean up. Implements break-early pattern:
|
||||
* at message_stop, if any built-in or custom-tools MCP tool was seen, kills
|
||||
* the subprocess before Droid CLI can auto-execute the tools.
|
||||
*
|
||||
* Hardened with: inactivity timeout (180s), subprocess exit handler with stderr
|
||||
* surfacing, streamEnded guard against double errors, abort via SIGKILL, and
|
||||
* process registry integration for teardown cleanup.
|
||||
*
|
||||
* @param model - The model to use (from pi's model catalog)
|
||||
* @param context - The conversation context with messages and system prompt
|
||||
* @param options - Optional cwd, abort signal, reasoning level, thinking budgets, and mcpConfigPath
|
||||
* @returns An AssistantMessageEventStream that receives bridged events
|
||||
*/
|
||||
export function streamViaCli(
|
||||
model: Model<Api>,
|
||||
context: PiContext,
|
||||
options?: StreamViaCLiOptions,
|
||||
): AssistantMessageEventStream {
|
||||
// @ts-expect-error — tsc can't verify AssistantMessageEventStream is a value
|
||||
// through pi-ai's `export *` re-export chain. The class constructor exists at runtime.
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
let proc: ReturnType<typeof spawnDroid> | undefined;
|
||||
let abortHandler: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
|
||||
// Resume if pi provides a session ID AND this isn't the first turn.
|
||||
// Pi passes sessionId on every call (including first), but we can only
|
||||
// --resume a CLI session that already exists on disk from a prior turn.
|
||||
const resumeSessionId =
|
||||
options?.sessionId && context.messages.length > 1
|
||||
? options.sessionId
|
||||
: undefined;
|
||||
|
||||
// Build prompt: if resuming, only send the latest user turn;
|
||||
// otherwise build the full flattened conversation history
|
||||
const prompt = resumeSessionId
|
||||
? buildResumePrompt(context)
|
||||
: buildPrompt(context);
|
||||
const systemPrompt = resumeSessionId
|
||||
? undefined
|
||||
: buildSystemPrompt(context, cwd);
|
||||
|
||||
// Compute effort level from reasoning options
|
||||
const effort = mapThinkingEffort(
|
||||
options?.reasoning,
|
||||
model.id,
|
||||
options?.thinkingBudgets,
|
||||
);
|
||||
|
||||
const spawnOptions = {
|
||||
cwd,
|
||||
signal: options?.signal,
|
||||
effort,
|
||||
mcpConfigPath: options?.mcpConfigPath,
|
||||
resumeSessionId,
|
||||
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
|
||||
};
|
||||
|
||||
// Spawn subprocess
|
||||
proc = spawnDroid(model.id, systemPrompt || undefined, spawnOptions);
|
||||
const getStderr = captureStderr(proc);
|
||||
|
||||
// Register in global process registry for teardown cleanup
|
||||
registerProcess(proc);
|
||||
const spawnArgs = buildDroidSpawnArgs(model.id, undefined, {
|
||||
effort,
|
||||
mcpConfigPath: options?.mcpConfigPath,
|
||||
resumeSessionId,
|
||||
newSessionId: !resumeSessionId ? options?.sessionId : undefined,
|
||||
});
|
||||
debugLog(
|
||||
`spawned droid subprocess pid=${proc.pid ?? "unknown"} args=${JSON.stringify(spawnArgs)}`,
|
||||
);
|
||||
|
||||
// Write user message to subprocess stdin
|
||||
writeUserMessage(proc, prompt);
|
||||
debugLog("user message written to stdin, stdin.end() called");
|
||||
|
||||
// Create event bridge (before endStreamWithError so bridge is in scope)
|
||||
const bridge = createEventBridge(stream, model);
|
||||
|
||||
// Guard against double stream.end() and double error events.
|
||||
// First error path wins; subsequent ones are no-ops.
|
||||
let streamEnded = false;
|
||||
|
||||
/**
|
||||
* End the stream with an error, using a "done" event instead of "error".
|
||||
*
|
||||
* Why "done" not "error": AssistantMessageEventStream.extractResult()
|
||||
* returns event.error (a string) for error events, but agent-loop.js
|
||||
* then calls message.content.filter() on the result, crashing because
|
||||
* a string has no .content property. By pushing "done" with a valid
|
||||
* AssistantMessage (content:[]), pi gets a well-formed object.
|
||||
*/
|
||||
function endStreamWithError(errMsg: string) {
|
||||
if (streamEnded || broken) return;
|
||||
streamEnded = true;
|
||||
const output = bridge.getOutput();
|
||||
const errorMessage = {
|
||||
...output,
|
||||
content: output.content?.length
|
||||
? output.content
|
||||
: [{ type: "text" as const, text: `Error: ${errMsg}` }],
|
||||
stopReason: "stop" as const,
|
||||
};
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: errorMessage,
|
||||
});
|
||||
stream.end();
|
||||
}
|
||||
|
||||
// Inactivity timeout: kill subprocess if no stdout for INACTIVITY_TIMEOUT_MS
|
||||
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function resetInactivityTimer() {
|
||||
if (inactivityTimer !== undefined) clearTimeout(inactivityTimer);
|
||||
inactivityTimer = setTimeout(() => {
|
||||
forceKillProcess(proc!);
|
||||
endStreamWithError(
|
||||
`Droid CLI subprocess timed out: no output for ${INACTIVITY_TIMEOUT_MS / 1000} seconds`,
|
||||
);
|
||||
}, INACTIVITY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Set up abort signal handler -- uses SIGKILL for immediate force-kill
|
||||
if (options?.signal) {
|
||||
abortHandler = () => {
|
||||
if (proc) {
|
||||
forceKillProcess(proc);
|
||||
}
|
||||
};
|
||||
|
||||
if (options.signal.aborted) {
|
||||
abortHandler();
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Track tool_use blocks for break-early decision at message_stop
|
||||
let sawBuiltInOrCustomTool = false;
|
||||
let firstLineReceived = false;
|
||||
// Guard against buffered readline lines firing after rl.close()
|
||||
let broken = false;
|
||||
|
||||
// Set up readline for line-by-line NDJSON parsing
|
||||
const rl = createInterface({
|
||||
input: proc.stdout!,
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
// Handle process error -- use endStreamWithError for guard
|
||||
proc.on("error", (err: Error) => {
|
||||
if (broken) return; // Break-early killed the process intentionally
|
||||
const stderr = getStderr();
|
||||
endStreamWithError(stderr || err.message);
|
||||
});
|
||||
|
||||
// Handle subprocess close -- surface crashes with stderr and exit code
|
||||
proc.on("close", (code: number | null, _signal: string | null) => {
|
||||
clearTimeout(inactivityTimer);
|
||||
debugLog(`subprocess closed: code=${code} signal=${_signal}`);
|
||||
if (broken) return; // Break-early kill, expected
|
||||
const stderr = getStderr().trim();
|
||||
if (stderr) {
|
||||
console.warn(`[droid-cli] Droid CLI stderr on close: ${stderr}`);
|
||||
}
|
||||
if (code !== 0 && code !== null) {
|
||||
const message = stderr
|
||||
? `Droid CLI exited with code ${code}: ${stderr}`
|
||||
: `Droid CLI exited unexpectedly with code ${code}`;
|
||||
endStreamWithError(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Start inactivity timer after writing user message
|
||||
resetInactivityTimer();
|
||||
|
||||
// Process NDJSON lines from stdout using event-based callback
|
||||
// NOTE: Using 'line' event instead of `for await` because the async
|
||||
// iterator batches lines, breaking real-time streaming to pi.
|
||||
rl.on("line", (line: string) => {
|
||||
if (!firstLineReceived) {
|
||||
firstLineReceived = true;
|
||||
debugLog("first stdout line received from Droid CLI");
|
||||
}
|
||||
if (broken) return; // Guard: ignore buffered lines after break-early
|
||||
|
||||
// Reset inactivity timer on each line of output
|
||||
resetInactivityTimer();
|
||||
|
||||
const msg = parseLine(line);
|
||||
if (!msg) return;
|
||||
|
||||
if (msg.type === "stream_event") {
|
||||
// Only forward top-level events to pi's event bridge.
|
||||
// Sub-agent events (parent_tool_use_id !== null) are internal to the CLI.
|
||||
const isTopLevel = !msg.parent_tool_use_id;
|
||||
if (isTopLevel) {
|
||||
bridge.handleEvent(msg.event);
|
||||
}
|
||||
|
||||
// Track tool_use blocks for break-early decision (top-level only)
|
||||
if (
|
||||
isTopLevel &&
|
||||
msg.event.type === "content_block_start" &&
|
||||
msg.event.content_block?.type === "tool_use"
|
||||
) {
|
||||
const toolName = msg.event.content_block.name;
|
||||
if (toolName) {
|
||||
const piKnownTool = isPiKnownDroidTool(toolName);
|
||||
debugLog(
|
||||
`top-level tool_use seen: ${toolName} (piKnown=${piKnownTool ? "yes" : "no"})`,
|
||||
);
|
||||
if (piKnownTool) {
|
||||
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
|
||||
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
|
||||
sawBuiltInOrCustomTool = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Break-early at message_stop: kill subprocess before CLI auto-executes tools
|
||||
// Only on top-level message_stop — sub-agent message_stop is internal
|
||||
if (
|
||||
isTopLevel &&
|
||||
msg.event.type === "message_stop" &&
|
||||
sawBuiltInOrCustomTool
|
||||
) {
|
||||
debugLog("break-early triggered at message_stop after pi-known tool_use");
|
||||
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
|
||||
clearTimeout(inactivityTimer);
|
||||
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them.
|
||||
forceKillProcess(proc!);
|
||||
rl.close();
|
||||
return; // Don't process further -- done event already pushed by event bridge
|
||||
}
|
||||
} else if (msg.type === "control_request") {
|
||||
debugLog(
|
||||
`unexpected control_request received (stdin already closed): ${msg.request_id}`,
|
||||
);
|
||||
} else if (msg.type === "result") {
|
||||
if (msg.subtype === "error") {
|
||||
endStreamWithError(msg.error ?? "Unknown error from Droid CLI");
|
||||
}
|
||||
// For both success and error: clean up the subprocess
|
||||
clearTimeout(inactivityTimer);
|
||||
cleanupProcess(proc!);
|
||||
rl.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for readline to close (result received or process ended)
|
||||
await new Promise<void>((resolve) => {
|
||||
rl.on("close", resolve);
|
||||
});
|
||||
|
||||
// Push done event after readline closes (async). Pushing synchronously
|
||||
// inside handleMessageStop prevents pi from executing tools.
|
||||
// Guard with streamEnded to avoid pushing done after an error was already pushed.
|
||||
if (!streamEnded) {
|
||||
const output = bridge.getOutput();
|
||||
const contentEvents = output.content || [];
|
||||
|
||||
if (contentEvents.length === 0) {
|
||||
console.warn(
|
||||
`[droid-cli] Droid CLI closed without content events (model=${model.id}, sessionId=${options?.sessionId ?? "none"})`,
|
||||
);
|
||||
}
|
||||
|
||||
// If stopReason is toolUse but there are no pi-known tool calls in content,
|
||||
// it means only user MCP tools were called (filtered by event bridge).
|
||||
// Override to "stop" so pi doesn't try to execute non-existent tools.
|
||||
const piToolCalls = (output.content || []).filter(
|
||||
(c: TextContent | ThinkingContent | ToolCall) => c.type === "toolCall",
|
||||
);
|
||||
const effectiveReason =
|
||||
output.stopReason === "toolUse" && piToolCalls.length === 0
|
||||
? "stop"
|
||||
: output.stopReason;
|
||||
|
||||
streamEnded = true;
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason:
|
||||
effectiveReason === "toolUse"
|
||||
? "toolUse"
|
||||
: effectiveReason === "length"
|
||||
? "length"
|
||||
: "stop",
|
||||
message: { ...output, stopReason: effectiveReason },
|
||||
});
|
||||
stream.end();
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
// Push a "done" event with a text error so pi gets a valid AssistantMessage.
|
||||
// Pushing type:"error" would require an AssistantMessage in the error field,
|
||||
// but we don't have a full AssistantMessage here.
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: `Error: ${errMsg}` }],
|
||||
api: "droid-cli",
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "stop" as const,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
stream.end();
|
||||
} finally {
|
||||
// Clean up abort listener
|
||||
if (options?.signal && abortHandler) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
cleanupSystemPromptFile();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
37
packages/droid-cli/src/stream-parser.ts
Normal file
37
packages/droid-cli/src/stream-parser.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { NdjsonMessage } from "./types";
|
||||
|
||||
/**
|
||||
* Parse a single NDJSON line from Droid CLI stdout into a typed message.
|
||||
*
|
||||
* This function is deliberately resilient -- it never throws. Debug noise,
|
||||
* empty lines, and malformed JSON all return null so the streaming pipeline
|
||||
* can safely skip them and continue processing.
|
||||
*/
|
||||
export function parseLine(line: string): NdjsonMessage | null {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip non-JSON lines (debug output like "[SandboxDebug] ...")
|
||||
if (!trimmed.startsWith("{")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
console.error("Failed to parse NDJSON line:", trimmed);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate that the parsed result is a non-null object (not array, not primitive)
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as NdjsonMessage;
|
||||
}
|
||||
83
packages/droid-cli/src/thinking-config.ts
Normal file
83
packages/droid-cli/src/thinking-config.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Thinking effort configuration for mapping pi's ThinkingLevel to Droid CLI --effort flags.
|
||||
*
|
||||
* Maps pi's reasoning levels (minimal/low/medium/high/xhigh) to the CLI's effort
|
||||
* levels (low/medium/high/max). Opus models get an elevated mapping where medium
|
||||
* becomes high and high becomes max, leveraging their superior reasoning capability.
|
||||
*
|
||||
* IMPORTANT: The CLI does NOT support --thinking-budget. Only --effort is supported.
|
||||
*/
|
||||
|
||||
import type { ThinkingLevel, ThinkingBudgets } from "@mariozechner/pi-ai";
|
||||
|
||||
/** CLI effort levels accepted by the --effort flag */
|
||||
export type CliEffortLevel = "low" | "medium" | "high" | "max";
|
||||
|
||||
/**
|
||||
* Standard model mapping: pi ThinkingLevel -> CLI effort.
|
||||
* Non-Opus models never receive "max" (would cause CLI error).
|
||||
*/
|
||||
const STANDARD_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "high", // non-Opus: silently downgrade (max not supported)
|
||||
};
|
||||
|
||||
/**
|
||||
* Opus model mapping: shifted up for elevated reasoning.
|
||||
* Opus models get max capability at high/xhigh levels.
|
||||
*/
|
||||
const OPUS_EFFORT_MAP: Record<ThinkingLevel, CliEffortLevel> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "high", // shifted: standard high
|
||||
high: "max", // shifted: maximum capability
|
||||
xhigh: "max", // Opus gets max
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect whether a model ID refers to an Opus model.
|
||||
* Uses includes('opus') for forward-compatibility with future Opus versions.
|
||||
*
|
||||
* @param modelId - The model identifier string
|
||||
* @returns true if the model is an Opus variant
|
||||
*/
|
||||
export function isOpusModel(modelId: string): boolean {
|
||||
return modelId.includes("opus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map pi's ThinkingLevel to a CLI effort string.
|
||||
*
|
||||
* When reasoning is undefined, returns undefined so the --effort flag is omitted
|
||||
* entirely, letting the CLI use its default behavior. When thinkingBudgets are
|
||||
* provided, a console.warn is logged because the CLI only supports effort levels,
|
||||
* not token budgets.
|
||||
*
|
||||
* @param reasoning - Pi's thinking level (undefined = omit flag)
|
||||
* @param modelId - Model ID for Opus detection
|
||||
* @param thinkingBudgets - Custom budgets (logged as unsupported, not applied)
|
||||
* @returns CLI effort level string, or undefined if flag should be omitted
|
||||
*/
|
||||
export function mapThinkingEffort(
|
||||
reasoning?: ThinkingLevel,
|
||||
modelId?: string,
|
||||
thinkingBudgets?: ThinkingBudgets,
|
||||
): CliEffortLevel | undefined {
|
||||
if (reasoning === undefined) {
|
||||
return undefined; // omit --effort flag entirely
|
||||
}
|
||||
|
||||
if (thinkingBudgets && Object.keys(thinkingBudgets).length > 0) {
|
||||
console.warn(
|
||||
"[droid-cli] Custom thinkingBudgets are not supported with CLI subprocess. " +
|
||||
"The CLI uses --effort levels instead of token budgets. Budgets will be ignored.",
|
||||
);
|
||||
}
|
||||
|
||||
const isOpus = modelId ? isOpusModel(modelId) : false;
|
||||
const map = isOpus ? OPUS_EFFORT_MAP : STANDARD_EFFORT_MAP;
|
||||
return map[reasoning];
|
||||
}
|
||||
147
packages/droid-cli/src/tool-mapping.ts
Normal file
147
packages/droid-cli/src/tool-mapping.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Single-source-of-truth tool mapping table for bidirectional translation
|
||||
* between Droid CLI tool names/arguments and pi tool names/arguments.
|
||||
*
|
||||
* All lookup tables are derived from the TOOL_MAPPINGS array.
|
||||
* Unknown tools and arguments pass through unchanged.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A mapping entry for a single tool.
|
||||
* `args` maps Claude argument names to pi argument names (only renamed args).
|
||||
*/
|
||||
export interface ToolMapping {
|
||||
claude: string;
|
||||
pi: string;
|
||||
args: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical tool mapping table. All other lookup structures are derived from this.
|
||||
*/
|
||||
export const TOOL_MAPPINGS: ToolMapping[] = [
|
||||
{ claude: "Read", pi: "read", args: { file_path: "path" } },
|
||||
{ claude: "Write", pi: "write", args: { file_path: "path" } },
|
||||
{
|
||||
claude: "Edit",
|
||||
pi: "edit",
|
||||
args: { file_path: "path", old_string: "oldText", new_string: "newText" },
|
||||
},
|
||||
{ claude: "Bash", pi: "bash", args: {} },
|
||||
{ claude: "Grep", pi: "grep", args: { head_limit: "limit" } },
|
||||
{ claude: "Glob", pi: "find", args: {} },
|
||||
];
|
||||
|
||||
/** Prefix for custom pi tools exposed via MCP. */
|
||||
export const CUSTOM_TOOLS_MCP_PREFIX = "mcp__custom-tools__";
|
||||
|
||||
/** Set of built-in pi tool names derived from TOOL_MAPPINGS for O(1) lookup. */
|
||||
const BUILT_IN_PI_NAMES = new Set(TOOL_MAPPINGS.map((m) => m.pi));
|
||||
|
||||
/**
|
||||
* Check if a pi tool name is a custom tool (not one of the 6 built-in tools).
|
||||
* Used by prompt builder to decide whether to add MCP prefix in history replay.
|
||||
*/
|
||||
export function isCustomToolName(piName: string): boolean {
|
||||
return !BUILT_IN_PI_NAMES.has(piName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Claude tool name maps to a pi-known tool.
|
||||
* Returns true for built-in tools (Read, Write, etc.) and custom MCP tools (mcp__custom-tools__*).
|
||||
* Returns false for internal Claude Code tools (ToolSearch, Task, Agent, etc.) that pi cannot execute.
|
||||
* Used by event bridge to filter out internal tool calls.
|
||||
*/
|
||||
export function isPiKnownDroidTool(claudeName: string): boolean {
|
||||
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) return true;
|
||||
return claudeName.toLowerCase() in DROID_TO_PI_NAME;
|
||||
}
|
||||
|
||||
// Derived lookup maps
|
||||
|
||||
/** Lowercase Claude name -> pi name */
|
||||
const DROID_TO_PI_NAME: Record<string, string> = {};
|
||||
/** Pi name -> PascalCase Claude name */
|
||||
const PI_TO_DROID_NAME: Record<string, string> = {};
|
||||
/** Lowercase Claude name -> { claudeArgName: piArgName } */
|
||||
const DROID_TO_PI_ARGS: Record<string, Record<string, string>> = {};
|
||||
/** Pi name -> { piArgName: claudeArgName } */
|
||||
const PI_TO_DROID_ARGS: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const m of TOOL_MAPPINGS) {
|
||||
DROID_TO_PI_NAME[m.claude.toLowerCase()] = m.pi;
|
||||
PI_TO_DROID_NAME[m.pi] = m.claude;
|
||||
DROID_TO_PI_ARGS[m.claude.toLowerCase()] = m.args;
|
||||
|
||||
// Build reverse arg map
|
||||
const reverseArgs: Record<string, string> = {};
|
||||
for (const [from, to] of Object.entries(m.args)) {
|
||||
reverseArgs[to] = from;
|
||||
}
|
||||
PI_TO_DROID_ARGS[m.pi] = reverseArgs;
|
||||
}
|
||||
|
||||
// Handle glob/find asymmetry: pi's "glob" also maps back to Claude's "Glob"
|
||||
PI_TO_DROID_NAME["glob"] = "Glob";
|
||||
|
||||
/**
|
||||
* Map a Claude tool name to the corresponding pi tool name.
|
||||
* Strips the mcp__custom-tools__ prefix for custom tools first,
|
||||
* then falls back to case-insensitive built-in lookup.
|
||||
* Unknown tool names pass through unchanged.
|
||||
*/
|
||||
export function mapDroidToolNameToPi(claudeName: string): string {
|
||||
// Strip custom-tools MCP prefix first (e.g., "mcp__custom-tools__deploy" -> "deploy")
|
||||
if (claudeName.startsWith(CUSTOM_TOOLS_MCP_PREFIX)) {
|
||||
return claudeName.slice(CUSTOM_TOOLS_MCP_PREFIX.length);
|
||||
}
|
||||
// Standard built-in tool mapping (case-insensitive)
|
||||
return DROID_TO_PI_NAME[claudeName.toLowerCase()] ?? claudeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a pi tool name to the corresponding Claude tool name.
|
||||
* Direct lookup. Unknown tool names pass through unchanged.
|
||||
*/
|
||||
export function mapPiToolNameToDroid(piName: string): string {
|
||||
return PI_TO_DROID_NAME[piName] ?? piName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate Claude tool arguments to pi format.
|
||||
* Only known renamed arguments are translated; all others pass through unchanged.
|
||||
* This prevents dropping unknown/extra arguments (Pitfall 5).
|
||||
*/
|
||||
export function translateDroidArgsToPi(
|
||||
claudeToolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const renames = DROID_TO_PI_ARGS[claudeToolName.toLowerCase()];
|
||||
if (!renames || Object.keys(renames).length === 0) return args;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
const newKey = renames[key] ?? key;
|
||||
result[newKey] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate pi tool arguments to Claude format.
|
||||
* Only known renamed arguments are translated; all others pass through unchanged.
|
||||
*/
|
||||
export function translatePiArgsToDroid(
|
||||
piToolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const renames = PI_TO_DROID_ARGS[piToolName];
|
||||
if (!renames || Object.keys(renames).length === 0) return args;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
const newKey = renames[key] ?? key;
|
||||
result[newKey] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
87
packages/droid-cli/src/types.ts
Normal file
87
packages/droid-cli/src/types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Wire protocol types for Droid CLI stream-json NDJSON communication
|
||||
|
||||
// NDJSON message types from Droid CLI stdout
|
||||
|
||||
export interface ClaudeStreamEventMessage {
|
||||
type: "stream_event";
|
||||
event: ClaudeApiEvent;
|
||||
/** Present on sub-agent stream events; null/undefined for top-level events. */
|
||||
parent_tool_use_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeResultMessage {
|
||||
type: "result";
|
||||
subtype: "success" | "error";
|
||||
result?: string;
|
||||
error?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeSystemMessage {
|
||||
type: "system";
|
||||
subtype: string;
|
||||
session_id?: string;
|
||||
tools?: unknown[];
|
||||
}
|
||||
|
||||
export interface ClaudeControlRequest {
|
||||
type: "control_request";
|
||||
request_id: string;
|
||||
request: {
|
||||
subtype: "can_use_tool";
|
||||
tool_name: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export type NdjsonMessage =
|
||||
| ClaudeStreamEventMessage
|
||||
| ClaudeResultMessage
|
||||
| ClaudeSystemMessage
|
||||
| ClaudeControlRequest;
|
||||
|
||||
// Claude API event types (inside stream_event wrapper)
|
||||
|
||||
export interface ClaudeApiEvent {
|
||||
type: string; // message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop
|
||||
index?: number;
|
||||
message?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown[];
|
||||
model?: string;
|
||||
usage?: ClaudeUsage;
|
||||
};
|
||||
content_block?: {
|
||||
type: string; // "text", "tool_use", "thinking"
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: string;
|
||||
};
|
||||
delta?: {
|
||||
type?: string; // "text_delta", "input_json_delta", "thinking_delta", "signature_delta"
|
||||
text?: string;
|
||||
partial_json?: string;
|
||||
thinking?: string;
|
||||
signature?: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
usage?: ClaudeUsage;
|
||||
}
|
||||
|
||||
export interface ClaudeUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
}
|
||||
|
||||
// Content block tracking during stream processing
|
||||
|
||||
export interface TrackedContentBlock {
|
||||
type: "text" | "thinking";
|
||||
text: string;
|
||||
index: number; // Claude's content_block index
|
||||
}
|
||||
Reference in New Issue
Block a user