diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts new file mode 100644 index 0000000000..44c3ded2b2 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + acpxCapabilities, + acpxDriverDescriptor, + validateAcpxDriverConfig, +} from "./driver-profile.js"; + +describe("ACPX driver profile", () => { + it.each([ + ["codex", "available"], + ["claude", "available"], + ["pi", "unsupported"], + ] as const)( + "advertises structured plans for %s as %s", + (agent, availability) => { + const plan = acpxCapabilities(agent).typedEventFamilies?.find( + (family) => family.family === "plan", + ); + expect(plan).toMatchObject({ + availability, + detailLevel: availability === "available" ? "structured" : "summary", + }); + }, + ); + + it("describes only implemented ACPX capability boundaries", () => { + expect(acpxDriverDescriptor("claude")).toMatchObject({ + kind: "acpx_runtime", + displayName: "Claude via ACPX", + version: "0.13.1", + protocolVersion: "acp/v1", + runtimeContextCapabilities: { + instructions: "native", + skills: "native", + mcp: "native", + }, + capabilities: { + resume: true, + steering: false, + interruption: true, + dynamicTools: true, + runtimeRequestResolution: true, + }, + }); + }); + + it.each([ + ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], + ["claude", "claude-sonnet-5"], + ["codex", "gpt-5.6-sol"], + ] as const)("accepts the exact qualified %s model", (agent, model) => { + expect(validateAcpxDriverConfig({ agent, model })).toEqual({ + ok: true, + config: { agent, model, permissionMode: "approve-all" }, + issues: [], + }); + }); + + it("fails closed for unknown fields and unqualified settings", () => { + expect(validateAcpxDriverConfig(null)).toMatchObject({ + ok: false, + issues: [{ code: "invalid_config" }], + }); + expect( + validateAcpxDriverConfig({ + agent: "codex", + model: "gpt-5.6-sol", + command: "/tmp/arbitrary-provider", + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "command", code: "unknown_field" }], + }); + expect( + validateAcpxDriverConfig({ agent: "codex", model: "other" }), + ).toMatchObject({ + ok: false, + issues: [{ path: "model", code: "invalid_model" }], + }); + expect( + validateAcpxDriverConfig({ + agent: "claude", + model: "claude-sonnet-5", + permissionMode: "unrestricted", + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "permissionMode", code: "invalid_permission_mode" }], + }); + for (const permissionMode of ["", 42, undefined]) { + expect( + validateAcpxDriverConfig({ + agent: "claude", + model: "claude-sonnet-5", + permissionMode, + }), + ).toMatchObject({ + ok: false, + issues: [{ path: "permissionMode", code: "invalid_permission_mode" }], + }); + } + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts new file mode 100644 index 0000000000..98abd458dc --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/driver-profile.ts @@ -0,0 +1,165 @@ +import type { + HarnessDriverConfigValidation, + HarnessDriverDescriptor, +} from "../../contracts/harness-driver.js"; +import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js"; +import type { NativeSessionCapabilities } from "../../contracts/types.js"; +import { providerFamilyCapabilities } from "../../provider-events.js"; +import { + ACPX_DRIVER_KIND, + QUALIFIED_ACPX_VERSION, + resolveQualifiedAcpxProfile, + type QualifiedAcpxAgent, +} from "./qualified-profiles.js"; + +const ACPX_AGENTS = ["pi", "claude", "codex"] as const; +const ACPX_PERMISSION_MODES = [ + "approve-all", + "approve-reads", + "deny-all", +] as const; +const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode"]); + +export interface ValidatedAcpxDriverConfig extends Record { + agent: QualifiedAcpxAgent; + model: string; + permissionMode: NativeAcpxPermissionMode; +} + +export function acpxCapabilities( + agent: QualifiedAcpxAgent, +): NativeSessionCapabilities { + return { + resume: true, + typedEvents: true, + typedEventFamilies: providerFamilyCapabilities({ + plan: agent === "pi" ? "unsupported" : "available", + tool_execution: "available", + model_identity: "available", + review: "available", + provider_notice: "available", + artifact: "policy_disabled", + }), + steering: false, + interruption: true, + structuredResult: true, + read: true, + reconciliation: true, + usage: true, + dynamicTools: true, + runtimeRequestResolution: true, + runtimeRequestHandoff: true, + goals: false, + threadLineage: false, + unsupported: ["steering", "goals", "threadLineage"], + }; +} + +export function acpxDriverDescriptor( + agent: QualifiedAcpxAgent, +): HarnessDriverDescriptor { + return { + kind: ACPX_DRIVER_KIND, + displayName: `${displayAgent(agent)} via ACPX`, + version: QUALIFIED_ACPX_VERSION, + protocolVersion: "acp/v1", + runtimeContextCapabilities: { + instructions: "native", + skills: "native", + mcp: "native", + }, + capabilities: acpxCapabilities(agent), + }; +} + +export function validateAcpxDriverConfig( + value: unknown, +): HarnessDriverConfigValidation { + const config = record(value); + if (config === null) { + return invalid("", "invalid_config", "ACPX config must be an object."); + } + const unknownField = Object.keys(config).find( + (field) => !ACPX_CONFIG_FIELDS.has(field), + ); + if (unknownField !== undefined) { + return invalid( + unknownField, + "unknown_field", + `ACPX config does not support ${unknownField}.`, + ); + } + + const agent = text(config.agent); + if (!isAcpxAgent(agent)) { + return invalid( + "agent", + "invalid_agent", + "ACPX agent must be pi, claude, or codex.", + ); + } + const model = text(config.model); + try { + resolveQualifiedAcpxProfile(agent, model); + } catch (error) { + return invalid("model", "invalid_model", safeErrorMessage(error)); + } + const permissionMode = Object.prototype.hasOwnProperty.call( + config, + "permissionMode", + ) + ? text(config.permissionMode) + : "approve-all"; + if (!isPermissionMode(permissionMode)) { + return invalid( + "permissionMode", + "invalid_permission_mode", + "ACPX permission mode must be approve-all, approve-reads, or deny-all.", + ); + } + + const validated: ValidatedAcpxDriverConfig = { + agent, + model, + permissionMode, + }; + return { ok: true, config: validated, issues: [] }; +} + +function invalid( + path: string, + code: string, + message: string, +): HarnessDriverConfigValidation { + return { ok: false, config: null, issues: [{ path, code, message }] }; +} + +function isAcpxAgent(value: string): value is QualifiedAcpxAgent { + return (ACPX_AGENTS as readonly string[]).includes(value); +} + +function isPermissionMode(value: string): value is NativeAcpxPermissionMode { + return (ACPX_PERMISSION_MODES as readonly string[]).includes(value); +} + +function displayAgent(agent: QualifiedAcpxAgent): string { + if (agent === "pi") return "Pi"; + if (agent === "claude") return "Claude"; + return "Codex"; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function safeErrorMessage(error: unknown): string { + return error instanceof Error + ? error.message.slice(0, 1_000) + : "Invalid model."; +}