feat: add Pi Coding Agent backend runtime, update dashboard UI
This commit is contained in:
@@ -4,6 +4,7 @@ import { ClaudeCodeBackend } from "../../src/backends/claude-backend.js";
|
||||
import { CodexBackend } from "../../src/backends/codex-backend.js";
|
||||
import { GeminiBackend } from "../../src/backends/gemini-backend.js";
|
||||
import { OpenCodeBackend } from "../../src/backends/opencode-backend.js";
|
||||
import { PiBackend } from "../../src/backends/pi-backend.js";
|
||||
import type { BackendAdapterConfig } from "../../src/backends/types.js";
|
||||
|
||||
// ── Shared arbitraries ──────────────────────────────────────────────
|
||||
@@ -151,6 +152,35 @@ describe("Property 5: Session resume args across backends", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi: --session <id> --continue when session provided, absent otherwise", () => {
|
||||
it("includes --session <id> --continue when session ID is provided", () => {
|
||||
fc.assert(
|
||||
fc.property(nonEmptyString, nonEmptyString, sessionId, (prompt, sysPr, sid) => {
|
||||
const backend = new PiBackend(makeConfig());
|
||||
const args = backend.buildArgs(prompt, sysPr, sid);
|
||||
const sessionIdx = args.indexOf("--session");
|
||||
return (
|
||||
sessionIdx !== -1 &&
|
||||
args[sessionIdx + 1] === sid &&
|
||||
args.includes("--continue")
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include --session or --continue when no session ID is provided", () => {
|
||||
fc.assert(
|
||||
fc.property(nonEmptyString, nonEmptyString, (prompt, sysPr) => {
|
||||
const backend = new PiBackend(makeConfig());
|
||||
const args = backend.buildArgs(prompt, sysPr);
|
||||
return !args.includes("--session") && !args.includes("--continue");
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -229,6 +259,24 @@ describe("Property 6: Output parsing extracts correct fields", () => {
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("Pi: parses NDJSON with result type and session_id", () => {
|
||||
fc.assert(
|
||||
fc.property(responseText, sessionId, (text, sid) => {
|
||||
const backend = new PiBackend(makeConfig());
|
||||
const lines = [
|
||||
JSON.stringify({ type: "result", result: text, session_id: sid }),
|
||||
].join("\n");
|
||||
const result = backend.parseOutput(lines);
|
||||
return (
|
||||
result.isError === false &&
|
||||
result.responseText === text &&
|
||||
result.sessionId === sid
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
@@ -252,6 +300,7 @@ const backendErrorPrefixes: Record<string, string> = {
|
||||
codex: "Codex CLI error",
|
||||
gemini: "Gemini CLI error",
|
||||
opencode: "OpenCode CLI error",
|
||||
pi: "Pi CLI error",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -272,7 +321,7 @@ function simulateErrorResult(
|
||||
}
|
||||
|
||||
describe("Property 8: Non-zero exit code produces error result", () => {
|
||||
const backendNames = ["claude", "codex", "gemini", "opencode"] as const;
|
||||
const backendNames = ["claude", "codex", "gemini", "opencode", "pi"] as const;
|
||||
|
||||
it("for any backend, non-zero exit code and stderr, result has isError=true and responseText contains stderr", () => {
|
||||
fc.assert(
|
||||
|
||||
143
tests/property/pi-backend.property.test.ts
Normal file
143
tests/property/pi-backend.property.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it } from "vitest";
|
||||
import fc from "fast-check";
|
||||
import { PiBackend } from "../../src/backends/pi-backend.js";
|
||||
import type { BackendAdapterConfig } from "../../src/backends/types.js";
|
||||
|
||||
// Feature: multi-cli-backend, Property: Pi backend required flags
|
||||
// Validates Pi-specific CLI argument construction
|
||||
|
||||
/**
|
||||
* Arbitrary for non-empty strings that won't break CLI arg parsing.
|
||||
*/
|
||||
const nonEmptyString = fc.string({ minLength: 1, maxLength: 200 });
|
||||
|
||||
/**
|
||||
* Arbitrary for model strings (provider/model format).
|
||||
*/
|
||||
const modelString = fc.stringMatching(/^[a-z]{1,20}\/[a-z0-9-]{1,40}$/);
|
||||
|
||||
function createBackend(model?: string): PiBackend {
|
||||
const config: BackendAdapterConfig = {
|
||||
cliPath: "pi",
|
||||
workingDir: "/tmp",
|
||||
queryTimeoutMs: 60000,
|
||||
allowedTools: [],
|
||||
maxTurns: 25,
|
||||
model,
|
||||
};
|
||||
return new PiBackend(config);
|
||||
}
|
||||
|
||||
describe("Pi backend required flags", () => {
|
||||
it("generated args always contain -p for print mode", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
(prompt) => {
|
||||
const backend = createBackend();
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
return args.includes("-p");
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("generated args always contain --mode json", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
(prompt) => {
|
||||
const backend = createBackend();
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
const modeIndex = args.indexOf("--mode");
|
||||
return modeIndex !== -1 && args[modeIndex + 1] === "json";
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("generated args contain --model when a model is configured", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
modelString,
|
||||
(prompt, model) => {
|
||||
const backend = createBackend(model);
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
const modelIndex = args.indexOf("--model");
|
||||
return modelIndex !== -1 && args[modelIndex + 1] === model;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("generated args do not contain --model when no model is configured", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
(prompt) => {
|
||||
const backend = createBackend(undefined);
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
return !args.includes("--model");
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("generated args contain --no-session for headless usage", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
(prompt) => {
|
||||
const backend = createBackend();
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
return args.includes("--no-session");
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("generated args contain --no-extensions, --no-skills, --no-themes for deterministic runs", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
(prompt) => {
|
||||
const backend = createBackend();
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
return (
|
||||
args.includes("--no-extensions") &&
|
||||
args.includes("--no-skills") &&
|
||||
args.includes("--no-themes")
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("prompt is always the last argument", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
nonEmptyString,
|
||||
(prompt) => {
|
||||
const backend = createBackend();
|
||||
const args = backend.buildArgs(prompt);
|
||||
|
||||
return args[args.length - 1] === prompt;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { ClaudeCodeBackend } from "../../src/backends/claude-backend.js";
|
||||
import { CodexBackend } from "../../src/backends/codex-backend.js";
|
||||
import { GeminiBackend } from "../../src/backends/gemini-backend.js";
|
||||
import { OpenCodeBackend } from "../../src/backends/opencode-backend.js";
|
||||
import { PiBackend } from "../../src/backends/pi-backend.js";
|
||||
import { createBackend } from "../../src/backends/registry.js";
|
||||
import { AgentRuntime, mapBackendEventResult } from "../../src/agent-runtime.js";
|
||||
import { SessionManager } from "../../src/session-manager.js";
|
||||
@@ -21,7 +22,7 @@ const defaultConfig: BackendAdapterConfig = {
|
||||
// ─── 11.1 validate() method tests ───────────────────────────────────────────
|
||||
|
||||
describe("11.1 Backend validate() method", () => {
|
||||
const backends = ["claude", "codex", "gemini", "opencode"] as const;
|
||||
const backends = ["claude", "codex", "gemini", "opencode", "pi"] as const;
|
||||
|
||||
for (const name of backends) {
|
||||
describe(`${name} backend`, () => {
|
||||
@@ -57,7 +58,7 @@ describe("11.2 Timeout behavior", () => {
|
||||
// Create a helper script path that sleeps for 30 seconds
|
||||
const nodeExe = process.execPath;
|
||||
|
||||
const backends = ["claude", "codex", "gemini", "opencode"] as const;
|
||||
const backends = ["claude", "codex", "gemini", "opencode", "pi"] as const;
|
||||
|
||||
for (const name of backends) {
|
||||
it(`${name} backend should return timeout error when process exceeds queryTimeoutMs`, async () => {
|
||||
@@ -445,12 +446,19 @@ describe("11.5 Unsupported option warning for ALLOWED_TOOLS", () => {
|
||||
expect(args.join(" ")).not.toContain("--allowedTools");
|
||||
});
|
||||
|
||||
it("Pi backend should NOT include any allowedTools flags", () => {
|
||||
const backend = new PiBackend(toolFilteringConfig);
|
||||
const args = backend.buildArgs("prompt", "system prompt");
|
||||
expect(args.join(" ")).not.toContain("allowedTools");
|
||||
expect(args.join(" ")).not.toContain("--allowedTools");
|
||||
});
|
||||
|
||||
it("should log a warning when ALLOWED_TOOLS is set for a non-Claude backend", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => undefined as any);
|
||||
|
||||
// Simulate the check that should happen at startup:
|
||||
// When the backend doesn't support tool filtering but allowedTools is configured
|
||||
const backendsWithoutToolFiltering = ["codex", "gemini", "opencode"] as const;
|
||||
const backendsWithoutToolFiltering = ["codex", "gemini", "opencode", "pi"] as const;
|
||||
const allowedTools = ["Read", "Write", "Bash"];
|
||||
|
||||
for (const name of backendsWithoutToolFiltering) {
|
||||
@@ -464,7 +472,7 @@ describe("11.5 Unsupported option warning for ALLOWED_TOOLS", () => {
|
||||
}
|
||||
}
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(4);
|
||||
for (const name of backendsWithoutToolFiltering) {
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
{ backend: name, allowedTools },
|
||||
|
||||
Reference in New Issue
Block a user