diff --git a/docs/integrations/cli-agents.mdx b/docs/integrations/cli-agents.mdx index 93dc01dd..dfabbe7f 100644 --- a/docs/integrations/cli-agents.mdx +++ b/docs/integrations/cli-agents.mdx @@ -1,16 +1,24 @@ --- title: CLI Agents -description: Run external AI CLI tools (Claude Code, Codex, Gemini CLI) as drop-in Smithers agents that implement the AI SDK agent interface. +description: Run external AI CLI tools (Claude Code, Codex, Gemini CLI, PI) as drop-in Smithers agents that implement the AI SDK agent interface. --- -Smithers ships three CLI-backed agent classes that wrap external AI command-line tools. Each agent implements the AI SDK `Agent` interface, so they work as drop-in replacements anywhere you would use an AI SDK agent -- including in `` components. +Smithers ships CLI-backed agent classes that wrap external AI command-line tools. Each agent implements the AI SDK `Agent` interface, so they work as drop-in replacements anywhere you would use an AI SDK agent -- including in `` components. The agents spawn the corresponding CLI process, pass the prompt via arguments or stdin, capture the output, and return it in the standard `GenerateTextResult` format. ## Import ```ts -import { ClaudeCodeAgent, CodexAgent, GeminiAgent } from "smithers-orchestrator/agents/cli"; +import { + ClaudeCodeAgent, + CodexAgent, + GeminiAgent, + PiAgent, + type PiAgentOptions, + type PiExtensionUiRequest, + type PiExtensionUiResponse, +} from "smithers-orchestrator/agents/cli"; ``` ## Prerequisites @@ -22,15 +30,17 @@ Each agent requires its corresponding CLI tool installed and available in the sy | `ClaudeCodeAgent` | `claude` | [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) | | `CodexAgent` | `codex` | [OpenAI Codex CLI](https://github.com/openai/codex) | | `GeminiAgent` | `gemini` | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | +| `PiAgent` | `pi` | [PI Coding Agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | ## Quick Start ```ts -import { ClaudeCodeAgent, CodexAgent, GeminiAgent } from "smithers-orchestrator/agents/cli"; +import { ClaudeCodeAgent, CodexAgent, GeminiAgent, PiAgent } from "smithers-orchestrator/agents/cli"; const claude = new ClaudeCodeAgent({ model: "claude-sonnet-4-20250514" }); const codex = new CodexAgent({ model: "gpt-4.1" }); const gemini = new GeminiAgent({ model: "gemini-2.5-pro" }); +const pi = new PiAgent({ provider: "openai", model: "gpt-5.2-codex" }); ``` Use them in workflows like any other agent: @@ -45,7 +55,7 @@ Use them in workflows like any other agent: ## Base Options -All three agent classes share a common set of base options: +All CLI agent classes share a common set of base options: ```ts type BaseCliAgentOptions = { @@ -253,6 +263,84 @@ type GeminiAgentOptions = BaseCliAgentOptions & { --- +## PiAgent + +Wraps the `pi` CLI. + +```ts +const pi = new PiAgent({ + provider: "openai", + model: "gpt-5.2-codex", + mode: "text", + noSession: true, +}); +``` + +### PI-Specific Options + +```ts +type PiAgentOptions = BaseCliAgentOptions & { + provider?: string; + model?: string; + apiKey?: string; + systemPrompt?: string; + appendSystemPrompt?: string; + mode?: "text" | "json" | "rpc"; + print?: boolean; + continue?: boolean; + resume?: boolean; + session?: string; + sessionDir?: string; + noSession?: boolean; + models?: string | string[]; + listModels?: boolean | string; + tools?: string[]; + noTools?: boolean; + extension?: string[]; + noExtensions?: boolean; + skill?: string[]; + noSkills?: boolean; + promptTemplate?: string[]; + noPromptTemplates?: boolean; + theme?: string[]; + noThemes?: boolean; + thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + export?: string; + files?: string[]; + verbose?: boolean; + onExtensionUiRequest?: (request: PiExtensionUiRequest) => + | Promise + | PiExtensionUiResponse + | null; +}; +``` + +**Key options:** + +| Option | Description | +|---|---| +| `provider` | PI provider name passed to `--provider` | +| `model` | PI model passed to `--model` | +| `apiKey` | Passed to `--api-key` (visible in process listings; prefer PI env/config when possible) | +| `mode` | PI mode: `text`, `json`, or `rpc` | +| `print` | Force print mode (`--print`) in text mode only | +| `continue` / `resume` / `session` | Session continuation controls (`--continue`, `--resume`, `--session`) | +| `sessionDir` | Custom session directory (`--session-dir`) | +| `models` / `listModels` | Scoped model patterns and model listing (`--models`, `--list-models`) | +| `extension` | Load extension path(s) | +| `skill` | Load skill path(s) | +| `promptTemplate` | Load prompt template path(s) | +| `theme` | Load theme path(s) | +| `tools` / `noTools` | Enable specific tools or disable built-ins | +| `export` | Export session HTML (`--export`) | +| `files` | File args passed as `@path` (text/json modes only) | +| `onExtensionUiRequest` | RPC-only handler for extension UI requests | +| `noSession` | Disable session persistence (defaults to `true` unless session flags are set) | + +**Input handling:** In `text`/`json` modes, the prompt is passed as a positional PI message argument and `files` are emitted as `@path` arguments. In `rpc` mode, the prompt is sent as a JSON `prompt` command over stdin (file args are not supported). Text mode defaults to `--print` and omits `--mode`, while `json`/`rpc` modes set `--mode` and do not pass `--print`. + +--- + ## Agent Interface All CLI agents implement the AI SDK `Agent` interface with two methods: diff --git a/docs/integrations/pi-integration.mdx b/docs/integrations/pi-integration.mdx new file mode 100644 index 00000000..ae447be0 --- /dev/null +++ b/docs/integrations/pi-integration.mdx @@ -0,0 +1,87 @@ +--- +title: PI Integration +description: Use PI as a Smithers workflow CLI backend and understand how PI extensibility composes with Smithers declarative orchestration. +--- + +This guide explains how PI and Smithers fit together for workflow execution in this repo. Chat-provider UI integration is out of scope here. + +## Why Combine PI and Smithers + +- Smithers gives deterministic orchestration: workflow graph, approvals, retries, and durable run data. +- PI gives adaptive agent capabilities: providers/models, extensions, skills, prompt templates, and package ecosystem. + +Use both together when you want deterministic execution with flexible agent behavior. + +## Integration Modes + +### 1) PI as Workflow Agent (`PiAgent`) + +Use `PiAgent` in workflow tasks like any other CLI-backed agent: + +```tsx +import { PiAgent } from "smithers-orchestrator/agents/cli"; + +const pi = new PiAgent({ + provider: "openai", + model: "gpt-5.2-codex", + mode: "text", +}); + + + {`Implement feature X and explain tradeoffs.`} + +``` + +`PiAgent` supports common PI CLI flags including provider/model, tools, extension paths, skill paths, prompt templates, themes, and session flags. Text mode uses `--print` by default (no explicit `--mode` flag), while JSON/RPC modes set `--mode` and do not enable `--print`. PI’s extensibility surface is broader than the other CLI agents (extensions + skills + prompt templates + themes), so expose those explicitly when you need adaptive behavior. + +### 2) PI Server Client (`pi-plugin`) + +Use `pi-plugin` when you need to drive Smithers server APIs from a PI extension or another Node process: + +```ts +import { runWorkflow, approve, streamEvents } from "smithers-orchestrator/pi-plugin"; +``` + +The client is a thin HTTP wrapper for Smithers server endpoints and remains unchanged by PI agent support. + +### 3) PI Extensibility + Smithers Orchestration (Hybrid) + +Recommended split: + +- Keep orchestration in Smithers (``, ``, ``, ``). +- Run adaptive logic in PI tasks (extensions/skills/provider overrides). + +Pattern examples: + +1. PI skill-driven coding task in a Smithers ``. +2. PI extension command that starts or resumes Smithers workflows via server API or pi-plugin. +3. Smithers workflow output persisted to SQLite and consumed by later PI-assisted tasks. + +## End-to-End Setup + +1. Install PI CLI and ensure it is on `PATH`. +2. Configure PI credentials/provider environment as required by your PI setup (prefer env/config over CLI args for API keys). +3. For workflows, instantiate `PiAgent` with explicit options. +4. For server-driven workflows, use `pi-plugin` to call Smithers server APIs. + +Quick sanity checks: + +```bash +pi --version +bun run test +``` + +## Design Guidance + +Use `PiAgent` task nodes when: + +- You need PI capabilities inside deterministic workflows. +- You want PI calls as explicit, auditable workflow steps. + +Use Smithers-native tasks/tools when: + +- You need strict reproducibility and narrow tool contracts. + +## Limitations and Expectations + +See `integrations/pi-support-matrix` for exact supported vs planned behavior. Chat-provider integration lives in host applications, not this repo. diff --git a/src/agents/cli.ts b/src/agents/cli.ts index 5a8da062..97265b35 100644 --- a/src/agents/cli.ts +++ b/src/agents/cli.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createInterface } from "node:readline"; import type { Agent, GenerateTextResult, @@ -119,6 +120,69 @@ type GeminiAgentOptions = BaseCliAgentOptions & { outputFormat?: "text" | "json" | "stream-json"; }; +export type PiExtensionUiRequest = { + type: "extension_ui_request"; + id: string; + method: string; + title?: string; + placeholder?: string; + [key: string]: unknown; +}; + +export type PiExtensionUiResponse = { + type: "extension_ui_response"; + id: string; + value?: string; + cancelled?: boolean; + [key: string]: unknown; +}; + +export type PiAgentOptions = BaseCliAgentOptions & { + provider?: string; + model?: string; + apiKey?: string; + systemPrompt?: string; + appendSystemPrompt?: string; + mode?: "text" | "json" | "rpc"; + print?: boolean; + continue?: boolean; + resume?: boolean; + session?: string; + sessionDir?: string; + noSession?: boolean; + models?: string | string[]; + listModels?: boolean | string; + tools?: string[]; + noTools?: boolean; + extension?: string[]; + noExtensions?: boolean; + skill?: string[]; + noSkills?: boolean; + promptTemplate?: string[]; + noPromptTemplates?: boolean; + theme?: string[]; + noThemes?: boolean; + thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + export?: string; + files?: string[]; + verbose?: boolean; + onExtensionUiRequest?: (request: PiExtensionUiRequest) => + | Promise + | PiExtensionUiResponse + | null; +}; + +type RunRpcCommandOptions = { + cwd: string; + env: Record; + prompt: string; + timeoutMs?: number; + signal?: AbortSignal; + maxOutputBytes?: number; + onStderr?: (chunk: string) => void; + onExtensionUiRequest?: PiAgentOptions["onExtensionUiRequest"]; +}; + type PromptParts = { prompt: string; systemFromMessages?: string; @@ -519,6 +583,201 @@ async function runCommand( }); } +async function runRpcCommand(command: string, args: string[], options: RunRpcCommandOptions): Promise<{ + text: string; + output: unknown; + stderr: string; + exitCode: number | null; + }> { + const { cwd, env, prompt, timeoutMs, signal, maxOutputBytes, onStderr, onExtensionUiRequest } = options; + return await new Promise((resolve, reject) => { + let stderr = ""; + let settled = false; + let exitCode: number | null = null; + let textDeltas = ""; + let finalMessage: unknown | null = null; + let promptResponseError: string | null = null; + + const child = spawn(command, args, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + const rl = createInterface({ input: child.stdout }); + + const handleError = (err: Error) => { + if (settled) return; + settled = true; + try { + rl.close(); + } catch { + // ignore + } + reject(err); + }; + + const finalize = (text: string, output: unknown) => { + if (settled) return; + settled = true; + try { + rl.close(); + } catch { + // ignore + } + resolve({ text, output, stderr, exitCode: child.exitCode }); + }; + + const terminateChild = () => { + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + const killTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + // ignore + } + }, 250); + child.once("close", () => clearTimeout(killTimer)); + }; + + const kill = (reason: string) => { + terminateChild(); + handleError(new Error(reason)); + }; + + let timer: ReturnType | undefined; + if (timeoutMs && Number.isFinite(timeoutMs)) { + timer = setTimeout(() => kill(`CLI timed out after ${timeoutMs}ms`), timeoutMs); + } + + if (signal) { + if (signal.aborted) { + kill("CLI aborted"); + } else { + signal.addEventListener("abort", () => kill("CLI aborted"), { once: true }); + } + } + + const maybeWriteExtensionResponse = async (request: PiExtensionUiRequest) => { + const needsResponse = ["select", "confirm", "input", "editor"].includes(request.method); + if (!needsResponse && !onExtensionUiRequest) return; + + let response = onExtensionUiRequest ? await onExtensionUiRequest(request) : null; + if (!response && needsResponse) { + response = { type: "extension_ui_response", id: request.id, cancelled: true }; + } + if (!response) return; + const normalized = { ...response, id: request.id, type: "extension_ui_response" } as PiExtensionUiResponse; + if (!child.stdin) { + handleError(new Error("Failed to send extension UI response: child stdin is not available")); + terminateChild(); + return; + } + child.stdin.write(`${JSON.stringify(normalized)}\n`); + }; + + const handleLine = async (line: string) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + if (!parsed || typeof parsed !== "object") return; + const event = parsed as Record; + const type = event.type; + if (type === "response" && event.command === "prompt" && event.success === false) { + const errorMessage = typeof event.error === "string" ? event.error : "PI RPC prompt failed"; + promptResponseError = errorMessage; + kill(errorMessage); + return; + } + if (type === "message_update") { + const assistantEvent = (event as any).assistantMessageEvent as { type?: string; delta?: string } | undefined; + if (assistantEvent?.type === "text_delta" && typeof assistantEvent.delta === "string") { + textDeltas += assistantEvent.delta; + } + } + if (type === "message_end") { + const message = (event as any).message as { role?: string; stopReason?: string; errorMessage?: string } | undefined; + if (message?.role === "assistant") { + finalMessage = (event as any).message; + if (message.stopReason === "error" || message.stopReason === "aborted") { + promptResponseError = message.errorMessage || `Request ${message.stopReason}`; + } + } + } + if (type === "turn_end") { + const message = (event as any).message as { role?: string; stopReason?: string; errorMessage?: string } | undefined; + if (message?.role === "assistant") { + finalMessage = (event as any).message ?? finalMessage; + if (message.stopReason === "error" || message.stopReason === "aborted") { + promptResponseError = message.errorMessage || `Request ${message.stopReason}`; + } + const extracted = finalMessage ? extractTextFromJsonValue(finalMessage) : undefined; + const text = extracted ?? textDeltas; + if (timer) clearTimeout(timer); + if (promptResponseError) { + handleError(new Error(promptResponseError)); + return; + } + finalize(text, finalMessage ?? text); + child.stdin?.end(); + terminateChild(); + } + } + if (type === "extension_ui_request") { + await maybeWriteExtensionResponse(event as PiExtensionUiRequest); + } + }; + + let lineQueue = Promise.resolve(); + rl.on("line", (line) => { + lineQueue = lineQueue.then(() => handleLine(line)).catch((err) => { + handleError(err instanceof Error ? err : new Error(String(err))); + }); + }); + + child.stderr?.on("data", (chunk) => { + const text = chunk.toString("utf8"); + stderr = truncateToBytes(stderr + text, maxOutputBytes); + onStderr?.(text); + }); + + child.on("error", (err) => { + if (timer) clearTimeout(timer); + handleError(err); + }); + + child.on("close", (code) => { + exitCode = code ?? null; + if (timer) clearTimeout(timer); + if (settled) return; + if (promptResponseError) { + handleError(new Error(promptResponseError)); + return; + } + if (code && code !== 0) { + handleError(new Error(stderr.trim() || `CLI exited with code ${code}`)); + return; + } + const text = finalMessage ? extractTextFromJsonValue(finalMessage) ?? textDeltas : textDeltas; + finalize(text ?? "", finalMessage ?? text ?? ""); + }); + + const promptPayload = { id: randomUUID(), type: "prompt", message: prompt }; + if (!child.stdin) { + handleError(new Error("Child process stdin is not available; cannot send prompt payload.")); + return; + } + child.stdin.write(`${JSON.stringify(promptPayload)}\n`); + }); + } + abstract class BaseCliAgent implements Agent { readonly version = "agent-v1" as const; readonly tools: Record = {}; @@ -933,3 +1192,165 @@ export class GeminiAgent extends BaseCliAgent { }; } } + +export class PiAgent extends BaseCliAgent { + private readonly opts: PiAgentOptions; + + constructor(opts: PiAgentOptions = {}) { + super(opts); + this.opts = opts; + } + + async generate(options: any): Promise> { + const { prompt, systemFromMessages } = extractPrompt(options); + const callTimeout = resolveTimeoutMs(options?.timeout, this.timeoutMs); + const cwd = this.cwd ?? getToolContext()?.rootDir ?? process.cwd(); + const env = { ...process.env, ...(this.env ?? {}) } as Record; + const combinedSystem = combineNonEmpty([this.systemPrompt, systemFromMessages]); + + const mode = this.opts.mode ?? "text"; + + if (mode === "rpc" && this.opts.files?.length) { + throw new Error("RPC mode does not support file arguments"); + } + + const args: string[] = []; + + // Mode handling: text uses --print (no --mode), json/rpc use --mode + if (mode === "text") { + if (this.opts.print !== false) args.push("--print"); + } else { + args.push("--mode", mode); + } + + pushFlag(args, "--provider", this.opts.provider); + pushFlag(args, "--model", this.opts.model ?? this.model); + pushFlag(args, "--api-key", this.opts.apiKey); + pushFlag(args, "--system-prompt", this.opts.systemPrompt); + + // Combine appendSystemPrompt with systemFromMessages + const appendParts = combineNonEmpty([this.opts.appendSystemPrompt, systemFromMessages]); + pushFlag(args, "--append-system-prompt", appendParts); + + if (this.opts.continue) args.push("--continue"); + if (this.opts.resume) args.push("--resume"); + pushFlag(args, "--session", this.opts.session); + pushFlag(args, "--session-dir", this.opts.sessionDir); + + // noSession defaults to true unless session flags are set + const hasSessionFlags = !!(this.opts.session || this.opts.sessionDir || this.opts.continue || this.opts.resume); + if (this.opts.noSession ?? (!hasSessionFlags)) { + args.push("--no-session"); + } + + if (this.opts.models) { + const modelsStr = Array.isArray(this.opts.models) ? this.opts.models.join(",") : this.opts.models; + args.push("--models", modelsStr); + } + if (this.opts.listModels !== undefined && this.opts.listModels !== false) { + if (typeof this.opts.listModels === "string") { + args.push("--list-models", this.opts.listModels); + } else { + args.push("--list-models"); + } + } + pushFlag(args, "--export", this.opts.export); + + if (this.opts.tools?.length) { + args.push("--tools", this.opts.tools.join(",")); + } + if (this.opts.noTools) args.push("--no-tools"); + + if (this.opts.extension) { + for (const ext of this.opts.extension) { + args.push("--extension", ext); + } + } + if (this.opts.noExtensions) args.push("--no-extensions"); + + if (this.opts.skill) { + for (const s of this.opts.skill) { + args.push("--skill", s); + } + } + if (this.opts.noSkills) args.push("--no-skills"); + + if (this.opts.promptTemplate) { + for (const pt of this.opts.promptTemplate) { + args.push("--prompt-template", pt); + } + } + if (this.opts.noPromptTemplates) args.push("--no-prompt-templates"); + + if (this.opts.theme) { + for (const t of this.opts.theme) { + args.push("--theme", t); + } + } + if (this.opts.noThemes) args.push("--no-themes"); + + pushFlag(args, "--thinking", this.opts.thinking); + if (this.opts.verbose) args.push("--verbose"); + if (this.extraArgs?.length) args.push(...this.extraArgs); + + if (mode !== "rpc") { + // File args as @path + if (this.opts.files) { + for (const f of this.opts.files) { + args.push(`@${f}`); + } + } + // Prompt as last positional arg + if (prompt) args.push(prompt); + + const result = await runCommand("pi", args, { + cwd, + env, + timeoutMs: callTimeout, + signal: options?.abortSignal, + maxOutputBytes: this.maxOutputBytes ?? getToolContext()?.maxOutputBytes, + onStdout: options?.onStdout, + onStderr: options?.onStderr, + }); + + if (result.exitCode && result.exitCode !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `CLI exited with code ${result.exitCode}`); + } + + const rawText = result.stdout.trim(); + const output = mode === "json" ? tryParseJson(rawText) : rawText; + return buildGenerateResult(rawText, output, this.opts.model ?? "pi"); + } + + // RPC mode + const rpcResult = await runRpcCommand("pi", args, { + cwd, + env, + prompt, + timeoutMs: callTimeout, + signal: options?.abortSignal, + maxOutputBytes: this.maxOutputBytes ?? getToolContext()?.maxOutputBytes, + onStderr: options?.onStderr, + onExtensionUiRequest: this.opts.onExtensionUiRequest, + }); + + return buildGenerateResult(rpcResult.text, rpcResult.output, this.opts.model ?? "pi"); + } + + protected async buildCommand(_params: { + prompt: string; + systemPrompt?: string; + cwd: string; + options: any; + }): Promise<{ + command: string; + args: string[]; + stdin?: string; + outputFormat?: string; + outputFile?: string; + cleanup?: () => Promise; + }> { + // PiAgent overrides generate() directly, so buildCommand is not used + throw new Error("PiAgent does not use buildCommand"); + } +} diff --git a/tests/pi-support.test.ts b/tests/pi-support.test.ts new file mode 100644 index 00000000..1fab364a --- /dev/null +++ b/tests/pi-support.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, test } from "bun:test"; + import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; + import { join } from "node:path"; + import { tmpdir } from "node:os"; + import { PiAgent } from "../src/agents/cli"; + + const originalPath = process.env.PATH ?? ""; + + async function makeFakePi(stdoutScript: string) { + const dir = await mkdtemp(join(tmpdir(), "smithers-pi-test-")); + const binPath = join(dir, "pi"); + const script = `#!/usr/bin/env node\n${stdoutScript}\n`; + await writeFile(binPath, script, "utf8"); + await chmod(binPath, 0o755); + return { dir, binPath }; + } + + afterEach(() => { + process.env.PATH = originalPath; + delete process.env.PI_ARGS_FILE; + delete process.env.PI_RESPONSE_FILE; + }); + + describe("PI CLI agent", () => { + test("PiAgent builds expected CLI arguments", async () => { + const argsFileDir = await mkdtemp(join(tmpdir(), "smithers-pi-args-")); + const argsFile = join(argsFileDir, "args.json"); + + const fake = await makeFakePi(` + const fs = require("node:fs"); + const args = process.argv.slice(2); + if (process.env.PI_ARGS_FILE) fs.writeFileSync(process.env.PI_ARGS_FILE, JSON.stringify(args), "utf8"); + process.stdout.write(JSON.stringify({ args }) + "\\n"); + `); + + try { + process.env.PATH = `${fake.dir}:${originalPath}`; + process.env.PI_ARGS_FILE = argsFile; + + const agent = new PiAgent({ + mode: "json", + continue: true, + resume: true, + provider: "openai", + model: "gpt-4o-mini", + apiKey: "pi-test-key", + systemPrompt: "Base system", + appendSystemPrompt: "Extra system", + session: "session.jsonl", + sessionDir: "/tmp/pi-sessions", + models: ["openai/*", "anthropic/*"], + listModels: "openai", + export: "session.html", + tools: ["read", "bash"], + extension: ["ext-a", "ext-b"], + skill: ["skill-a", "skill-b"], + promptTemplate: ["prompt-a", "prompt-b"], + theme: ["theme-a", "theme-b"], + files: ["prompt.md"], + thinking: "low", + verbose: true, + env: { PATH: process.env.PATH! }, + }); + + const result = await agent.generate({ + messages: [ + { role: "system", content: "System from messages" }, + { role: "user", content: "Hello from user" }, + ], + }); + + const payload = result.output as { args: string[] }; + expect(Array.isArray(payload.args)).toBe(true); + + expect(payload.args).toContain("--mode"); + expect(payload.args).toContain("json"); + expect(payload.args).toContain("--continue"); + expect(payload.args).toContain("--resume"); + expect(payload.args).toContain("--provider"); + expect(payload.args).toContain("openai"); + expect(payload.args).toContain("--api-key"); + expect(payload.args).toContain("pi-test-key"); + expect(payload.args).toContain("--system-prompt"); + expect(payload.args).toContain("Base system"); + expect(payload.args).toContain("--session"); + expect(payload.args).toContain("session.jsonl"); + expect(payload.args).toContain("--session-dir"); + expect(payload.args).toContain("/tmp/pi-sessions"); + expect(payload.args).not.toContain("--no-session"); + expect(payload.args).toContain("--list-models"); + const listIndex = payload.args.indexOf("--list-models"); + expect(payload.args[listIndex + 1]).toBe("openai"); + expect(payload.args).toContain("--export"); + expect(payload.args).toContain("session.html"); + expect(payload.args).toContain("--tools"); + expect(payload.args).toContain("read,bash"); + expect(payload.args).toContain("--extension"); + expect(payload.args).toContain("ext-a"); + expect(payload.args).toContain("ext-b"); + expect(payload.args.filter((arg) => arg === "--extension")).toHaveLength(2); + expect(payload.args).toContain("--skill"); + expect(payload.args).toContain("skill-a"); + expect(payload.args).toContain("skill-b"); + expect(payload.args.filter((arg) => arg === "--skill")).toHaveLength(2); + expect(payload.args).toContain("--prompt-template"); + expect(payload.args).toContain("prompt-a"); + expect(payload.args).toContain("prompt-b"); + expect(payload.args.filter((arg) => arg === "--prompt-template")).toHaveLength(2); + expect(payload.args).toContain("--theme"); + expect(payload.args).toContain("theme-a"); + expect(payload.args).toContain("theme-b"); + expect(payload.args.filter((arg) => arg === "--theme")).toHaveLength(2); + expect(payload.args).toContain("--thinking"); + expect(payload.args).toContain("low"); + expect(payload.args).toContain("--models"); + expect(payload.args).toContain("openai/*,anthropic/*"); + expect(payload.args).toContain("@prompt.md"); + expect(payload.args).toContain("--verbose"); + + const appendIndex = payload.args.indexOf("--append-system-prompt"); + expect(appendIndex).toBeGreaterThan(-1); + const appendValue = payload.args[appendIndex + 1]; + expect(appendValue).toContain("Extra system"); + expect(appendValue).toContain("System from messages"); + + const lastArg = payload.args[payload.args.length - 1]; + expect(lastArg).toContain("USER: Hello from user"); + + const capturedArgs = JSON.parse(await readFile(argsFile, "utf8")) as string[]; + expect(capturedArgs).toEqual(payload.args); + } finally { + await rm(fake.dir, { recursive: true, force: true }); + await rm(argsFileDir, { recursive: true, force: true }); + } + }); + + test("PiAgent RPC mode sends prompt and returns output", async () => { + const argsFileDir = await mkdtemp(join(tmpdir(), "smithers-pi-rpc-")); + const argsFile = join(argsFileDir, "prompt.json"); + + const fake = await makeFakePi(` + const fs = require("node:fs"); + let buffer = ""; + process.stdin.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const lines = buffer.split(/\\r?\\n/); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.type === "prompt") { + if (process.env.PI_ARGS_FILE) fs.writeFileSync(process.env.PI_ARGS_FILE, JSON.stringify(msg), "utf8"); + process.stdout.write(JSON.stringify({ type: "response", command: "prompt", success: true, id: msg.id }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Hello" } }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: [{ type: "text", text: "Hello" }], stopReason: "stop" } }) + "\\n"); + } + } + }); + `); + + try { + process.env.PATH = `${fake.dir}:${originalPath}`; + process.env.PI_ARGS_FILE = argsFile; + + const agent = new PiAgent({ + mode: "rpc", + model: "gpt-4o-mini", + env: { PATH: process.env.PATH! }, + }); + + const result = await agent.generate({ + messages: [{ role: "user", content: "Ping?" }], + }); + + expect(result.text).toBe("Hello"); + + const promptPayload = JSON.parse(await readFile(argsFile, "utf8")) as { type: string; message: string }; + expect(promptPayload.type).toBe("prompt"); + expect(promptPayload.message).toContain("USER: Ping?"); + } finally { + await rm(fake.dir, { recursive: true, force: true }); + await rm(argsFileDir, { recursive: true, force: true }); + } + }); + + test("PiAgent RPC mode handles extension UI requests", async () => { + const argsFileDir = await mkdtemp(join(tmpdir(), "smithers-pi-rpc-ui-")); + const argsFile = join(argsFileDir, "prompt.json"); + const responseFile = join(argsFileDir, "response.json"); + + const fake = await makeFakePi(` + const fs = require("node:fs"); + const readline = require("node:readline"); + const rl = readline.createInterface({ input: process.stdin }); + + rl.on("line", (line) => { + if (!line.trim()) return; + const msg = JSON.parse(line); + if (msg.type === "prompt") { + if (process.env.PI_ARGS_FILE) fs.writeFileSync(process.env.PI_ARGS_FILE, JSON.stringify(msg), "utf8"); + process.stdout.write(JSON.stringify({ type: "response", command: "prompt", success: true, id: msg.id }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "extension_ui_request", id: "req-1", method: "input", title: "Need input", placeholder: "Type here" }) + "\\n"); + } else if (msg.type === "extension_ui_response") { + if (process.env.PI_RESPONSE_FILE) fs.writeFileSync(process.env.PI_RESPONSE_FILE, JSON.stringify(msg), "utf8"); + process.stdout.write(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: [{ type: "text", text: "Done" }], stopReason: "stop" } }) + "\\n"); + process.exit(0); + } + }); + `); + + try { + process.env.PATH = `${fake.dir}:${originalPath}`; + process.env.PI_ARGS_FILE = argsFile; + process.env.PI_RESPONSE_FILE = responseFile; + + let requestSeen: { id: string; method: string } | null = null; + + const agent = new PiAgent({ + mode: "rpc", + model: "gpt-4o-mini", + env: { PATH: process.env.PATH! }, + onExtensionUiRequest: (request) => { + requestSeen = { id: request.id, method: request.method }; + return { type: "extension_ui_response", id: request.id, value: "Input value" }; + }, + }); + + const result = await agent.generate({ + messages: [{ role: "user", content: "Ping?" }], + }); + + expect(result.text).toBe("Done"); + expect((requestSeen as { id: string; method: string } | null)?.method).toBe("input"); + + const promptPayload = JSON.parse(await readFile(argsFile, "utf8")) as { type: string; message: string }; + expect(promptPayload.type).toBe("prompt"); + + const responsePayload = JSON.parse(await readFile(responseFile, "utf8")) as { type: string; value?: string }; + expect(responsePayload.type).toBe("extension_ui_response"); + expect(responsePayload.value).toBe("Input value"); + } finally { + await rm(fake.dir, { recursive: true, force: true }); + await rm(argsFileDir, { recursive: true, force: true }); + } + }); + + test("PiAgent throws when using file args in RPC mode", async () => { + const agent = new PiAgent({ + mode: "rpc", + files: ["README.md"], + }); + + await expect( + agent.generate({ + messages: [{ role: "user", content: "Ping?" }], + }) + ).rejects.toThrow(/RPC mode does not support file arguments/); + }); + + test("PiAgent surfaces stderr on non-zero exit", async () => { + const fake = await makeFakePi(` + process.stderr.write("pi failed for test\\n"); + process.exit(23); + `); + + try { + process.env.PATH = `${fake.dir}:${originalPath}`; + + const agent = new PiAgent({ + mode: "text", + model: "gemini-2.5-flash", + env: { PATH: process.env.PATH! }, + }); + + await expect( + agent.generate({ + messages: [{ role: "user", content: "trigger failure" }], + }) + ).rejects.toThrow(/pi failed for test/); + } finally { + await rm(fake.dir, { recursive: true, force: true }); + } + }); + });