diff --git a/Dockerfile b/Dockerfile index a6631b71f5..2610730d71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,6 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/skills-catalog/package.json packages/skills-catalog/ COPY packages/teams-catalog/package.json packages/teams-catalog/ -COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ diff --git a/cli/package.json b/cli/package.json index 34f4386115..08656b56cc 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,7 +37,6 @@ }, "dependencies": { "@clack/prompts": "^0.10.0", - "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 260a45d6a3..f30e32eafb 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -1,5 +1,4 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; -import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; @@ -19,11 +18,6 @@ const claudeLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printClaudeStreamEvent, }; -const acpxLocalCLIAdapter: CLIAdapterModule = { - type: "acpx_local", - formatStdoutEvent: printAcpxStreamEvent, -}; - const codexLocalCLIAdapter: CLIAdapterModule = { type: "codex_local", formatStdoutEvent: printCodexStreamEvent, @@ -76,7 +70,6 @@ const openclawGatewayCLIAdapter: CLIAdapterModule = { const adaptersByType = new Map( [ - acpxLocalCLIAdapter, claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 11582e9718..451853c352 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -195,14 +195,14 @@ PR CI now checks changed release-enabled package manifests against npm. That cat The first publish of a brand-new package still needs one human maintainer with npm write access. After that, trusted publishing can take over. -Example for `@paperclipai/adapter-acpx-local` from the repo root: +Example for a newly added public package from the repo root: ```bash # safe preview -pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local +pnpm run release:bootstrap-package -- @paperclipai/new-package # one-time first publish from an authenticated maintainer machine -pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456 +pnpm run release:bootstrap-package -- @paperclipai/new-package --publish --otp 123456 ``` The helper script: @@ -223,7 +223,7 @@ The helper now requires `--otp ` up front for `--publish`, so it fails bef After that first publish succeeds: -1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local` +1. open `https://www.npmjs.com/package/@paperclipai/new-package` 2. go to `Settings` → `Trusted publishing` 3. add repository `paperclipai/paperclip` 4. set workflow filename to `release.yml` diff --git a/docker/agent-runtime/Dockerfile.acpx b/docker/agent-runtime/Dockerfile.acpx deleted file mode 100644 index 965be1f092..0000000000 --- a/docker/agent-runtime/Dockerfile.acpx +++ /dev/null @@ -1,14 +0,0 @@ -# syntax=docker/dockerfile:1.6 -ARG BASE_TAG=dev -FROM paperclipai/agent-runtime-base:${BASE_TAG} - -USER root -# acpx is the ACPX wrapper that bridges to claude / codex backends. -# Verified npm package name: "acpx" (bin 'acpx' → dist/cli.js). -RUN npm install -g acpx@latest \ - && { chown -R 1000:1000 /usr/lib/node_modules || true; } - -USER 1000:1000 - -# Verify the CLI is on PATH for the shim's exec.LookPath -RUN command -v acpx >/dev/null 2>&1 || (echo "acpx not on PATH"; exit 1) diff --git a/docker/agent-runtime/README.md b/docker/agent-runtime/README.md index 5b5ce28aae..b2d35a26b2 100644 --- a/docker/agent-runtime/README.md +++ b/docker/agent-runtime/README.md @@ -10,7 +10,7 @@ Container images for running coding-agent harnesses in sandboxed environments (f - **`agent-runtime-codex`**: Extends base with `@openai/codex`. - **`agent-runtime-gemini`**: Extends base with `@google/gemini-cli` plus headless auth-mode settings. - **`agent-runtime-claude`**: Extends base with `@anthropic-ai/claude-code` (symlinked as `claude-code`). -- **`agent-runtime-acpx`** / **`agent-runtime-hermes`**: Dockerfiles included in the bake group, not in the default publish scope (hermes is a stub until a CLI package exists). +- **`agent-runtime-hermes`**: Dockerfile included in the bake group, not in the default publish scope (stub until a CLI package exists). ## Base Image Contents diff --git a/docker/agent-runtime/buildx-bake.hcl b/docker/agent-runtime/buildx-bake.hcl index e7d9f67ef7..a2afb935ac 100644 --- a/docker/agent-runtime/buildx-bake.hcl +++ b/docker/agent-runtime/buildx-bake.hcl @@ -1,5 +1,5 @@ group "default" { - targets = ["base", "claude", "codex", "gemini", "acpx", "opencode", "pi", "hermes"] + targets = ["base", "claude", "codex", "gemini", "opencode", "pi", "hermes"] } variable "VERSION" { default = "dev" } @@ -51,19 +51,6 @@ target "gemini" { } } -target "acpx" { - context = "." - dockerfile = "docker/agent-runtime/Dockerfile.acpx" - platforms = ["linux/amd64"] - tags = ["${REGISTRY}/agent-runtime-acpx:${VERSION}"] - args = { - BASE_TAG = "${VERSION}" - } - contexts = { - "paperclipai/agent-runtime-base:${VERSION}" = "target:base" - } -} - target "opencode" { context = "." dockerfile = "docker/agent-runtime/Dockerfile.opencode" diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 739c1c18ed..83c14e10ef 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -18,9 +18,8 @@ When a heartbeat fires, Paperclip: | Adapter | Type Key | Description | |---------|----------|-------------| -| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | -| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | -| ACPX Local | `acpx_local` | Runs Claude, Codex, or a custom ACP agent through ACPX with live structured event streaming | +| [Claude Code](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally, with a native ACP engine when available | +| [Codex](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally, with a native ACP engine when available | | [Gemini CLI](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | | OpenCode | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | Cursor | `cursor` | Runs Cursor in background mode | @@ -115,8 +114,8 @@ my-adapter/ ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, `acpx_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin -- **Need the richest live run feedback (especially for sandbox workers)?** Use `acpx_local` — see [Feedback granularity](#feedback-granularity) +- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, or install `droid_local` as an external plugin +- **Need the richest live run feedback?** Use `claude_local`, `codex_local`, or `gemini_local` with `adapterConfig.engine` set to `acp` when the execution environment satisfies the ACP prerequisites — see [Feedback granularity](#feedback-granularity) - **Need Hermes on another host or already running as a service?** Use `hermes_gateway` - **Need to run a script or command?** Use `process` - **Need to call a custom external service?** Use `http` @@ -128,11 +127,11 @@ Adapter choice determines how much structured, live detail a run's transcript ca Rough tiers, richest first: -1. **`acpx_local` — full structured event stream.** ACPX emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates. +1. **Native ACP engine (`claude_local`, `codex_local`, or `gemini_local` with `engine: "acp"`) — full structured event stream.** ACP emits a JSONL event per meaningful runtime moment: `acpx.session` (agent, mode, session identity), `acpx.status` (progress text plus context-window usage), `acpx.text_delta` (assistant/thinking token deltas), `acpx.tool_call` (tool title, call id, and status updates as the call progresses), `acpx.result` (stop reason summary), and `acpx.error` (code, message, retryability). The transcript renders these as live-updating message, thinking, tool, and status blocks, and repeated `acpx.tool_call` status updates fold into a single tool card instead of stacking duplicates. 2. **CLI wrappers (`claude_local`, `codex_local`, `cursor`, `opencode_local`, …).** These parse each CLI's own streaming JSON output. You get assistant text, tool calls/results, and a final usage/cost summary, but granularity is limited to what the CLI prints — some emit tool progress, others only call/finish pairs. 3. **Generic adapters (`process`, `http`).** Plain stdout/stderr lines with no structured transcript — you see raw output only. -**Recommendation:** for sandbox workers, prefer `acpx_local`. Sandbox run logs are streamed live, so the richer the event stream, the more useful the live transcript and status line are while a remote run is in flight. ACPX's status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally. +**Recommendation:** use the native ACP engine on `claude_local`, `codex_local`, or `gemini_local` when the selected execution environment supports it. Rich ACP status events (including context usage) and incremental tool-call updates give the closest thing to watching the agent work locally. ## UI Parser Contract diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 5eaf100488..adf2a237c1 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -39,6 +39,10 @@ "clean": "rm -rf dist", "typecheck": "tsc --noEmit" }, + "dependencies": { + "acpx": "^0.12.0", + "picocolors": "^1.1.1" + }, "devDependencies": { "@types/node": "^22.19.21", "typescript": "^5.7.3" diff --git a/packages/adapters/acpx-local/src/cli/format-event.ts b/packages/adapter-utils/src/acpx-engine/cli.ts similarity index 100% rename from packages/adapters/acpx-local/src/cli/format-event.ts rename to packages/adapter-utils/src/acpx-engine/cli.ts diff --git a/packages/adapter-utils/src/acpx-engine/constants.ts b/packages/adapter-utils/src/acpx-engine/constants.ts new file mode 100644 index 0000000000..86368f5f97 --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/constants.ts @@ -0,0 +1,21 @@ +export const DEFAULT_ACP_ENGINE_AGENT = "claude"; +export const DEFAULT_ACP_ENGINE_MODE = "persistent"; +export const DEFAULT_ACP_ENGINE_PERMISSION_MODE = "approve-all"; +export const DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS = "deny"; +export const DEFAULT_ACP_ENGINE_TIMEOUT_SEC = 0; +export const DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS = 0; + +export const ACPX_ADAPTER_AGENT_IDS = { + claude_local: "claude", + codex_local: "codex", + gemini_local: "gemini", + custom_acp: "custom", +} as const; + +export type AcpxAdapterType = keyof typeof ACPX_ADAPTER_AGENT_IDS; +export type AcpxAgentId = (typeof ACPX_ADAPTER_AGENT_IDS)[AcpxAdapterType]; + +export function acpxAgentIdForAdapterType(adapterType: string | null | undefined): AcpxAgentId | null { + if (!adapterType) return null; + return ACPX_ADAPTER_AGENT_IDS[adapterType as AcpxAdapterType] ?? null; +} diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts similarity index 74% rename from packages/adapters/acpx-local/src/server/execute.test.ts rename to packages/adapter-utils/src/acpx-engine/execute.test.ts index 36fcbc272f..a953021dca 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -1,10 +1,20 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import type { AcpRuntimeOptions } from "acpx/runtime"; import { DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC } from "@paperclipai/adapter-utils/execution-target"; -import { createAcpxLocalExecutor } from "./execute.js"; +import { + createAcpxEngineExecutor, + findAncestorBin, + geminiVersionSupportsNativeAcpFlag, + parseGeminiVersionParts, + rewriteGeminiAcpFlagForVersion, +} from "./execute.js"; + +const execFileAsync = promisify(execFile); const tempRoots: string[] = []; @@ -63,13 +73,14 @@ async function runExecutor( options: { context?: Record; executionTransport?: Record; + authToken?: string; executionTarget?: Record; } = {}, ) { const runtimeOptions: Record[] = []; const meta: Record[] = []; const logs: Array<{ stream: string; text: string }> = []; - const execute = createAcpxLocalExecutor({ + const execute = createAcpxEngineExecutor({ createRuntime: (options) => { runtimeOptions.push(options as unknown as Record); return buildRuntime() as never; @@ -86,6 +97,7 @@ async function runExecutor( config, context: options.context ?? {}, executionTransport: options.executionTransport, + authToken: options.authToken, executionTarget: options.executionTarget, onLog: async (stream: "stdout" | "stderr", text: string) => { logs.push({ stream, text }); @@ -99,7 +111,53 @@ async function runExecutor( return { logs, meta, runtimeOptions, result }; } -describe("acpx_local runtime skill isolation", () => { +describe("shared ACPX engine runtime behavior", () => { + it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => { + const { meta } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js" }, + { + authToken: "runtime-secret-token", + context: { + taskId: "issue-1", + wakeReason: "issue_assigned", + paperclipWake: { + reason: "issue_assigned", + issue: { id: "issue-1", identifier: "TEST-1" }, + }, + }, + }, + ); + + const prompt = String(meta[0]?.prompt ?? ""); + const promptMetrics = meta[0]?.promptMetrics as Record | undefined; + expect(prompt).toContain("Paperclip runtime note:"); + expect(prompt).toContain("PAPERCLIP_AGENT_ID"); + expect(prompt).toContain("PAPERCLIP_API_KEY"); + expect(prompt).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON"); + expect(prompt).toContain("Paperclip API access note:"); + expect(prompt).toContain('PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"'); + expect(prompt).toContain("$PAPERCLIP_API_BASE/api/agents/me"); + expect(prompt).toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID"); + expect(prompt).toContain("X-Paperclip-Run-Id"); + expect(prompt).not.toContain("$PAPERCLIP_API_URL/api/"); + expect(prompt).not.toContain("/api/issues/{id}"); + expect(prompt).not.toContain("-d '{...}'"); + expect(prompt).not.toContain("runtime-secret-token"); + expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0); + }); + + it("does not show a scoped issue API command when the task id is unavailable", async () => { + const { meta } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js" }, + { authToken: "runtime-secret-token" }, + ); + + const prompt = String(meta[0]?.prompt ?? ""); + expect(prompt).toContain("Paperclip API access note:"); + expect(prompt).toContain("Use a real issue id from the current context before making issue write requests."); + expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID"); + }); + it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => { const root = await makeTempRoot(); const skillRoot = path.join(root, "skills"); @@ -400,7 +458,7 @@ describe("acpx_local runtime skill isolation", () => { } const logs: Array<{ stream: string; text: string }> = []; - const execute = createAcpxLocalExecutor({ + const execute = createAcpxEngineExecutor({ createRuntime: () => ({ ensureSession: async () => { throw new FakeAcpRuntimeError( @@ -462,7 +520,7 @@ describe("acpx_local runtime skill isolation", () => { const stateDir = path.join(root, "state"); const runtimeOptions: AcpRuntimeOptions[] = []; - const execute = createAcpxLocalExecutor({ + const execute = createAcpxEngineExecutor({ createRuntime: (options) => { runtimeOptions.push(options as unknown as AcpRuntimeOptions); return buildRuntime() as never; @@ -485,7 +543,7 @@ describe("acpx_local runtime skill isolation", () => { expect(result.exitCode).toBe(0); const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose); - // verbose is scoped to the claude agent (PAPA-388); the custom agent here + // verbose is scoped to the claude agent; the custom agent here // should not opt in to ACPX runtime verbose session-event logs. expect(verboseFlags.every((flag) => flag === false)).toBe(true); @@ -500,9 +558,60 @@ describe("acpx_local runtime skill isolation", () => { expect(wrapper).toContain("exec node ./fake-acp.js"); }); + it.skipIf(process.platform === "win32")("drops benign ACP nes/close cleanup stderr but keeps it in the run log", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + + const execute = createAcpxEngineExecutor({ + createRuntime: () => buildRuntime() as never, + }); + + const fakeAgentPath = path.join(root, "fake-acp.sh"); + await fs.writeFile( + fakeAgentPath, + [ + "#!/usr/bin/env bash", + "echo \"Error handling request { method: 'nes/close' } { code: -32601, message: '\\\"Method not found\\\": nes/close' }\" >&2", + "echo \"some genuine crash: TypeError: x is not a function\" >&2", + "", + ].join("\n"), + { mode: 0o700 }, + ); + + const result = await execute({ + runId: "run-nes-close-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: fakeAgentPath, + stateDir, + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + const wrapperFile = (await fs.readdir(path.join(stateDir, "wrappers"))).find((name) => name.endsWith(".sh")); + expect(wrapperFile).toBeTruthy(); + const wrapperPath = path.join(stateDir, "wrappers", wrapperFile!); + + const { stderr } = await execFileAsync("bash", [wrapperPath], { + env: { ...process.env, PAPERCLIP_RUN_ID: "run-nes-close-1" }, + }); + + expect(stderr).not.toContain("nes/close"); + expect(stderr).toContain("some genuine crash: TypeError: x is not a function"); + + const runLog = await fs.readFile(path.join(stateDir, "run-stderr", "run-nes-close-1.log"), "utf8"); + expect(runLog).toContain("nes/close"); + expect(runLog).toContain("some genuine crash: TypeError: x is not a function"); + }); + it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => { let observedApiKeyDuringStream: string | undefined; - const execute = createAcpxLocalExecutor({ + const execute = createAcpxEngineExecutor({ createRuntime: () => ({ ensureSession: async () => ({ backendSessionId: "backend-session", @@ -662,7 +771,7 @@ describe("acpx_local runtime skill isolation", () => { const verboseByAgent: Record = {}; for (const agent of ["claude", "codex", "custom"] as const) { const runtimeOptions: AcpRuntimeOptions[] = []; - const execute = createAcpxLocalExecutor({ + const execute = createAcpxEngineExecutor({ createRuntime: (options) => { runtimeOptions.push(options as AcpRuntimeOptions); return buildRuntime() as never; @@ -702,9 +811,181 @@ describe("acpx_local runtime skill isolation", () => { expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false); }); + + it("changes the ACPX session fingerprint when the resolved secret manifest rotates", async () => { + const root = await makeTempRoot(); + const baseConfig = { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + }; + + const first = await runExecutor(baseConfig, { + context: { + paperclipSecrets: { + manifest: [ + { + configPath: "env.API_TOKEN", + envKey: "API_TOKEN", + secretId: "secret-1", + bindingId: "binding-1", + secretKey: "api-token", + version: 1, + provider: "local_encrypted", + }, + ], + }, + }, + }); + const second = await runExecutor(baseConfig, { + context: { + paperclipSecrets: { + manifest: [ + { + configPath: "env.API_TOKEN", + envKey: "API_TOKEN", + secretId: "secret-1", + bindingId: "binding-1", + secretKey: "api-token", + version: 2, + provider: "local_encrypted", + }, + ], + }, + }, + }); + + expect(first.result.sessionParams?.configFingerprint).toBeTypeOf("string"); + expect(second.result.sessionParams?.configFingerprint).toBeTypeOf("string"); + expect(first.result.sessionParams?.configFingerprint).not.toBe(second.result.sessionParams?.configFingerprint); + }); }); -describe("acpx_local execution timeouts", () => { +describe("findAncestorBin", () => { + async function writeFakeBin(dir: string, name: string) { + const binDir = path.join(dir, "node_modules", ".bin"); + await fs.mkdir(binDir, { recursive: true }); + const binPath = path.join(binDir, name); + await fs.writeFile(binPath, "#!/usr/bin/env bash\necho ok\n", { mode: 0o755 }); + return binPath; + } + + it("finds the binary in the start directory's own node_modules/.bin", async () => { + const root = await makeTempRoot(); + const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils"); + await fs.mkdir(packageDir, { recursive: true }); + const expectedBin = await writeFakeBin(packageDir, "claude-agent-acp"); + + const resolved = await findAncestorBin(packageDir, "claude-agent-acp"); + + expect(resolved).toBe(expectedBin); + }); + + it("finds the binary hoisted to an ancestor node_modules/.bin", async () => { + const root = await makeTempRoot(); + const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils"); + await fs.mkdir(packageDir, { recursive: true }); + const expectedBin = await writeFakeBin(root, "claude-agent-acp"); + + const resolved = await findAncestorBin(packageDir, "claude-agent-acp"); + + expect(resolved).toBe(expectedBin); + }); + + it("returns null when the binary is not present in any ancestor", async () => { + const root = await makeTempRoot(); + const packageDir = path.join(root, "node_modules", "@paperclipai", "adapter-utils"); + await fs.mkdir(packageDir, { recursive: true }); + + const resolved = await findAncestorBin(packageDir, "claude-agent-acp"); + + expect(resolved).toBeNull(); + }); + + it("terminates at the filesystem root instead of looping forever", async () => { + const resolved = await findAncestorBin("/", "definitely-not-a-real-bin-name-xyz"); + expect(resolved).toBeNull(); + }); +}); + +describe("gemini ACP flag selection", () => { + it("parses semantic version parts from gemini --version output", () => { + expect(parseGeminiVersionParts("0.30.0")).toEqual([0, 30, 0]); + expect(parseGeminiVersionParts("gemini-cli v1.2.3\n")).toEqual([1, 2, 3]); + expect(parseGeminiVersionParts("no version here")).toBeNull(); + expect(parseGeminiVersionParts(null)).toBeNull(); + }); + + it("keeps --acp for gemini >= 0.33.0 and unknown versions", () => { + expect(geminiVersionSupportsNativeAcpFlag([0, 33, 0])).toBe(true); + expect(geminiVersionSupportsNativeAcpFlag([0, 34, 1])).toBe(true); + expect(geminiVersionSupportsNativeAcpFlag([1, 0, 0])).toBe(true); + expect(geminiVersionSupportsNativeAcpFlag(null)).toBe(true); + expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 33, 0])).toBe("gemini --acp"); + }); + + it("downgrades --acp to --experimental-acp for gemini < 0.33.0", () => { + expect(geminiVersionSupportsNativeAcpFlag([0, 30, 0])).toBe(false); + expect(geminiVersionSupportsNativeAcpFlag([0, 32, 9])).toBe(false); + expect(rewriteGeminiAcpFlagForVersion("gemini --acp", [0, 30, 0])).toBe("gemini --experimental-acp"); + expect(rewriteGeminiAcpFlagForVersion("/opt/bin/gemini --acp", [0, 30, 0])).toBe( + "/opt/bin/gemini --experimental-acp", + ); + }); + + async function writeFakeGemini(binDir: string, version: string) { + await fs.mkdir(binDir, { recursive: true }); + const binPath = path.join(binDir, "gemini"); + await fs.writeFile(binPath, `#!/bin/sh\necho "${version}"\n`, { mode: 0o755 }); + } + + function pathWithFakeBin(binDir: string): string { + return [binDir, process.env.PATH ?? ""].filter(Boolean).join(path.delimiter); + } + + async function readGeminiWrapperScript(stateDir: string): Promise { + const wrappersDir = path.join(stateDir, "wrappers"); + const names = await fs.readdir(wrappersDir); + const scriptName = names.find((name) => name.endsWith(".sh")); + expect(scriptName).toBeTypeOf("string"); + return fs.readFile(path.join(wrappersDir, scriptName!), "utf8"); + } + + it("writes a gemini wrapper that execs a multi-word command instead of a single quoted token", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const binDir = path.join(root, "bin"); + await writeFakeGemini(binDir, "0.33.0"); + + await runExecutor({ + agent: "gemini", + stateDir, + env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) }, + }); + + const script = await readGeminiWrapperScript(stateDir); + expect(script).toContain('exec gemini --acp "$@"'); + expect(script).not.toContain("'gemini --acp'"); + }); + + it("downgrades the built-in gemini command flag when the local CLI predates --acp", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const binDir = path.join(root, "bin"); + await writeFakeGemini(binDir, "0.30.0"); + + await runExecutor({ + agent: "gemini", + stateDir, + env: { HOME: path.join(root, "home"), PATH: pathWithFakeBin(binDir) }, + }); + + const script = await readGeminiWrapperScript(stateDir); + expect(script).toContain('exec gemini --experimental-acp "$@"'); + }); +}); + +describe("shared ACP engine execution timeouts", () => { it("applies the 4h sandbox backstop when timeoutSec is unset on a sandbox execution target", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); @@ -837,7 +1118,7 @@ describe("acpx_local execution timeouts", () => { releaseTurn = resolve; }); - const execute = createAcpxLocalExecutor({ + const execute = createAcpxEngineExecutor({ createRuntime: () => ({ ensureSession: async () => ({ backendSessionId: "backend-session", diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts similarity index 84% rename from packages/adapters/acpx-local/src/server/execute.ts rename to packages/adapter-utils/src/acpx-engine/execute.ts index 90d2609a82..6d4a849d9f 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1,6 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { createHash, randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; @@ -21,6 +23,7 @@ import { buildPaperclipEnv, ensureAbsoluteDirectory, ensurePathInEnv, + ensurePaperclipSkillSymlink, joinPromptSections, materializePaperclipSkillCopy, parseObject, @@ -30,6 +33,7 @@ import { renderTemplate, resolvePaperclipInstanceRootForAdapter, resolvePaperclipDesiredSkillNames, + removeMaintainerOnlySkillSymlinks, rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, @@ -50,21 +54,21 @@ import { type AcpRuntimeTurnResult, } from "acpx/runtime"; import { - DEFAULT_ACPX_LOCAL_AGENT, - DEFAULT_ACPX_LOCAL_MODE, - DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, - DEFAULT_ACPX_LOCAL_PERMISSION_MODE, - DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, - DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS, -} from "../index.js"; + DEFAULT_ACP_ENGINE_AGENT, + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_PERMISSION_MODE, + DEFAULT_ACP_ENGINE_TIMEOUT_SEC, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "./constants.js"; -const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultModuleDir = path.dirname(fileURLToPath(import.meta.url)); const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000; const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json"; type AcpxRuntimeFactory = (options: AcpRuntimeOptions) => AcpRuntime; -interface RuntimeCacheEntry { +export interface RuntimeCacheEntry { runtime: AcpRuntime; handle: AcpRuntimeHandle; fingerprint: string; @@ -72,10 +76,19 @@ interface RuntimeCacheEntry { cleanupTimer?: NodeJS.Timeout; } -interface ExecuteDeps { +interface AcpxEngineSettings { + adapterType: string; + moduleDir: string; + packageRootDir: string; +} + +export interface AcpxEngineExecutorOptions { createRuntime?: AcpxRuntimeFactory; now?: () => number; warmHandles?: Map; + adapterType?: string; + moduleDir?: string; + packageRootDir?: string; } interface AcpxPreparedRuntime { @@ -108,6 +121,15 @@ interface AcpxPreparedRuntime { const defaultWarmHandles = new Map(); +function resolveEngineSettings(options: AcpxEngineExecutorOptions): AcpxEngineSettings { + const moduleDir = path.resolve(options.moduleDir ?? defaultModuleDir); + return { + adapterType: options.adapterType?.trim() || "acp_engine", + moduleDir, + packageRootDir: path.resolve(options.packageRootDir ?? path.resolve(moduleDir, "../..")), + }; +} + function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { @@ -133,31 +155,101 @@ function defaultPaperclipInstanceDir(): string { } function defaultStateDir(companyId: string, agentId: string): string { - return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acpx-local", "agents", agentId); + return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acp-engine", "agents", agentId); } function resolveManagedCodexHomeDir(companyId: string): string { return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home"); } -function packageRootDir(): string { - return path.resolve(__moduleDir, "../.."); +// Walk up from startDir looking for `node_modules/.bin/`. This matches +// npm/pnpm binary hoisting in packaged installs while preserving monorepo dev. +export async function findAncestorBin(startDir: string, binName: string): Promise { + let current = path.resolve(startDir); + while (true) { + const candidate = path.join(current, "node_modules", ".bin", binName); + if (await pathExists(candidate)) return candidate; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } } -function resolveBuiltInAgentCommand(agent: string): string | null { - const binName = - agent === "claude" - ? "claude-agent-acp" - : agent === "codex" - ? "codex-acp" - : null; +interface BuiltInAgentCommand { + command: string; + shellCommand: string; +} + +async function resolveBuiltInAgentCommand(agent: string, packageRootDir: string): Promise { + if (agent === "gemini") { + return { command: "gemini --acp", shellCommand: "gemini --acp" }; + } + const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null; if (!binName) return null; - return path.join(packageRootDir(), "node_modules", ".bin", binName); + const resolved = (await findAncestorBin(packageRootDir, binName)) ?? binName; + return { command: resolved, shellCommand: shellQuote(resolved) }; +} + +const execFileAsync = promisify(execFile); +// Gemini CLI renamed --experimental-acp to --acp in 0.33.0. acpx normally +// rewrites the flag itself, but the agent wrapper script hides the gemini +// command from acpx's detection, so the engine must downgrade it here. +const GEMINI_NATIVE_ACP_FLAG_MIN_VERSION = [0, 33, 0] as const; +const GEMINI_VERSION_PROBE_TIMEOUT_MS = 2000; + +export function parseGeminiVersionParts(output: string | null | undefined): number[] | null { + const match = output?.match(/(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function geminiVersionSupportsNativeAcpFlag(parts: number[] | null): boolean { + if (!parts) return true; + for (let index = 0; index < GEMINI_NATIVE_ACP_FLAG_MIN_VERSION.length; index += 1) { + const diff = (parts[index] ?? 0) - GEMINI_NATIVE_ACP_FLAG_MIN_VERSION[index]; + if (diff !== 0) return diff > 0; + } + return true; +} + +export function rewriteGeminiAcpFlagForVersion(commandShell: string, versionParts: number[] | null): string { + if (geminiVersionSupportsNativeAcpFlag(versionParts)) return commandShell; + return commandShell + .trim() + .split(/\s+/) + .map((token) => (token === "--acp" ? "--experimental-acp" : token)) + .join(" "); +} + +function geminiAcpCommandTokens(commandShell: string): string[] | null { + const tokens = commandShell.trim().split(/\s+/); + const bin = tokens[0]; + if (!bin || bin.startsWith("'") || bin.startsWith('"')) return null; + if (path.basename(bin) !== "gemini") return null; + if (!tokens.includes("--acp")) return null; + return tokens; +} + +async function normalizeGeminiAcpCommandShell(commandShell: string, env: NodeJS.ProcessEnv): Promise { + const tokens = geminiAcpCommandTokens(commandShell); + if (!tokens) return commandShell; + let versionParts: number[] | null = null; + try { + const { stdout } = await execFileAsync(tokens[0], ["--version"], { + timeout: GEMINI_VERSION_PROBE_TIMEOUT_MS, + encoding: "utf8", + env, + }); + versionParts = parseGeminiVersionParts(stdout); + } catch { + return commandShell; + } + return rewriteGeminiAcpFlagForVersion(commandShell, versionParts); } function normalizeAgent(config: Record): string { - const agent = asString(config.agent, DEFAULT_ACPX_LOCAL_AGENT).trim(); - return agent || DEFAULT_ACPX_LOCAL_AGENT; + const agent = asString(config.agent, DEFAULT_ACP_ENGINE_AGENT).trim(); + return agent || DEFAULT_ACP_ENGINE_AGENT; } async function pathExists(candidate: string): Promise { @@ -301,8 +393,9 @@ async function buildSkillSetKey(input: { async function resolveSelectedRuntimeSkills( config: Record, + moduleDir: string, ): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> { - const allSkills = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const allSkills = await readPaperclipRuntimeSkillEntries(config, moduleDir); const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills); const desiredSet = new Set(desiredSkillNames); return { @@ -315,13 +408,14 @@ async function resolveSelectedRuntimeSkills( async function prepareClaudeSkillRuntime(input: { stateDir: string; config: Record; + moduleDir: string; onLog: AdapterExecutionContext["onLog"]; }): Promise<{ identity: Record; promptInstructions: string; commandNotes: string[]; }> { - const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config); + const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir); const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "claude" }); const bundleRoot = path.join(input.stateDir, "runtime-skills", "claude", skillSetKey); const skillsHome = path.join(bundleRoot, ".claude", "skills"); @@ -443,6 +537,7 @@ async function prepareCodexSkillRuntime(input: { companyId: string; config: Record; env: Record; + moduleDir: string; onLog: AdapterExecutionContext["onLog"]; }): Promise<{ identity: Record; commandNotes: string[] }> { const envConfig = parseObject(input.config.env); @@ -462,7 +557,7 @@ async function prepareCodexSkillRuntime(input: { targetHome: managedCodexHome, onLog: input.onLog, }); - const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config); + const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir); const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "codex" }); const skillsHome = path.join(effectiveCodexHome, "skills"); await fs.mkdir(skillsHome, { recursive: true }); @@ -507,19 +602,76 @@ async function prepareCodexSkillRuntime(input: { }; } +function resolveGeminiSkillsHome(config: Record): string { + const envConfig = parseObject(config.env); + const configuredHome = + typeof envConfig.HOME === "string" && envConfig.HOME.trim().length > 0 + ? path.resolve(envConfig.HOME.trim()) + : os.homedir(); + return path.join(configuredHome, ".gemini", "skills"); +} + +async function prepareGeminiSkillRuntime(input: { + config: Record; + moduleDir: string; + onLog: AdapterExecutionContext["onLog"]; +}): Promise<{ identity: Record; commandNotes: string[] }> { + const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config, input.moduleDir); + const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "gemini" }); + const skillsHome = resolveGeminiSkillsHome(input.config); + await fs.mkdir(skillsHome, { recursive: true }); + + const allowedSkillNames = selectedSkills.map((entry) => entry.runtimeName); + const removedSkills = await removeMaintainerOnlySkillSymlinks(skillsHome, allowedSkillNames); + for (const skillName of removedSkills) { + await input.onLog("stdout", `[paperclip] Removed maintainer-only ACPX Gemini skill "${skillName}" from ${skillsHome}\n`); + } + + for (const entry of selectedSkills) { + const target = path.join(skillsHome, entry.runtimeName); + try { + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "created" || result === "repaired") { + await input.onLog( + "stdout", + `[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} ACPX Gemini skill "${entry.runtimeName}" into ${skillsHome}\n`, + ); + } + } catch (err) { + await input.onLog( + "stderr", + `[paperclip] Failed to link ACPX Gemini skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + + return { + identity: { + mode: "gemini", + skillSetKey, + desiredSkillNames, + selectedSkills: selectedSkills.map((entry) => entry.runtimeName).sort(), + skillsHome, + }, + commandNotes: selectedSkills.length > 0 + ? [`Prepared ${selectedSkills.length} ACPX Gemini skill(s) at ${skillsHome}.`] + : [], + }; +} + function normalizeMode(config: Record): "persistent" | "oneshot" { - return asString(config.mode, DEFAULT_ACPX_LOCAL_MODE) === "oneshot" ? "oneshot" : "persistent"; + return asString(config.mode, DEFAULT_ACP_ENGINE_MODE) === "oneshot" ? "oneshot" : "persistent"; } function normalizePermissionMode(config: Record): "approve-all" | "approve-reads" | "deny-all" { - const value = asString(config.permissionMode, DEFAULT_ACPX_LOCAL_PERMISSION_MODE).trim(); + const value = asString(config.permissionMode, DEFAULT_ACP_ENGINE_PERMISSION_MODE).trim(); if (value === "approve-reads" || value === "deny-all") return value; if (value === "default") return "approve-reads"; return "approve-all"; } function normalizeNonInteractivePermissions(config: Record): "deny" | "fail" { - return asString(config.nonInteractivePermissions, DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS) === "fail" + return asString(config.nonInteractivePermissions, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS) === "fail" ? "fail" : "deny"; } @@ -586,7 +738,7 @@ function uniqueSorted(values: Array): string[] { return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort(); } -// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses +// The Claude Code SDK that `claude-agent-acp` runs uses // `settingSources: ["user", "project", "local"]`. By writing a per-worktree // `.claude/settings.local.json` we override the user's potentially-restrictive // `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently @@ -700,7 +852,9 @@ async function writeAgentWrapper(input: { `stderr_dir=${shellQuote(input.childStderrDir)}`, "if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then", " mkdir -p \"$stderr_dir\"", - " exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)", + // Keep the run-stderr file unfiltered, but do not forward the known-benign + // ACP nes/close cleanup RPC error to Paperclip's live stderr stream. + " exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" | grep -Ev \"method: ['\\\"]nes/close['\\\"].*-32601\" >&2 || true)", "fi", `exec ${input.agentCommandShell} "$@"`, "", @@ -739,9 +893,12 @@ async function cleanupStaleAgentWrappers(input: { wrappersDir: string; currentFi async function buildRuntime(input: { ctx: AdapterExecutionContext; + engine: AcpxEngineSettings; }): Promise { const { runId, agent, config, context, authToken } = input.ctx; const workspaceContext = parseObject(context.paperclipWorkspace); + const secretsContext = parseObject(context.paperclipSecrets); + const secretManifest = Array.isArray(secretsContext.manifest) ? secretsContext.manifest : []; const workspaceCwd = asString(workspaceContext.cwd, ""); const workspaceSource = asString(workspaceContext.source, ""); const workspaceStrategy = asString(workspaceContext.strategy, ""); @@ -785,7 +942,7 @@ async function buildRuntime(input: { // local/SSH runs keep the historical "0 = no adapter timeout" behavior. const timeoutResolution = resolveAdapterExecutionTargetTimeout( executionTarget, - asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC), + asNumber(config.timeoutSec, DEFAULT_ACP_ENGINE_TIMEOUT_SEC), ); const timeoutSec = timeoutResolution.timeoutSec; const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id)); @@ -858,6 +1015,7 @@ async function buildRuntime(input: { const preparedSkills = await prepareClaudeSkillRuntime({ stateDir, config, + moduleDir: input.engine.moduleDir, onLog: input.ctx.onLog, }); skillPromptInstructions = preparedSkills.promptInstructions; @@ -879,12 +1037,24 @@ async function buildRuntime(input: { companyId: agent.companyId, config, env, + moduleDir: input.engine.moduleDir, + onLog: input.ctx.onLog, + }); + skillsIdentity = preparedSkills.identity; + skillCommandNotes.push(...preparedSkills.commandNotes); + } else if (acpxAgent === "gemini") { + const preparedSkills = await prepareGeminiSkillRuntime({ + config, + moduleDir: input.engine.moduleDir, onLog: input.ctx.onLog, }); skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); } else { - const desired = resolvePaperclipDesiredSkillNames(config, await readPaperclipRuntimeSkillEntries(config, __moduleDir)); + const desired = resolvePaperclipDesiredSkillNames( + config, + await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir), + ); skillsIdentity = { mode: "custom_unsupported", desiredSkillNames: desired }; if (desired.length > 0) { skillCommandNotes.push("Selected Paperclip skills are tracked only; ACPX custom commands do not expose a runtime skill contract yet."); @@ -892,9 +1062,19 @@ async function buildRuntime(input: { } const configuredCommand = asString(config.agentCommand, "").trim(); - const builtInCommand = resolveBuiltInAgentCommand(acpxAgent); - const agentCommand = configuredCommand || builtInCommand || null; - const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : ""); + const builtInCommand = await resolveBuiltInAgentCommand(acpxAgent, input.engine.packageRootDir); + let agentCommand = configuredCommand || builtInCommand?.command || null; + let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || ""; + if (acpxAgent === "gemini" && agentCommandShell) { + const normalized = await normalizeGeminiAcpCommandShell( + agentCommandShell, + ensurePathInEnv({ ...process.env, ...env }), + ); + if (normalized !== agentCommandShell) { + agentCommandShell = normalized; + agentCommand = normalized; + } + } const childStderrDir = path.join(stateDir, "run-stderr"); const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null; const wrapper = agentCommand @@ -929,6 +1109,7 @@ async function buildRuntime(input: { defaultMode: paperclipClaudeSettings.defaultMode, } : null, + secretManifestHash: shortHash(secretManifest), }); const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; @@ -1021,7 +1202,40 @@ async function applySessionConfigOptions(input: { } } -async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{ +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!env.PAPERCLIP_API_URL || !env.PAPERCLIP_API_KEY) return ""; + const lines = [ + "Paperclip API access note:", + "Use terminal commands with curl to make Paperclip API requests.", + "Normalize the base URL before adding API paths:", + ` PAPERCLIP_API_BASE="\${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="\${PAPERCLIP_API_BASE%/api}"`, + "GET example:", + ` curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_BASE/api/agents/me"`, + ]; + if (env.PAPERCLIP_TASK_ID) { + lines.push( + "Scoped issue comment example:", + ` curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" -d '{"body":"Status update from agent."}' "$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID/comments"`, + ); + } else { + lines.push("Use a real issue id from the current context before making issue write requests."); + } + return lines.join("\n"); +} + +async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean, env: Record): Promise<{ prompt: string; promptMetrics: Record; commandNotes: string[]; @@ -1073,12 +1287,16 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); const prompt = joinPromptSections([ promptInstructionsPrefix, renderedBootstrapPrompt, wakePrompt, sessionHandoffNote, taskContextNote, + paperclipEnvNote, + apiAccessNote, renderedPrompt, ]); @@ -1092,6 +1310,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean wakePromptChars: wakePrompt.length, sessionHandoffChars: sessionHandoffNote.length, taskContextChars: taskContextNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, heartbeatPromptChars: renderedPrompt.length, }, }; @@ -1389,16 +1608,17 @@ function warmHandleMatches( runtime: AcpRuntime, handle: AcpRuntimeHandle, ): boolean { - return entry?.runtime === runtime && entry.handle === handle; + return entry !== undefined && entry.runtime === runtime && entry.handle === handle; } -export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { +export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { const createRuntime = deps.createRuntime ?? createAcpRuntime; const now = deps.now ?? (() => Date.now()); const warmHandles = deps.warmHandles ?? defaultWarmHandles; + const engine = resolveEngineSettings(deps); - return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise { - const prepared = await buildRuntime({ ctx }); + return async function executeAcpxEngine(ctx: AdapterExecutionContext): Promise { + const prepared = await buildRuntime({ ctx, engine }); // State the effective wall-clock timeout and its source up front so a // later timeout is diagnosable from the run log alone. Goes to stderr: // the acpx stdout log stream carries JSON acpx.* event payloads and must @@ -1407,7 +1627,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { "stderr", `[paperclip] ${formatAdapterExecutionTimeoutStartLogLine(prepared.timeoutResolution)}\n`, ); - const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS); + const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS); await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs }); const previousParams = parseObject(ctx.runtime.sessionParams); @@ -1421,8 +1641,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { permissionMode: prepared.permissionMode, nonInteractivePermissions: prepared.nonInteractivePermissions, timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined, - // Scope ACPX runtime verbose logs to the claude agent only — that's the - // surface we know needs the extra session-event detail (PAPA-388). codex + // Scope ACPX runtime verbose logs to the claude agent only. Codex // and custom agents already emit their own per-tool output and don't // benefit from doubling the log volume. verbose: prepared.acpxAgent === "claude", @@ -1544,7 +1763,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { summary: message, }; } - const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession); + const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession, prepared.env); const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]); await emitAcpxLog(ctx, { type: "acpx.session", @@ -1561,7 +1780,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { }); if (ctx.onMeta) { await ctx.onMeta({ - adapterType: "acpx_local", + adapterType: engine.adapterType, command: prepared.agentCommand ?? prepared.acpxAgent, cwd: prepared.cwd, commandNotes: [ @@ -1757,4 +1976,5 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { }; } -export const execute = createAcpxLocalExecutor(); + +export const execute = createAcpxEngineExecutor(); diff --git a/packages/adapter-utils/src/acpx-engine/index.ts b/packages/adapter-utils/src/acpx-engine/index.ts new file mode 100644 index 0000000000..b749e320b1 --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/index.ts @@ -0,0 +1,5 @@ +export * from "./constants.js"; +export { createAcpxEngineExecutor, execute } from "./execute.js"; +export { sessionCodec } from "./session-codec.js"; +export { printAcpxStreamEvent } from "./cli.js"; +export { parseAcpxStdoutLine } from "./ui.js"; diff --git a/packages/adapters/acpx-local/src/server/session-codec.ts b/packages/adapter-utils/src/acpx-engine/session-codec.ts similarity index 100% rename from packages/adapters/acpx-local/src/server/session-codec.ts rename to packages/adapter-utils/src/acpx-engine/session-codec.ts diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.ts b/packages/adapter-utils/src/acpx-engine/ui.ts similarity index 100% rename from packages/adapters/acpx-local/src/ui/parse-stdout.ts rename to packages/adapter-utils/src/acpx-engine/ui.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 32e890e0d5..e73c4ed06a 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -28,6 +28,7 @@ export type { ConfigFieldSchema, AdapterConfigSchema, AdapterRuntimeCommandSpec, + AcpTargetDescriptor, ServerAdapterModule, QuotaWindow, ProviderQuotaResult, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 1de7f3d662..1e7dd6ac47 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -37,7 +37,6 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = { }; export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ - "acpx_local", "claude_local", "codex_local", "cursor_cloud", @@ -49,11 +48,6 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ ]); export const ADAPTER_SESSION_MANAGEMENT: Record = { - acpx_local: { - supportsSessionResume: true, - nativeContextManagement: "confirmed", - defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY, - }, claude_local: { supportsSessionResume: true, nativeContextManagement: "confirmed", diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index e290cbbe3c..7bdfbc4264 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -348,10 +348,20 @@ export interface AdapterRuntimeCommandSpec { installCommand?: string | null; } +export interface AcpTargetDescriptor { + agentId: "claude" | "codex" | "gemini" | "custom" | (string & {}); + skillsMode: "ephemeral" | "unsupported"; + prerequisites: { + nodeRange?: string; + packages?: string[]; + }; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; + acp?: AcpTargetDescriptor; listSkills?: (ctx: AdapterSkillContext) => Promise; syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise; sessionCodec?: AdapterSessionCodec; @@ -482,6 +492,24 @@ export interface CreateConfigValues { cheapModelEnabled?: boolean; chrome: boolean; dangerouslySkipPermissions: boolean; + claudeEngine?: "auto" | "cli" | "acp"; + claudeAcpAgentCommand?: string; + claudeAcpMode?: "persistent" | "oneshot"; + claudeAcpNonInteractivePermissions?: "deny" | "fail"; + claudeAcpStateDir?: string; + claudeAcpWarmHandleIdleMs?: number; + codexEngine?: "auto" | "cli" | "acp"; + codexAcpAgentCommand?: string; + codexAcpMode?: "persistent" | "oneshot"; + codexAcpNonInteractivePermissions?: "deny" | "fail"; + codexAcpStateDir?: string; + codexAcpWarmHandleIdleMs?: number; + geminiEngine?: "auto" | "cli" | "acp"; + geminiAcpAgentCommand?: string; + geminiAcpMode?: "persistent" | "oneshot"; + geminiAcpNonInteractivePermissions?: "deny" | "fail"; + geminiAcpStateDir?: string; + geminiAcpWarmHandleIdleMs?: number; search: boolean; fastMode: boolean; dangerouslyBypassSandbox: boolean; diff --git a/packages/adapters/acpx-local/package.json b/packages/adapters/acpx-local/package.json deleted file mode 100644 index 1217b39ea7..0000000000 --- a/packages/adapters/acpx-local/package.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "name": "@paperclipai/adapter-acpx-local", - "version": "0.3.1", - "license": "MIT", - "homepage": "https://github.com/paperclipai/paperclip", - "bugs": { - "url": "https://github.com/paperclipai/paperclip/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/paperclipai/paperclip", - "directory": "packages/adapters/acpx-local" - }, - "type": "module", - "exports": { - ".": "./src/index.ts", - "./server": "./src/server/index.ts", - "./ui": "./src/ui/index.ts", - "./cli": "./src/cli/index.ts" - }, - "publishConfig": { - "access": "public", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./server": { - "types": "./dist/server/index.d.ts", - "import": "./dist/server/index.js" - }, - "./ui": { - "types": "./dist/ui/index.d.ts", - "import": "./dist/ui/index.js" - }, - "./cli": { - "types": "./dist/cli/index.d.ts", - "import": "./dist/cli/index.js" - } - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "files": [ - "dist", - "skills" - ], - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@agentclientprotocol/claude-agent-acp": "^0.52.0", - "@paperclipai/adapter-utils": "workspace:*", - "@zed-industries/codex-acp": "^0.16.0", - "acpx": "^0.12.0", - "picocolors": "^1.1.1" - }, - "devDependencies": { - "@types/node": "^22.19.21", - "typescript": "^5.7.3" - } -} diff --git a/packages/adapters/acpx-local/src/cli/format-event.test.ts b/packages/adapters/acpx-local/src/cli/format-event.test.ts deleted file mode 100644 index 34e2b6b3e6..0000000000 --- a/packages/adapters/acpx-local/src/cli/format-event.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { printAcpxStreamEvent } from "./format-event.js"; - -function emit(payload: Record): string { - return JSON.stringify(payload); -} - -interface CapturedOutput { - log: string[]; - stdout: string[]; -} - -function captureOutput(): { capture: CapturedOutput; restore: () => void } { - const log: string[] = []; - const stdout: string[] = []; - const logSpy = vi.spyOn(console, "log").mockImplementation((value?: unknown) => { - log.push(String(value ?? "")); - }); - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { - stdout.push(String(chunk ?? "")); - return true; - }) as typeof process.stdout.write); - return { - capture: { log, stdout }, - restore: () => { - logSpy.mockRestore(); - stdoutSpy.mockRestore(); - }, - }; -} - -function strip(value: string): string { - return value.replace(/\x1b\[[0-9;]*m/g, ""); -} - -describe("printAcpxStreamEvent", () => { - let captured: CapturedOutput; - let restore: () => void; - - beforeEach(() => { - const result = captureOutput(); - captured = result.capture; - restore = result.restore; - }); - - afterEach(() => { - restore(); - }); - - it("renders acpx.session as a labeled session header", () => { - printAcpxStreamEvent( - emit({ - type: "acpx.session", - agent: "claude", - acpSessionId: "acp-1", - mode: "persistent", - permissionMode: "approve-all", - }), - false, - ); - expect(captured.log.map(strip)).toEqual(["claude session: acp-1 [persistent / approve-all]"]); - }); - - it("streams output text_delta to stdout for live progress", () => { - printAcpxStreamEvent( - emit({ type: "acpx.text_delta", text: "hello", channel: "output" }), - false, - ); - expect(captured.log).toEqual([]); - expect(captured.stdout.map(strip)).toEqual(["hello"]); - }); - - it("renders thought text_delta on its own line", () => { - printAcpxStreamEvent( - emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }), - false, - ); - expect(captured.log.map(strip)).toEqual(["thinking…"]); - }); - - it("renders tool_call with status and id", () => { - printAcpxStreamEvent( - emit({ - type: "acpx.tool_call", - name: "read", - toolCallId: "tool-1", - status: "running", - text: "read README.md", - }), - false, - ); - expect(captured.log.map(strip)).toEqual([ - "tool_call: read [running] (tool-1)", - "read README.md", - ]); - }); - - it("renders status events with optional context window", () => { - printAcpxStreamEvent( - emit({ type: "acpx.status", tag: "context_window", used: 100, size: 200000 }), - false, - ); - expect(captured.log.map(strip)).toEqual(["status: context_window (100/200000 ctx)"]); - }); - - it("renders acpx.result and acpx.error", () => { - printAcpxStreamEvent(emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }), false); - printAcpxStreamEvent(emit({ type: "acpx.error", message: "auth required" }), false); - expect(captured.log.map(strip)).toEqual(["result: completed", "error: auth required"]); - }); - - it("falls back to plain output for non-JSON lines", () => { - printAcpxStreamEvent("not json", false); - expect(captured.log).toEqual(["not json"]); - }); - - it("still emits unknown / non-JSON lines when debug is enabled", () => { - printAcpxStreamEvent("not json", true); - expect(strip(captured.log[0])).toBe("not json"); - }); -}); diff --git a/packages/adapters/acpx-local/src/cli/index.ts b/packages/adapters/acpx-local/src/cli/index.ts deleted file mode 100644 index 51a60e2af1..0000000000 --- a/packages/adapters/acpx-local/src/cli/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { printAcpxStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/acpx-local/src/index.ts b/packages/adapters/acpx-local/src/index.ts deleted file mode 100644 index e94513b232..0000000000 --- a/packages/adapters/acpx-local/src/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { AdapterModel } from "@paperclipai/adapter-utils"; - -export const type = "acpx_local"; -export const label = "ACPX"; - -export const DEFAULT_ACPX_LOCAL_AGENT = "claude"; -export const DEFAULT_ACPX_LOCAL_MODE = "persistent"; -export const DEFAULT_ACPX_LOCAL_PERMISSION_MODE = "approve-all"; -export const DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS = "deny"; -export const DEFAULT_ACPX_LOCAL_TIMEOUT_SEC = 0; -export const DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS = 0; - -export const acpxAgentOptions = [ - { id: "claude", label: "Claude via ACPX" }, - { id: "codex", label: "Codex via ACPX" }, - { id: "custom", label: "Custom ACP command" }, -] as const; - -export const models: AdapterModel[] = []; - -export const agentConfigurationDoc = `# acpx_local agent configuration - -Adapter: acpx_local - -Use when: -- The agent should run through Agent Client Protocol via ACPX on the Paperclip host or a managed execution environment. -- You want one built-in adapter that can target Claude, Codex, or a custom ACP server command. -- You want the richest live run feedback. acpx_local streams structured JSONL events (acpx.session, acpx.status, acpx.text_delta, acpx.tool_call, acpx.result, acpx.error) that the UI renders as live message, thinking, tool, and status blocks. -- The agent runs on a sandbox execution target. Sandbox run logs stream live, so - acpx_local's granular events make remote runs as observable as local ones. - Prefer acpx_local for sandbox workers, especially when watching progress in - real time. - -Don't use when: -- You depend on CLI-wrapper-specific behavior of claude_local or codex_local (their session files, CLI flags, or CLI-version-specific output). -- The host cannot satisfy ACPX's Node >=22.12.0 prerequisite. -- The agent runtime is not an ACP server and cannot be launched through ACPX. - -Core fields: -- agent (string, optional): claude, codex, or custom. Defaults to claude. -- agentCommand (string, optional): custom ACP command when agent=custom, or an override for a built-in ACP agent command. -- mode (string, optional): persistent or oneshot. Defaults to persistent. Paperclip keeps session state persistent and may close the live process between runs. -- cwd (string, optional): default absolute working directory fallback for the agent process. -- permissionMode (string, optional): defaults to approve-all, meaning ACPX permission requests are auto-approved. -- nonInteractivePermissions (string, optional): fallback behavior when ACPX cannot ask interactively. Supported values are deny and fail. -- stateDir (string, optional): ACPX state directory. Defaults to a Paperclip-managed company/agent scoped location. -- instructionsFilePath (string, optional): absolute path to a markdown instructions file used by Paperclip prompt construction. -- promptTemplate (string, optional): run prompt template. -- bootstrapPromptTemplate (string, optional): first-run bootstrap prompt template. -- model (string, optional): requested ACP model. Claude and Codex ACP agents both receive this through ACP session config. -- effort/modelReasoningEffort (string, optional): requested thinking effort. Claude uses effort; Codex uses modelReasoningEffort/reasoning_effort. -- fastMode (boolean, optional): for ACPX Codex, request Codex fast mode through ACP session config. -- timeoutSec (number, optional): run timeout in seconds. Defaults to 0, meaning no adapter timeout for local/SSH execution. Sandbox execution targets default to a 4h wall-clock backstop when timeoutSec is unset; the output-inactivity monitor remains the primary hang detector. -- warmHandleIdleMs (number, optional): live ACPX process idle window after a successful persistent run. Defaults to 0, meaning Paperclip shuts the process down after each run while retaining ACPX session state. -- env (object, optional): KEY=VALUE environment variables or secret bindings. - -Dependency decision: -- acpx_local declares direct dependencies on acpx, @agentclientprotocol/claude-agent-acp, and @zed-industries/codex-acp so the built-in adapter has deterministic package resolution instead of relying on globally installed ACP commands. -- ACPX currently requires Node >=22.12.0. Paperclip keeps the repo-wide Node >=20 engine and surfaces the stricter runtime prerequisite through acpx_local diagnostics. -`; diff --git a/packages/adapters/acpx-local/src/server/config-schema.ts b/packages/adapters/acpx-local/src/server/config-schema.ts deleted file mode 100644 index 1cbaf86c37..0000000000 --- a/packages/adapters/acpx-local/src/server/config-schema.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; -import { - DEFAULT_ACPX_LOCAL_AGENT, - DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, - DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, - DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS, - acpxAgentOptions, -} from "../index.js"; - -export function getConfigSchema(): AdapterConfigSchema { - return { - fields: [ - { - key: "agent", - label: "ACP agent", - type: "select", - default: DEFAULT_ACPX_LOCAL_AGENT, - required: true, - options: acpxAgentOptions.map((agent) => ({ value: agent.id, label: agent.label })), - hint: "Choose the ACP agent launched through ACPX.", - }, - { - key: "agentCommand", - label: "Agent command", - type: "text", - hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.", - }, - { - key: "nonInteractivePermissions", - label: "Non-interactive permissions", - type: "select", - default: DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, - options: [ - { value: "deny", label: "Deny" }, - { value: "fail", label: "Fail" }, - ], - hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.", - }, - { - key: "cwd", - label: "Working directory", - type: "text", - hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.", - }, - { - key: "stateDir", - label: "State directory", - type: "text", - hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.", - }, - { - key: "fastMode", - label: "Codex fast mode", - type: "toggle", - default: false, - hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.", - meta: { visibleWhen: { key: "agent", values: ["codex"] } }, - }, - { - key: "timeoutSec", - label: "Timeout seconds", - type: "number", - default: DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, - hint: "Wall-clock timeout for a run. 0 uses the target default: no adapter timeout on local/SSH, 4 hours on sandbox targets. Set a negative value (e.g. -1) to disable the adapter timeout everywhere, including sandboxes.", - }, - { - key: "warmHandleIdleMs", - label: "Warm process idle ms", - type: "number", - default: DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS, - hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.", - }, - { - key: "env", - label: "Environment JSON", - type: "textarea", - hint: "Optional JSON object of environment values or secret bindings.", - }, - ], - }; -} diff --git a/packages/adapters/acpx-local/src/server/index.ts b/packages/adapters/acpx-local/src/server/index.ts deleted file mode 100644 index 7463c9526a..0000000000 --- a/packages/adapters/acpx-local/src/server/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { execute, createAcpxLocalExecutor } from "./execute.js"; -export { testEnvironment } from "./test.js"; -export { getConfigSchema } from "./config-schema.js"; -export { sessionCodec } from "./session-codec.js"; -export { listAcpxSkills, syncAcpxSkills } from "./skills.js"; diff --git a/packages/adapters/acpx-local/src/server/skills.ts b/packages/adapters/acpx-local/src/server/skills.ts deleted file mode 100644 index 60f7b11a9e..0000000000 --- a/packages/adapters/acpx-local/src/server/skills.ts +++ /dev/null @@ -1,67 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { - AdapterSkillContext, - AdapterSkillSnapshot, -} from "@paperclipai/adapter-utils"; -import { - buildRuntimeMountedSkillSnapshot, - readPaperclipRuntimeSkillEntries, - resolvePaperclipDesiredSkillNames, -} from "@paperclipai/adapter-utils/server-utils"; - -const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); - -type AcpxSkillAgent = "claude" | "codex" | "custom"; - -function normalizeAcpxSkillAgent(config: Record): AcpxSkillAgent { - const configured = typeof config.agent === "string" ? config.agent.trim() : ""; - if (configured === "codex" || configured === "custom") return configured; - if (configured === "claude" || configured === "") return "claude"; - return "claude"; -} - -function configuredDetail(agent: AcpxSkillAgent): string { - if (agent === "codex") { - return "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session."; - } - return "Will be mounted into the next ACPX Claude session."; -} - -function unsupportedDetail(): string { - return "Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available."; -} - -async function buildAcpxSkillSnapshot(config: Record): Promise { - const acpxAgent = normalizeAcpxSkillAgent(config); - const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - const supported = acpxAgent !== "custom"; - const warnings: string[] = supported - ? [] - : [ - "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", - ]; - - return buildRuntimeMountedSkillSnapshot({ - adapterType: "acpx_local", - availableEntries, - desiredSkills, - supported, - mode: supported ? "ephemeral" : "unsupported", - configuredDetail: configuredDetail(acpxAgent), - unsupportedDetail: unsupportedDetail(), - warnings, - }); -} - -export async function listAcpxSkills(ctx: AdapterSkillContext): Promise { - return buildAcpxSkillSnapshot(ctx.config); -} - -export async function syncAcpxSkills( - ctx: AdapterSkillContext, - _desiredSkills: string[], -): Promise { - return buildAcpxSkillSnapshot(ctx.config); -} diff --git a/packages/adapters/acpx-local/src/server/test.test.ts b/packages/adapters/acpx-local/src/server/test.test.ts deleted file mode 100644 index f5744f5408..0000000000 --- a/packages/adapters/acpx-local/src/server/test.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { testEnvironment } from "./test.js"; - -const originalNodeVersion = process.version; - -function setNodeVersion(version: string): void { - Object.defineProperty(process, "version", { - configurable: true, - enumerable: true, - value: version, - }); -} - -afterEach(() => { - setNodeVersion(originalNodeVersion); -}); - -describe("acpx_local environment diagnostics", () => { - it("does not force healthy default Claude diagnostics to warn", async () => { - setNodeVersion("v22.12.0"); - - const result = await testEnvironment({ - adapterType: "acpx_local", - companyId: "test-company", - config: { agent: "claude" }, - }); - - expect(result.status).toBe("pass"); - expect(result.checks).toContainEqual( - expect.objectContaining({ - code: "acpx_agent_selected", - level: "info", - message: "ACP agent selected: claude", - }), - ); - expect(result.checks).toContainEqual( - expect.objectContaining({ - code: "acpx_runtime_scaffold", - level: "info", - }), - ); - expect(result.checks).not.toContainEqual( - expect.objectContaining({ - code: "acpx_runtime_scaffold", - level: "warn", - }), - ); - }); -}); diff --git a/packages/adapters/acpx-local/src/server/test.ts b/packages/adapters/acpx-local/src/server/test.ts deleted file mode 100644 index f19304e8d6..0000000000 --- a/packages/adapters/acpx-local/src/server/test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { createRequire } from "node:module"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { - AdapterEnvironmentCheck, - AdapterEnvironmentTestContext, - AdapterEnvironmentTestResult, -} from "@paperclipai/adapter-utils"; -import { - asString, - parseObject, -} from "@paperclipai/adapter-utils/server-utils"; - -const require = createRequire(import.meta.url); -const MIN_NODE_MAJOR = 22; -const MIN_NODE_MINOR = 12; -const MIN_NODE_PATCH = 0; - -function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { - if (checks.some((check) => check.level === "error")) return "fail"; - if (checks.some((check) => check.level === "warn")) return "warn"; - return "pass"; -} - -function nodeVersionMeetsMinimum(version: string): boolean { - const [major = 0, minor = 0, patch = 0] = version - .replace(/^v/, "") - .split(".") - .map((part) => Number.parseInt(part, 10)); - if (major > MIN_NODE_MAJOR) return true; - if (major < MIN_NODE_MAJOR) return false; - if (minor > MIN_NODE_MINOR) return true; - if (minor < MIN_NODE_MINOR) return false; - return patch >= MIN_NODE_PATCH; -} - -function isNonEmpty(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function getStringEnv(configEnv: Record, key: string): string | undefined { - const configured = configEnv[key]; - if (typeof configured === "string") return configured; - return process.env[key]; -} - -function credentialSource(configEnv: Record, key: string): string { - return typeof configEnv[key] === "string" ? "adapter config env" : "server environment"; -} - -async function readJsonObject(filePath: string): Promise | null> { - try { - const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? parsed as Record - : null; - } catch { - return null; - } -} - -function readNestedString(record: Record, pathSegments: string[]): string | null { - let current: unknown = record; - for (const segment of pathSegments) { - if (typeof current !== "object" || current === null || Array.isArray(current)) return null; - current = (current as Record)[segment]; - } - return isNonEmpty(current) ? current.trim() : null; -} - -async function hasClaudeSubscriptionCredentials(configDir: string): Promise { - for (const filename of [".credentials.json", "credentials.json"]) { - const credentials = await readJsonObject(path.join(configDir, filename)); - if (!credentials) continue; - if (readNestedString(credentials, ["claudeAiOauth", "accessToken"])) return true; - } - return false; -} - -async function hasCodexNativeCredentials(codexHome: string): Promise { - const auth = await readJsonObject(path.join(codexHome, "auth.json")); - if (!auth) return false; - return Boolean( - readNestedString(auth, ["accessToken"]) || - readNestedString(auth, ["tokens", "access_token"]) || - readNestedString(auth, ["OPENAI_API_KEY"]), - ); -} - -async function buildCredentialHintChecks( - agent: string, - configEnv: Record, -): Promise { - if (agent === "claude") { - const bedrockFlag = getStringEnv(configEnv, "CLAUDE_CODE_USE_BEDROCK"); - const bedrockBaseUrl = getStringEnv(configEnv, "ANTHROPIC_BEDROCK_BASE_URL"); - const hasBedrock = - bedrockFlag === "1" || - /^true$/i.test(bedrockFlag ?? "") || - isNonEmpty(bedrockBaseUrl); - const bedrockSourceKey = isNonEmpty(bedrockFlag) - ? "CLAUDE_CODE_USE_BEDROCK" - : "ANTHROPIC_BEDROCK_BASE_URL"; - const anthropicApiKey = getStringEnv(configEnv, "ANTHROPIC_API_KEY"); - const claudeConfigDir = isNonEmpty(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR")) - ? path.resolve(getStringEnv(configEnv, "CLAUDE_CONFIG_DIR") as string) - : path.join(os.homedir(), ".claude"); - - if (hasBedrock) { - return [{ - code: "acpx_claude_bedrock_auth_detected", - level: "info", - message: "Claude credential hint: Bedrock auth indicators are configured.", - detail: `Detected in ${credentialSource(configEnv, bedrockSourceKey)}.`, - hint: "Ensure AWS credentials and AWS_REGION are available to the ACPX-launched Claude agent.", - }]; - } - - if (isNonEmpty(anthropicApiKey)) { - return [{ - code: "acpx_claude_anthropic_api_key_detected", - level: "info", - message: "Claude credential hint: ANTHROPIC_API_KEY is set.", - detail: `Detected in ${credentialSource(configEnv, "ANTHROPIC_API_KEY")}.`, - }]; - } - - if (await hasClaudeSubscriptionCredentials(claudeConfigDir)) { - return [{ - code: "acpx_claude_subscription_auth_detected", - level: "info", - message: "Claude credential hint: local Claude subscription credentials were found.", - detail: `Credentials found in ${claudeConfigDir}.`, - }]; - } - - return [{ - code: "acpx_claude_credentials_missing", - level: "info", - message: "Claude credential hint: no Claude API, Bedrock, or local subscription credentials were detected.", - hint: "Set ANTHROPIC_API_KEY, configure Bedrock, or run `claude login` before starting an ACPX Claude agent.", - }]; - } - - if (agent === "codex") { - const openAiApiKey = getStringEnv(configEnv, "OPENAI_API_KEY"); - const codexHome = isNonEmpty(getStringEnv(configEnv, "CODEX_HOME")) - ? path.resolve(getStringEnv(configEnv, "CODEX_HOME") as string) - : path.join(os.homedir(), ".codex"); - - if (isNonEmpty(openAiApiKey)) { - return [{ - code: "acpx_codex_openai_api_key_detected", - level: "info", - message: "Codex credential hint: OPENAI_API_KEY is set.", - detail: `Detected in ${credentialSource(configEnv, "OPENAI_API_KEY")}.`, - }]; - } - - if (await hasCodexNativeCredentials(codexHome)) { - return [{ - code: "acpx_codex_native_auth_detected", - level: "info", - message: "Codex credential hint: local Codex auth configuration was found.", - detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`, - }]; - } - - return [{ - code: "acpx_codex_credentials_missing", - level: "info", - message: "Codex credential hint: no OpenAI API key or local Codex auth configuration was detected.", - hint: "Set OPENAI_API_KEY or run `codex login` before starting an ACPX Codex agent.", - }]; - } - - return []; -} - -function resolvePackage(name: string): AdapterEnvironmentCheck { - try { - const resolved = require.resolve(`${name}/package.json`); - return { - code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_present`, - level: "info", - message: `${name} is resolvable.`, - detail: resolved, - }; - } catch { - return { - code: `acpx_package_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_missing`, - level: "error", - message: `${name} is not resolvable from the acpx_local adapter package.`, - hint: "Run pnpm install so the ACPX adapter dependencies are installed.", - }; - } -} - -async function checkDirectory(pathValue: string, code: string, label: string): Promise { - const dir = pathValue.trim(); - if (!dir) return null; - try { - await fs.mkdir(dir, { recursive: true }); - await fs.access(dir); - return { - code, - level: "info", - message: `${label} is writable: ${dir}`, - }; - } catch (err) { - return { - code: `${code}_invalid`, - level: "error", - message: err instanceof Error ? err.message : `${label} is not writable.`, - detail: dir, - }; - } -} - -export async function testEnvironment( - ctx: AdapterEnvironmentTestContext, -): Promise { - const config = parseObject(ctx.config); - const envConfig = parseObject(config.env); - const configEnv: Record = {}; - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") configEnv[key] = value; - } - const checks: AdapterEnvironmentCheck[] = []; - const nodeVersion = process.version; - - checks.push({ - code: nodeVersionMeetsMinimum(nodeVersion) ? "acpx_node_supported" : "acpx_node_unsupported", - level: nodeVersionMeetsMinimum(nodeVersion) ? "info" : "error", - message: nodeVersionMeetsMinimum(nodeVersion) - ? `Node ${nodeVersion} satisfies ACPX's >=22.12.0 requirement.` - : `Node ${nodeVersion} does not satisfy ACPX's >=22.12.0 requirement.`, - hint: nodeVersionMeetsMinimum(nodeVersion) - ? undefined - : "Run acpx_local agents with Node >=22.12.0 or use claude_local/codex_local on Node 20.", - }); - - checks.push(resolvePackage("acpx")); - checks.push(resolvePackage("@agentclientprotocol/claude-agent-acp")); - checks.push(resolvePackage("@zed-industries/codex-acp")); - - const agent = asString(config.agent, "claude"); - if (!["claude", "codex", "custom"].includes(agent)) { - checks.push({ - code: "acpx_agent_invalid", - level: "error", - message: `Unsupported ACP agent: ${agent}`, - hint: "Use agent=claude, agent=codex, or agent=custom.", - }); - } else { - checks.push({ - code: "acpx_agent_selected", - level: "info", - message: `ACP agent selected: ${agent}`, - }); - checks.push(...await buildCredentialHintChecks(agent, configEnv)); - } - - if (agent === "custom" && !asString(config.agentCommand, "")) { - checks.push({ - code: "acpx_custom_command_missing", - level: "error", - message: "agentCommand is required when agent=custom.", - }); - } - - const stateDirCheck = await checkDirectory(asString(config.stateDir, ""), "acpx_state_dir_writable", "ACPX state directory"); - if (stateDirCheck) checks.push(stateDirCheck); - - const permissionMode = asString(config.permissionMode, "approve-all"); - checks.push({ - code: "acpx_permission_mode", - level: "info", - message: `Effective permission mode: ${permissionMode || "approve-all"}`, - }); - - checks.push({ - code: "acpx_runtime_scaffold", - level: "info", - message: "acpx_local runtime execution is available through the bundled ACPX runtime.", - }); - - return { - adapterType: ctx.adapterType, - status: summarizeStatus(checks), - checks, - testedAt: new Date().toISOString(), - }; -} diff --git a/packages/adapters/acpx-local/src/ui/build-config.ts b/packages/adapters/acpx-local/src/ui/build-config.ts deleted file mode 100644 index 729d16c12c..0000000000 --- a/packages/adapters/acpx-local/src/ui/build-config.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; -import { - DEFAULT_ACPX_LOCAL_AGENT, - DEFAULT_ACPX_LOCAL_MODE, - DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, - DEFAULT_ACPX_LOCAL_PERMISSION_MODE, - DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, - DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS, -} from "../index.js"; - -function parseCommaArgs(value: string): string[] { - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -function parseEnvVars(text: string): Record { - const env: Record = {}; - for (const line of text.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq <= 0) continue; - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; - env[key] = value; - } - return env; -} - -function parseEnvBindings(bindings: unknown): Record { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - for (const [key, raw] of Object.entries(bindings)) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; - if (typeof raw === "string") { - env[key] = { type: "plain", value: raw }; - continue; - } - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; - const rec = raw as Record; - if (rec.type === "plain" && typeof rec.value === "string") { - env[key] = { type: "plain", value: rec.value }; - continue; - } - if (rec.type === "secret_ref" && typeof rec.secretId === "string") { - env[key] = { - type: "secret_ref", - secretId: rec.secretId, - ...(typeof rec.version === "number" || rec.version === "latest" - ? { version: rec.version } - : {}), - }; - } - } - return env; -} - -function parseJsonObject(text: string): Record | null { - const trimmed = text.trim(); - if (!trimmed) return null; - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; - return parsed as Record; - } catch { - return null; - } -} - -function readNumber(value: unknown, fallback: number): number { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return fallback; -} - -export function buildAcpxLocalConfig(v: CreateConfigValues): Record { - const schemaValues = v.adapterSchemaValues ?? {}; - const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT); - const ac: Record = { - agent, - mode: schemaValues.mode || DEFAULT_ACPX_LOCAL_MODE, - permissionMode: schemaValues.permissionMode || DEFAULT_ACPX_LOCAL_PERMISSION_MODE, - nonInteractivePermissions: - schemaValues.nonInteractivePermissions || DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, - timeoutSec: readNumber(schemaValues.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC), - warmHandleIdleMs: readNumber(schemaValues.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS), - }; - - for (const key of [ - "agentCommand", - "cwd", - "stateDir", - "instructionsFilePath", - "promptTemplate", - "bootstrapPromptTemplate", - ]) { - const value = schemaValues[key]; - if (typeof value === "string" && value.trim()) ac[key] = value.trim(); - } - - if (!ac.cwd && v.cwd) ac.cwd = v.cwd; - if (!ac.instructionsFilePath && v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; - if (!ac.promptTemplate && v.promptTemplate) ac.promptTemplate = v.promptTemplate; - if (!ac.bootstrapPromptTemplate && v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; - if (v.model?.trim()) ac.model = v.model.trim(); - if (v.thinkingEffort) { - ac[agent === "codex" ? "modelReasoningEffort" : "effort"] = v.thinkingEffort; - } - if (schemaValues.fastMode === true) ac.fastMode = true; - - const env = parseEnvBindings(v.envBindings); - const legacy = parseEnvVars(v.envVars); - for (const [key, value] of Object.entries(legacy)) { - if (!Object.prototype.hasOwnProperty.call(env, key)) { - env[key] = { type: "plain", value }; - } - } - if (typeof schemaValues.env === "string") { - const schemaEnv = parseJsonObject(schemaValues.env); - if (schemaEnv) Object.assign(env, schemaEnv); - } else if (typeof schemaValues.env === "object" && schemaValues.env !== null && !Array.isArray(schemaValues.env)) { - Object.assign(env, schemaValues.env as Record); - } - if (Object.keys(env).length > 0) ac.env = env; - - if (v.workspaceStrategyType === "git_worktree") { - ac.workspaceStrategy = { - type: "git_worktree", - ...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}), - ...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}), - ...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}), - }; - } - const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? ""); - if (runtimeServices && Array.isArray(runtimeServices.services)) { - ac.workspaceRuntime = runtimeServices; - } - if (v.command) ac.command = v.command; - if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); - return ac; -} diff --git a/packages/adapters/acpx-local/src/ui/index.ts b/packages/adapters/acpx-local/src/ui/index.ts deleted file mode 100644 index 629baaad40..0000000000 --- a/packages/adapters/acpx-local/src/ui/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { parseAcpxStdoutLine } from "./parse-stdout.js"; -export { buildAcpxLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts b/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts deleted file mode 100644 index 932ffbe74e..0000000000 --- a/packages/adapters/acpx-local/src/ui/parse-stdout.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseAcpxStdoutLine } from "./parse-stdout.js"; - -const TS = "2026-04-30T00:00:00.000Z"; - -function emit(payload: Record): string { - return JSON.stringify(payload); -} - -describe("parseAcpxStdoutLine", () => { - it("renders an init entry from acpx.session", () => { - const entries = parseAcpxStdoutLine( - emit({ - type: "acpx.session", - agent: "claude", - acpSessionId: "acp-1", - runtimeSessionName: "runtime-1", - mode: "persistent", - permissionMode: "approve-all", - }), - TS, - ); - expect(entries).toEqual([ - { - kind: "init", - ts: TS, - model: "claude (persistent / approve-all)", - sessionId: "acp-1", - }, - ]); - }); - - it("routes output text_delta to the assistant transcript", () => { - const entries = parseAcpxStdoutLine( - emit({ type: "acpx.text_delta", text: "hello", channel: "output", tag: "agent_message_chunk" }), - TS, - ); - expect(entries).toEqual([ - { kind: "assistant", ts: TS, text: "hello", delta: true }, - ]); - }); - - it("routes thought text_delta to the thinking transcript", () => { - const entries = parseAcpxStdoutLine( - emit({ type: "acpx.text_delta", text: "thinking…", channel: "thought" }), - TS, - ); - expect(entries).toEqual([ - { kind: "thinking", ts: TS, text: "thinking…", delta: true }, - ]); - }); - - it("falls back to stream when channel is missing", () => { - const entries = parseAcpxStdoutLine( - emit({ type: "acpx.text_delta", text: "thinking…", stream: "thought" }), - TS, - ); - expect(entries[0]).toMatchObject({ kind: "thinking" }); - }); - - it("renders status events as system text with optional ctx usage", () => { - expect( - parseAcpxStdoutLine( - emit({ type: "acpx.status", text: "thinking", tag: "agent_thought_chunk" }), - TS, - ), - ).toEqual([{ kind: "system", ts: TS, text: "thinking" }]); - - expect( - parseAcpxStdoutLine( - emit({ type: "acpx.status", tag: "context_window", used: 12000, size: 200000 }), - TS, - ), - ).toEqual([{ kind: "system", ts: TS, text: "context_window (12000/200000 ctx)" }]); - }); - - it("emits a tool_call entry that preserves toolCallId, status, and input", () => { - const entries = parseAcpxStdoutLine( - emit({ - type: "acpx.tool_call", - name: "read", - toolCallId: "tool-1", - status: "running", - text: "read README.md", - }), - TS, - ); - expect(entries).toEqual([ - { - kind: "tool_call", - ts: TS, - name: "read", - toolUseId: "tool-1", - input: { text: "read README.md", status: "running" }, - }, - ]); - }); - - it("merges explicit tool_call input payload with status text", () => { - const entries = parseAcpxStdoutLine( - emit({ - type: "acpx.tool_call", - name: "read", - toolCallId: "tool-3", - status: "in_progress", - text: "reading README.md", - input: { file: "README.md" }, - }), - TS, - ); - expect(entries).toEqual([ - { - kind: "tool_call", - ts: TS, - name: "read", - toolUseId: "tool-3", - input: { file: "README.md", status: "in_progress", text: "reading README.md" }, - }, - ]); - }); - - it("keeps terminal tool_call status while preserving existing input", () => { - const entries = parseAcpxStdoutLine( - emit({ - type: "acpx.tool_call", - name: "read", - toolCallId: "tool-4", - status: "completed", - text: "ok", - input: { file: "README.md", status: "running" }, - }), - TS, - ); - expect(entries).toEqual([ - { - kind: "tool_call", - ts: TS, - name: "read", - toolUseId: "tool-4", - input: { file: "README.md", status: "running", text: "ok" }, - }, - { - kind: "tool_result", - ts: TS, - toolUseId: "tool-4", - toolName: "read", - content: "ok", - isError: false, - }, - ]); - }); - - it("emits a paired tool_result entry when a tool_call reports terminal status", () => { - const completed = parseAcpxStdoutLine( - emit({ - type: "acpx.tool_call", - name: "read", - toolCallId: "tool-1", - status: "completed", - text: "ok", - }), - TS, - ); - expect(completed[1]).toEqual({ - kind: "tool_result", - ts: TS, - toolUseId: "tool-1", - toolName: "read", - content: "ok", - isError: false, - }); - - const failed = parseAcpxStdoutLine( - emit({ - type: "acpx.tool_call", - name: "edit", - toolCallId: "tool-2", - status: "failed", - text: "permission denied", - }), - TS, - ); - expect(failed[1]).toMatchObject({ kind: "tool_result", isError: true, content: "permission denied" }); - }); - - it("renders acpx.result with summary fallback to stopReason", () => { - const entries = parseAcpxStdoutLine( - emit({ type: "acpx.result", summary: "completed", stopReason: "end_turn" }), - TS, - ); - expect(entries[0]).toMatchObject({ kind: "result", text: "completed", subtype: "end_turn", isError: false }); - }); - - it("treats acpx.error as a stderr entry", () => { - const entries = parseAcpxStdoutLine( - emit({ type: "acpx.error", message: "auth required", code: "ACP_AUTH" }), - TS, - ); - expect(entries).toEqual([{ kind: "stderr", ts: TS, text: "auth required" }]); - }); - - it("renders unknown acpx.* events as system entries", () => { - const entries = parseAcpxStdoutLine( - emit({ type: "acpx.misc", message: "unhandled" }), - TS, - ); - expect(entries).toEqual([{ kind: "system", ts: TS, text: "unhandled" }]); - }); - - it("falls back to a stdout entry for non-JSON lines", () => { - const entries = parseAcpxStdoutLine("not json", TS); - expect(entries).toEqual([{ kind: "stdout", ts: TS, text: "not json" }]); - }); -}); diff --git a/packages/adapters/acpx-local/tsconfig.json b/packages/adapters/acpx-local/tsconfig.json deleted file mode 100644 index e1b71318a6..0000000000 --- a/packages/adapters/acpx-local/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/packages/adapters/acpx-local/vitest.config.ts b/packages/adapters/acpx-local/vitest.config.ts deleted file mode 100644 index f624398e8d..0000000000 --- a/packages/adapters/acpx-local/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - }, -}); diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index 600a2269c1..dd87f919e3 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -53,6 +53,7 @@ "probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.52.0", "@paperclipai/adapter-utils": "workspace:*", "picocolors": "^1.1.1" }, diff --git a/packages/adapters/claude-local/src/cli/format-event.ts b/packages/adapters/claude-local/src/cli/format-event.ts index 13263be6d4..a675c85411 100644 --- a/packages/adapters/claude-local/src/cli/format-event.ts +++ b/packages/adapters/claude-local/src/cli/format-event.ts @@ -1,4 +1,5 @@ import pc from "picocolors"; +import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli"; function asErrorText(value: unknown): string { if (typeof value === "string") return value; @@ -51,6 +52,10 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { } const type = typeof parsed.type === "string" ? parsed.type : ""; + if (type.startsWith("acpx.")) { + printAcpxStreamEvent(line, debug); + return; + } if (type === "system" && parsed.subtype === "init") { const model = typeof parsed.model === "string" ? parsed.model : "unknown"; diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 3310bc8b3f..4f365a7eb0 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -35,6 +35,7 @@ export const agentConfigurationDoc = `# claude_local agent configuration Adapter: claude_local Core fields: +- engine (string, optional): execution engine. Leave unset/auto to use ACP when prerequisites pass and fall back to the Claude Code CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP. - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime - model (string, optional): Claude model id @@ -49,10 +50,19 @@ Core fields: - workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } - workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats +ACP fields (only when engine="acp"): +- agentCommand (string, optional): override for the Claude ACP server command; defaults to the package-local claude-agent-acp binary +- mode (string, optional, default "persistent"): ACP session mode ("persistent" or "oneshot") +- stateDir (string, optional): ACP session state directory; defaults to Paperclip-managed company/agent scoped storage +- nonInteractivePermissions (string, optional, default "deny"): fallback when the ACP agent asks for input outside an interactive session +- warmHandleIdleMs (number, optional, default 0): keep the ACP process warm for this many ms after a successful run + Operational fields: - timeoutSec (number, optional): run timeout in seconds - graceSec (number, optional): SIGTERM grace period in seconds Notes: +- The Claude ACP lane requires Node >=22.12.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Auto engine selection falls back to CLI when those prerequisites are unavailable; explicit engine="acp" fails loudly. +- For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts new file mode 100644 index 0000000000..791f0b719c --- /dev/null +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -0,0 +1,375 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { + buildClaudeAcpConfig, + createClaudeAcpExecutor, + nodeVersionMeetsClaudeAcpMinimum, + resolveClaudeExecutionEngine, + resolveClaudeExecutionEngineForRun, + testClaudeAcpEnvironment, +} from "./acp.js"; + +type FakeRuntimeOptions = Record; +type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string }; +type FakeRuntimeHandle = { + sessionKey: string; + backend: string; + runtimeSessionName: string; + cwd?: string; + acpxRecordId: string; + backendSessionId: string; + agentSessionId: string; +}; +type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string }; +type FakeRuntimeTurn = { + requestId: string; + events: AsyncIterable; + result: Promise; + cancel: () => Promise; + closeStream: () => Promise; +}; + +const tempRoots: string[] = []; +const originalNodeVersion = process.version; + +function setNodeVersion(version: string): void { + Object.defineProperty(process, "version", { + configurable: true, + enumerable: true, + value: version, + }); +} + +afterEach(async () => { + setNodeVersion(originalNodeVersion); + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +class FakeRuntime { + ensureInputs: Array<{ + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + resumeSessionId?: string; + }> = []; + startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = []; + closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = []; + setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = []; + ensureCount = 0; + + constructor( + readonly options: FakeRuntimeOptions, + readonly events: FakeRuntimeEvent[] = [ + { type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" }, + ], + readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" }, + ) {} + + async ensureSession(input: { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + resumeSessionId?: string; + }): Promise { + this.ensureInputs.push(input); + this.ensureCount += 1; + return { + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `runtime-${this.ensureCount}`, + cwd: input.cwd, + acpxRecordId: `record-${this.ensureCount}`, + backendSessionId: `acp-${this.ensureCount}`, + agentSessionId: `agent-${this.ensureCount}`, + }; + } + + startTurn(input: { + handle: FakeRuntimeHandle; + text: string; + requestId: string; + timeoutMs?: number; + }): FakeRuntimeTurn { + this.startInputs.push(input); + const events = this.events; + const terminal = this.terminal; + return { + requestId: input.requestId, + events: { + [Symbol.asyncIterator]: async function* () { + for (const event of events) yield event; + }, + }, + result: Promise.resolve(terminal), + cancel: async () => {}, + closeStream: async () => {}, + }; + } + + runTurn(): AsyncIterable { + throw new Error("not used"); + } + + getCapabilities() { + return { controls: [] }; + } + + getStatus() { + return Promise.resolve({}); + } + + async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) { + this.setConfigInputs.push(input); + } + + async setMode() {} + + async cancel() {} + + async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) { + this.closeInputs.push(input); + } +} + +async function makeTempRoot(prefix: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +async function createRuntimeSkill(root: string) { + const source = path.join(root, "skills", "review"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8"); + return { + key: "company/review", + runtimeName: "review", + source, + }; +} + +function buildContext(root: string, overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude ACP", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: "PAP-1", + }, + config: { + engine: "acp", + cwd: root, + stateDir: path.join(root, "state"), + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + paperclipWorkspace: { + cwd: root, + source: "project_workspace", + workspaceId: "workspace-1", + }, + }, + onLog: async () => {}, + ...overrides, + }; +} + +describe("claude_local ACP lane", () => { + it("maps Claude config to the ACPX Claude target", () => { + expect(buildClaudeAcpConfig({ + engine: "acp", + cwd: "/repo", + model: "claude-opus-4-7", + effort: "high", + agentCommand: "custom-claude-acp", + warmHandleIdleMs: 25, + })).toMatchObject({ + agent: "claude", + cwd: "/repo", + model: "claude-opus-4-7", + effort: "high", + agentCommand: "custom-claude-acp", + mode: "persistent", + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + warmHandleIdleMs: 25, + }); + }); + + it("checks the Node version required by the Claude ACP runtime", () => { + setNodeVersion("v22.11.0"); + expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(false); + setNodeVersion("v22.12.0"); + expect(nodeVersionMeetsClaudeAcpMinimum()).toBe(true); + }); + + it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => { + const root = await makeTempRoot("paperclip-claude-acp-default-"); + const commandPath = path.join(root, "bin", "claude-agent-acp"); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + setNodeVersion("v22.12.0"); + + expect(resolveClaudeExecutionEngine({})).toEqual({ engine: "acp", explicit: false }); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { agentCommand: commandPath }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { engine: "cli", agentCommand: commandPath }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "cli", explicit: true }); + + setNodeVersion("v22.11.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { agentCommand: commandPath }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("Node"), + }); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { engine: "acp", agentCommand: "/missing/claude-agent-acp" }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "acp", explicit: true }); + }); + + it("reports ACP prerequisites for the ACP lane", async () => { + const root = await makeTempRoot("paperclip-claude-acp-env-"); + const commandPath = path.join(root, "bin", "claude-agent-acp"); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + setNodeVersion("v22.12.0"); + + const result = await testClaudeAcpEnvironment({ + adapterType: "claude_local", + companyId: "company-1", + config: { + engine: "acp", + cwd: root, + agentCommand: commandPath, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "claude_engine_selected", + level: "info", + }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "claude_acp_command_resolvable", + level: "info", + }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "claude_acp_runtime_scaffold", + level: "info", + }), + ); + }); + + it("executes through ACPX with Claude model env, settings.local.json, and ephemeral skills", async () => { + const root = await makeTempRoot("paperclip-claude-acp-exec-"); + const skill = await createRuntimeSkill(root); + const runtimes: FakeRuntime[] = []; + const meta: AdapterInvocationMeta[] = []; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime as never; + }, + }); + + const result = await execute(buildContext(root, { + config: { + engine: "acp", + cwd: root, + stateDir: path.join(root, "state"), + model: "claude-opus-4-7", + effort: "high", + promptTemplate: "Do the assigned work.", + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { desiredSkills: [skill.key] }, + }, + onMeta: async (payload: AdapterInvocationMeta) => { + meta.push(payload); + }, + })); + + expect(result.exitCode).toBe(0); + expect(result.sessionParams).toMatchObject({ + agent: "claude", + mode: "persistent", + acpSessionId: "acp-1", + workspaceId: "workspace-1", + }); + expect(result.sessionParams?.skills).toMatchObject({ + mode: "claude", + selectedSkills: ["review"], + }); + const skillRoot = (result.sessionParams?.skills as { skillRoot?: string }).skillRoot; + expect(skillRoot).toBeTruthy(); + await expect(fs.readFile(path.join(skillRoot!, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill"); + expect(runtimes[0]?.setConfigInputs.map((input) => [input.key, input.value])).toEqual([["effort", "high"]]); + expect(meta[0]?.commandNotes?.join("\n")).toContain("set via ANTHROPIC_MODEL"); + expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-opus-4-7"); + const settings = JSON.parse(await fs.readFile(path.join(root, ".claude", "settings.local.json"), "utf8")); + expect(settings.permissions.defaultMode).toBe("default"); + expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"])); + }); + + it("resumes compatible ACP sessions on later Claude ACP runs", async () => { + const root = await makeTempRoot("paperclip-claude-acp-resume-"); + const runtimes: FakeRuntime[] = []; + const execute = createClaudeAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime as never; + }, + }); + + const first = await execute(buildContext(root)); + const second = await execute(buildContext(root, { + runtime: { + sessionId: first.sessionId ?? null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: first.sessionDisplayId ?? null, + taskKey: "PAP-1", + }, + })); + + expect(second.exitCode).toBe(0); + expect(runtimes).toHaveLength(2); + expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1"); + }); +}); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts new file mode 100644 index 0000000000..63dcd13254 --- /dev/null +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -0,0 +1,348 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, + AdapterExecutionContext, + AdapterExecutionResult, +} from "@paperclipai/adapter-utils"; +import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_PERMISSION_MODE, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; +import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute"; +import { + asNumber, + asString, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRootDir = path.resolve(moduleDir, "../.."); +const MIN_ACP_NODE_VERSION = "22.12.0"; + +export type ClaudeExecutionEngine = "cli" | "acp"; + +export interface ClaudeEngineSelection { + engine: ClaudeExecutionEngine; + explicit: boolean; + fallbackReason?: string; +} + +type ClaudeEngineResolutionInput = + Pick & + Partial>; + +type ClaudeAcpExecutorOptions = Omit< + AcpxEngineExecutorOptions, + "adapterType" | "moduleDir" | "packageRootDir" +>; + +type ClaudeAcpExecutor = (ctx: AdapterExecutionContext) => Promise; + +function normalizeEngine(value: unknown): ClaudeEngineSelection { + const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (raw === "acp") return { engine: "acp", explicit: true }; + if (raw === "cli") return { engine: "cli", explicit: true }; + return { engine: "acp", explicit: false }; +} + +export function resolveClaudeExecutionEngine(config: Record): ClaudeEngineSelection { + return normalizeEngine(config.engine); +} + +export async function resolveClaudeExecutionEngineForRun( + input: ClaudeEngineResolutionInput, +): Promise { + const selection = normalizeEngine(input.config.engine); + if (selection.explicit || selection.engine !== "acp") return selection; + + const fallbackReason = await defaultClaudeAcpFallbackReason(input); + if (!fallbackReason) return selection; + return { engine: "cli", explicit: false, fallbackReason }; +} + +export function formatClaudeAcpFallbackMessage(reason: string): string { + return `[paperclip] Claude ACP default unavailable; falling back to Claude CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed.length > 0) return trimmed; + } + return undefined; +} + +export function buildClaudeAcpConfig(config: Record): Record { + const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir); + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const permissionMode = + firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ?? + DEFAULT_ACP_ENGINE_PERMISSION_MODE; + const nonInteractivePermissions = + firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ?? + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS; + const warmHandleIdleMs = + config.warmHandleIdleMs ?? + config.acpWarmHandleIdleMs ?? + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS; + + return { + ...config, + agent: "claude", + mode, + permissionMode, + nonInteractivePermissions, + warmHandleIdleMs, + ...(agentCommand ? { agentCommand } : {}), + ...(stateDir ? { stateDir } : {}), + }; +} + +function withClaudeAcpDefaults(options: ClaudeAcpExecutorOptions): AcpxEngineExecutorOptions { + return { + ...options, + adapterType: "claude_local", + moduleDir, + packageRootDir, + }; +} + +export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ClaudeAcpExecutor { + let executor: ClaudeAcpExecutor | null = null; + return async (ctx) => { + let currentExecutor = executor; + if (!currentExecutor) { + const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute"); + currentExecutor = createAcpxEngineExecutor(withClaudeAcpDefaults(options)); + executor = currentExecutor; + } + return currentExecutor({ + ...ctx, + config: buildClaudeAcpConfig(ctx.config), + }); + }; +} + +function parseVersion(version: string): [number, number, number] { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function nodeVersionMeetsClaudeAcpMinimum(version = process.version): boolean { + const [major, minor, patch] = parseVersion(version); + const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION); + if (major !== minMajor) return major > minMajor; + if (minor !== minMinor) return minor > minMinor; + return patch >= minPatch; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +function hasPathSeparator(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +function looksLikeShellCommand(command: string): boolean { + return /\s/.test(command.trim()); +} + +async function findCommandOnPath(binName: string): Promise { + const pathValue = process.env.PATH ?? ""; + for (const segment of pathValue.split(path.delimiter)) { + if (!segment) continue; + const candidate = path.join(segment, binName); + if (await pathExists(candidate)) return candidate; + } + return null; +} + +async function findAncestorBin(startDir: string, binName: string): Promise { + let current = path.resolve(startDir); + while (true) { + const candidate = path.join(current, "node_modules", ".bin", binName); + if (await pathExists(candidate)) return candidate; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +async function commandIsResolvable(command: string): Promise { + const trimmed = command.trim(); + if (!trimmed) return false; + if (looksLikeShellCommand(trimmed)) return true; + if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed); + return (await findCommandOnPath(trimmed)) !== null; +} + +async function resolveClaudeAcpCommand(config: Record): Promise { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + return ( + (await findAncestorBin(packageRootDir, "claude-agent-acp")) ?? + (await findCommandOnPath("claude-agent-acp")) ?? + path.join(packageRootDir, "node_modules", ".bin", "claude-agent-acp") + ); +} + +async function defaultClaudeAcpFallbackReason( + input: ClaudeEngineResolutionInput, +): Promise { + const target = readAdapterExecutionTarget({ + executionTarget: input.executionTarget, + legacyRemoteExecution: input.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + return "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + } + if (!nodeVersionMeetsClaudeAcpMinimum()) { + return `Node ${process.version} does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + } + const command = await resolveClaudeAcpCommand(input.config); + if (!(await commandIsResolvable(command))) { + return `Claude ACP server command is not available: ${command}.`; + } + return null; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export async function testClaudeAcpEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + + checks.push({ + code: "claude_engine_selected", + level: "info", + message: "Execution engine selected: ACP.", + hint: "Set engine=cli to use the existing Claude Code CLI lane.", + }); + + if (targetIsRemote) { + checks.push({ + code: "claude_acp_remote_target_unsupported", + level: "error", + message: "Claude ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", + hint: "Use engine=cli for remote or sandbox Claude runs.", + }); + } + + const cwd = asString(config.cwd, process.cwd()); + try { + await fs.mkdir(cwd, { recursive: true }); + checks.push({ + code: "claude_acp_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "claude_acp_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + checks.push({ + code: nodeVersionMeetsClaudeAcpMinimum() ? "claude_acp_node_supported" : "claude_acp_node_unsupported", + level: nodeVersionMeetsClaudeAcpMinimum() ? "info" : "error", + message: nodeVersionMeetsClaudeAcpMinimum() + ? `Node ${process.version} satisfies Claude ACP runtime requirements.` + : `Node ${process.version} does not satisfy Claude ACP runtime requirements.`, + hint: nodeVersionMeetsClaudeAcpMinimum() + ? undefined + : `Run Claude ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, + }); + + const command = await resolveClaudeAcpCommand(config); + const commandResolvable = await commandIsResolvable(command); + checks.push({ + code: commandResolvable ? "claude_acp_command_resolvable" : "claude_acp_command_missing", + level: commandResolvable ? "info" : "error", + message: commandResolvable + ? `Claude ACP server command is executable: ${command}` + : `Claude ACP server command is not available: ${command}`, + hint: commandResolvable + ? undefined + : "Install dependencies so @agentclientprotocol/claude-agent-acp is present, or set agentCommand to a valid Claude ACP server command.", + }); + + const envConfig = parseObject(config.env); + const considerHostEnv = !targetIsRemote; + const hasBedrock = + envConfig.CLAUDE_CODE_USE_BEDROCK === "1" || + envConfig.CLAUDE_CODE_USE_BEDROCK === "true" || + (considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") || + (considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") || + isNonEmpty(envConfig.ANTHROPIC_BEDROCK_BASE_URL) || + (considerHostEnv && isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL)); + const configApiKey = envConfig.ANTHROPIC_API_KEY; + const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined; + if (hasBedrock) { + checks.push({ + code: "claude_acp_bedrock_auth", + level: "info", + message: "AWS Bedrock auth detected. Claude ACP will use Bedrock for inference.", + hint: "Ensure AWS credentials and AWS_REGION are configured in this environment.", + }); + } else if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) { + const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment"; + checks.push({ + code: "claude_acp_anthropic_api_key_detected", + level: "warn", + message: "ANTHROPIC_API_KEY is set. Claude ACP will use API-key auth instead of subscription credentials.", + detail: `Detected in ${source}.`, + hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.", + }); + } else if (!targetIsRemote) { + checks.push({ + code: "claude_acp_subscription_mode_possible", + level: "info", + message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.", + }); + } + + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const warmHandleIdleMs = asNumber( + config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + ); + checks.push({ + code: "claude_acp_runtime_scaffold", + level: "info", + message: "Claude ACP runtime execution is available through the shared ACP engine.", + detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`, + }); + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/claude-local/src/server/config-schema.ts b/packages/adapters/claude-local/src/server/config-schema.ts new file mode 100644 index 0000000000..099bacf94c --- /dev/null +++ b/packages/adapters/claude-local/src/server/config-schema.ts @@ -0,0 +1,73 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; + +const acpVisible = { visibleWhen: { key: "engine", values: ["acp"] } }; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "engine", + label: "Execution engine", + type: "select", + default: "auto", + options: [ + { value: "auto", label: "Auto (ACP preferred)" }, + { value: "cli", label: "Claude CLI" }, + { value: "acp", label: "ACP" }, + ], + hint: "Auto uses ACP when prerequisites pass and falls back to Claude CLI with diagnostics.", + }, + { + key: "agentCommand", + label: "ACP server command", + type: "text", + hint: "Optional override for the Claude ACP server command. Defaults to the package-local claude-agent-acp binary.", + meta: acpVisible, + }, + { + key: "mode", + label: "ACP session mode", + type: "select", + default: DEFAULT_ACP_ENGINE_MODE, + options: [ + { value: "persistent", label: "Persistent" }, + { value: "oneshot", label: "One-shot" }, + ], + hint: "Persistent keeps ACP session state between runs. One-shot starts fresh each run.", + meta: acpVisible, + }, + { + key: "nonInteractivePermissions", + label: "ACP non-interactive permissions", + type: "select", + default: DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + options: [ + { value: "deny", label: "Deny" }, + { value: "fail", label: "Fail" }, + ], + hint: "Fallback if the ACP agent asks for input outside an interactive session.", + meta: acpVisible, + }, + { + key: "stateDir", + label: "ACP state directory", + type: "text", + hint: "Optional ACP session state directory. Defaults to Paperclip-managed company/agent scoped storage.", + meta: acpVisible, + }, + { + key: "warmHandleIdleMs", + label: "ACP warm process idle ms", + type: "number", + default: DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + hint: "Defaults to 0, which closes the ACP process after each run while retaining persistent session state.", + meta: acpVisible, + }, + ], + }; +} diff --git a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts new file mode 100644 index 0000000000..a096d91372 --- /dev/null +++ b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + executeClaudeAcp, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, +} = vi.hoisted(() => ({ + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined), + executeClaudeAcp: vi.fn(async () => { + throw new Error('Transform failed with 1 error: execute.ts:818:0: ERROR: Unexpected "<<"'); + }), + resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "claude"), + runAdapterExecutionTargetProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }), + JSON.stringify({ + type: "assistant", + session_id: "claude-session-1", + message: { content: [{ type: "text", text: "hello" }] }, + }), + JSON.stringify({ + type: "result", + session_id: "claude-session-1", + result: "hello", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), +})); + +vi.mock("./acp.js", () => ({ + createClaudeAcpExecutor: () => executeClaudeAcp, + formatClaudeAcpFallbackMessage: (reason: string) => + `[paperclip] Claude ACP default unavailable; falling back to Claude CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`, + resolveClaudeExecutionEngineForRun: async (ctx: { config: Record }) => + ctx.config.engine === "acp" + ? { engine: "acp", explicit: true } + : { engine: "acp", explicit: false }, +})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + }; +}); + +import { execute } from "./execute.js"; + +function buildContext(config: Record = {}) { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config, + context: {}, + onLog: vi.fn(async () => {}), + }; +} + +describe("claude_local ACP startup fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("falls back to Claude CLI when auto-selected ACP fails before execution starts", async () => { + const ctx = buildContext(); + + const result = await execute(ctx as never); + + expect(result.exitCode).toBe(0); + expect(executeClaudeAcp).toHaveBeenCalledTimes(1); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + expect(ctx.onLog).toHaveBeenCalledWith( + "stderr", + expect.stringContaining("Claude ACP startup failed"), + ); + expect(ctx.onLog).toHaveBeenCalledWith( + "stderr", + expect.stringContaining('Unexpected "<<"'), + ); + }); + + it("keeps explicit ACP strict when startup fails", async () => { + const ctx = buildContext({ engine: "acp" }); + + await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"'); + + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 99bf1f1621..38d6d218e8 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -68,8 +68,14 @@ import { isBedrockModelId } from "./models.js"; import { prepareClaudePromptBundle } from "./prompt-cache.js"; import { buildClaudeExecutionPermissionArgs } from "./permissions.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { + createClaudeAcpExecutor, + formatClaudeAcpFallbackMessage, + resolveClaudeExecutionEngineForRun, +} from "./acp.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const executeClaudeAcp = createClaudeAcpExecutor(); interface ClaudeExecutionInput { runId: string; @@ -367,6 +373,23 @@ export async function runClaudeLogin(input: { } export async function execute(ctx: AdapterExecutionContext): Promise { + const engineSelection = await resolveClaudeExecutionEngineForRun(ctx); + if (engineSelection.engine === "acp") { + try { + return await executeClaudeAcp(ctx); + } catch (err) { + if (engineSelection.explicit) throw err; + const reason = err instanceof Error ? err.message : String(err); + await ctx.onLog( + "stderr", + formatClaudeAcpFallbackMessage(`Claude ACP startup failed: ${reason}`), + ); + } + } + if (!engineSelection.explicit && engineSelection.fallbackReason) { + await ctx.onLog("stderr", formatClaudeAcpFallbackMessage(engineSelection.fallbackReason)); + } + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const executionTarget = readAdapterExecutionTarget({ executionTarget: ctx.executionTarget, diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 4d50d30cc3..17fea96235 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -1,4 +1,6 @@ export { claudeSessionCwdMatchesExecutionTarget, execute, runClaudeLogin } from "./execute.js"; +export * from "./acp.js"; +export { getConfigSchema } from "./config-schema.js"; export { listClaudeSkills, syncClaudeSkills } from "./skills.js"; export { listClaudeModels, refreshClaudeModels, resetClaudeModelsCacheForTests } from "./models.js"; export { testEnvironment } from "./test.js"; @@ -27,6 +29,7 @@ export { claudeConfigDir, } from "./quota.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; +import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec"; function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -37,7 +40,7 @@ export const sessionCodec: AdapterSessionCodec = { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; const record = raw as Record; const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); - if (!sessionId) return null; + if (!sessionId) return acpxSessionCodec.deserialize(raw); const cwd = readNonEmptyString(record.cwd) ?? readNonEmptyString(record.workdir) ?? @@ -60,7 +63,7 @@ export const sessionCodec: AdapterSessionCodec = { serialize(params: Record | null) { if (!params) return null; const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); - if (!sessionId) return null; + if (!sessionId) return acpxSessionCodec.serialize(params); const cwd = readNonEmptyString(params.cwd) ?? readNonEmptyString(params.workdir) ?? @@ -82,6 +85,11 @@ export const sessionCodec: AdapterSessionCodec = { }, getDisplayId(params: Record | null) { if (!params) return null; - return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + acpxSessionCodec.getDisplayId?.(params) ?? + null + ); }, }; diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index b07fc4a38b..caedc9d732 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -36,6 +36,7 @@ import { isBedrockModelId } from "./models.js"; import { buildClaudeProbePermissionArgs } from "./permissions.js"; import { materializeRemoteClaudeConfig, prepareClaudeConfigSeed } from "./claude-config.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; +import { resolveClaudeExecutionEngineForRun, testClaudeAcpEnvironment } from "./acp.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -88,7 +89,24 @@ function summarizeProbeDetail(stdout: string, stderr: string): string | null { export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { + const engineSelection = await resolveClaudeExecutionEngineForRun({ + config: parseObject(ctx.config), + executionTarget: ctx.executionTarget, + }); + if (engineSelection.engine === "acp") { + return testClaudeAcpEnvironment(ctx); + } + const checks: AdapterEnvironmentCheck[] = []; + if (!engineSelection.explicit && engineSelection.fallbackReason) { + checks.push({ + code: "claude_acp_default_fallback", + level: "warn", + message: "Claude ACP default is unavailable; testing the Claude CLI fallback lane.", + detail: engineSelection.fallbackReason, + hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", + }); + } const config = parseObject(ctx.config); const command = asString(config.command, "claude"); const target = ctx.executionTarget ?? null; diff --git a/packages/adapters/claude-local/src/ui/build-config.test.ts b/packages/adapters/claude-local/src/ui/build-config.test.ts new file mode 100644 index 0000000000..3f8d3626bf --- /dev/null +++ b/packages/adapters/claude-local/src/ui/build-config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildClaudeLocalConfig } from "./build-config.js"; + +function makeValues(overrides: Partial = {}): CreateConfigValues { + return { + adapterType: "claude_local", + cwd: "", + instructionsFilePath: "", + promptTemplate: "", + model: "claude-opus-4-7", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: true, + claudeEngine: "auto", + search: false, + fastMode: false, + dangerouslyBypassSandbox: false, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "", + bootstrapPrompt: "", + payloadTemplateJson: "", + workspaceStrategyType: "project_primary", + workspaceBaseRef: "", + workspaceBranchTemplate: "", + worktreeParentDir: "", + runtimeServicesJson: "", + maxTurnsPerRun: 1000, + heartbeatEnabled: false, + intervalSec: 300, + ...overrides, + }; +} + +describe("buildClaudeLocalConfig", () => { + it("omits engine for the auto default so runtime fallback remains available", () => { + const config = buildClaudeLocalConfig(makeValues({ claudeEngine: "auto" })); + + expect(config).not.toHaveProperty("engine"); + }); + + it("persists explicit engine pins", () => { + expect(buildClaudeLocalConfig(makeValues({ claudeEngine: "cli" }))).toMatchObject({ engine: "cli" }); + expect(buildClaudeLocalConfig(makeValues({ claudeEngine: "acp" }))).toMatchObject({ engine: "acp" }); + }); +}); diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index f7f20bca44..c17fcd7178 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -64,6 +64,19 @@ function parseJsonObject(text: string): Record | null { export function buildClaudeLocalConfig(v: CreateConfigValues): Record { const ac: Record = {}; + if (v.claudeEngine === "cli") ac.engine = "cli"; + if (v.claudeEngine === "acp") { + ac.engine = "acp"; + if (v.claudeAcpAgentCommand) ac.agentCommand = v.claudeAcpAgentCommand; + if (v.claudeAcpMode) ac.mode = v.claudeAcpMode; + if (v.claudeAcpNonInteractivePermissions) { + ac.nonInteractivePermissions = v.claudeAcpNonInteractivePermissions; + } + if (v.claudeAcpStateDir) ac.stateDir = v.claudeAcpStateDir; + if (typeof v.claudeAcpWarmHandleIdleMs === "number") { + ac.warmHandleIdleMs = v.claudeAcpWarmHandleIdleMs; + } + } if (v.cwd) ac.cwd = v.cwd; if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; if (v.model) ac.model = v.model; diff --git a/packages/adapters/claude-local/src/ui/parse-stdout.ts b/packages/adapters/claude-local/src/ui/parse-stdout.ts index f7bd1b2a04..d4263c8d8c 100644 --- a/packages/adapters/claude-local/src/ui/parse-stdout.ts +++ b/packages/adapters/claude-local/src/ui/parse-stdout.ts @@ -1,4 +1,5 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui"; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -41,6 +42,10 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry } const type = typeof parsed.type === "string" ? parsed.type : ""; + if (type.startsWith("acpx.")) { + return parseAcpxStdoutLine(line, ts); + } + if (type === "system" && parsed.subtype === "init") { return [ { diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index d0c259fdb6..cb098ab083 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -52,6 +52,7 @@ "probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json" }, "dependencies": { + "@agentclientprotocol/codex-acp": "^1.1.0", "@paperclipai/adapter-utils": "workspace:*", "picocolors": "^1.1.1" }, diff --git a/packages/adapters/codex-local/src/cli/format-event.ts b/packages/adapters/codex-local/src/cli/format-event.ts index 524eb8daf5..e7eb94bb97 100644 --- a/packages/adapters/codex-local/src/cli/format-event.ts +++ b/packages/adapters/codex-local/src/cli/format-event.ts @@ -1,4 +1,5 @@ import pc from "picocolors"; +import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli"; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -152,6 +153,10 @@ export function printCodexStreamEvent(raw: string, _debug: boolean): void { } const type = asString(parsed.type); + if (type.startsWith("acpx.")) { + printAcpxStreamEvent(line, _debug); + return; + } if (type === "thread.started") { const threadId = asString(parsed.thread_id); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 3a38914972..a3d1eceea5 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -69,6 +69,7 @@ export const agentConfigurationDoc = `# codex_local agent configuration Adapter: codex_local Core fields: +- engine (string, optional): leave unset/auto to use ACP when prerequisites pass and fall back to the Codex CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP. - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime - model (string, optional): Codex model id @@ -87,6 +88,11 @@ Operational fields: - timeoutSec (number, optional): run timeout in seconds - graceSec (number, optional): SIGTERM grace period in seconds - outputInactivityTimeoutMs (number | null, optional): inactivity monitor around the codex child. Resets on every parsed JSONL event from stdout. Defaults to 7 * 60_000 ms when unset or non-positive. Set to \`null\` to disable the monitor entirely (only do this for known-slow tasks; the platform-level 1h silent-run safety net still applies). On fire, the adapter sends SIGTERM to the process group, waits 5s, then SIGKILL, and surfaces the run as failed with errorMessage "monitor: no codex output for {N}m {S}s". +- agentCommand (string, optional): ACP server command override used only when engine="acp"; defaults to the package-local codex-acp binary +- mode (string, optional): ACP session mode when engine="acp"; persistent or oneshot +- nonInteractivePermissions (string, optional): ACP non-interactive permission fallback when engine="acp"; deny or fail +- stateDir (string, optional): ACP state directory override when engine="acp" +- warmHandleIdleMs (number, optional): warm ACP process idle timeout when engine="acp"; defaults to 0 Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). @@ -97,4 +103,5 @@ Notes: - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). - Fast mode is supported on GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. +- Codex ACP is the preferred auto lane when Node >=22.13.0 and the Codex ACP server are available. It reuses shared ACP prompt/runtime guidance, selected skill materialization into CODEX_HOME/skills, model/reasoning/fast-mode session config, and existing quota-window reporting. Auto selection falls back to CLI when ACP prerequisites are unavailable; explicit engine="acp" fails loudly. `; diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts new file mode 100644 index 0000000000..cfb7df5550 --- /dev/null +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -0,0 +1,406 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { + buildCodexAcpConfig, + createCodexAcpExecutor, + nodeVersionMeetsCodexAcpMinimum, + resolveCodexExecutionEngine, + resolveCodexExecutionEngineForRun, + testCodexAcpEnvironment, +} from "./acp.js"; + +type FakeRuntimeOptions = Record; +type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string }; +type FakeRuntimeHandle = { + sessionKey: string; + backend: string; + runtimeSessionName: string; + cwd?: string; + acpxRecordId: string; + backendSessionId: string; + agentSessionId: string; +}; +type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string }; +type FakeRuntimeTurn = { + requestId: string; + events: AsyncIterable; + result: Promise; + cancel: () => Promise; + closeStream: () => Promise; +}; + +const tempRoots: string[] = []; +const originalNodeVersion = process.version; +const originalPaperclipHome = process.env.PAPERCLIP_HOME; +const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + +function setNodeVersion(version: string): void { + Object.defineProperty(process, "version", { + configurable: true, + enumerable: true, + value: version, + }); +} + +afterEach(async () => { + setNodeVersion(originalNodeVersion); + if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = originalPaperclipHome; + if (originalPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId; + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +class FakeRuntime { + ensureInputs: Array<{ + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + resumeSessionId?: string; + }> = []; + startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = []; + closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = []; + setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = []; + ensureCount = 0; + + constructor( + readonly options: FakeRuntimeOptions, + readonly events: FakeRuntimeEvent[] = [ + { type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" }, + ], + readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" }, + ) {} + + async ensureSession(input: { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + resumeSessionId?: string; + }): Promise { + this.ensureInputs.push(input); + this.ensureCount += 1; + return { + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `runtime-${this.ensureCount}`, + cwd: input.cwd, + acpxRecordId: `record-${this.ensureCount}`, + backendSessionId: `acp-${this.ensureCount}`, + agentSessionId: `agent-${this.ensureCount}`, + }; + } + + startTurn(input: { + handle: FakeRuntimeHandle; + text: string; + requestId: string; + timeoutMs?: number; + }): FakeRuntimeTurn { + this.startInputs.push(input); + const events = this.events; + const terminal = this.terminal; + return { + requestId: input.requestId, + events: { + [Symbol.asyncIterator]: async function* () { + for (const event of events) yield event; + }, + }, + result: Promise.resolve(terminal), + cancel: async () => {}, + closeStream: async () => {}, + }; + } + + runTurn(): AsyncIterable { + throw new Error("not used"); + } + + getCapabilities() { + return { controls: [] }; + } + + getStatus() { + return Promise.resolve({}); + } + + async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) { + this.setConfigInputs.push(input); + } + + async setMode() {} + + async cancel() {} + + async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) { + this.closeInputs.push(input); + } +} + +async function makeTempRoot(prefix: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(root); + process.env.PAPERCLIP_HOME = path.join(root, "paperclip-home"); + process.env.PAPERCLIP_INSTANCE_ID = "test"; + return root; +} + +async function createRuntimeSkill(root: string) { + const source = path.join(root, "skills", "review"); + await fs.mkdir(source, { recursive: true }); + await fs.writeFile(path.join(source, "SKILL.md"), "---\n---\nUse the review skill.\n", "utf8"); + return { + key: "company/review", + runtimeName: "review", + source, + }; +} + +function buildContext(root: string, overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex ACP", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: "PAP-1", + }, + config: { + engine: "acp", + cwd: root, + stateDir: path.join(root, "state"), + env: { + CODEX_HOME: path.join(root, "codex-home"), + }, + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + paperclipWorkspace: { + cwd: root, + source: "project_workspace", + workspaceId: "workspace-1", + }, + }, + onLog: async () => {}, + ...overrides, + }; +} + +describe("codex_local ACP lane", () => { + it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => { + const root = await makeTempRoot("paperclip-codex-acp-default-"); + const commandPath = path.join(root, "bin", "codex-acp"); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + setNodeVersion("v22.13.0"); + + expect(resolveCodexExecutionEngine({})).toEqual({ engine: "acp", explicit: false }); + await expect( + resolveCodexExecutionEngineForRun({ + config: { agentCommand: commandPath }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + await expect( + resolveCodexExecutionEngineForRun({ + config: { engine: "cli", agentCommand: commandPath }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "cli", explicit: true }); + expect(resolveCodexExecutionEngine({ engine: "acp" })).toEqual({ + engine: "acp", + explicit: true, + }); + + setNodeVersion("v22.12.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: { agentCommand: commandPath }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("Node"), + }); + await expect( + resolveCodexExecutionEngineForRun({ + config: { engine: "acp", agentCommand: "/missing/codex-acp" }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "acp", explicit: true }); + }); + + it("maps Codex config to the ACPX Codex target", () => { + expect(buildCodexAcpConfig({ + engine: "acp", + cwd: "/repo", + command: "codex", + model: "gpt-5.5", + modelReasoningEffort: "high", + fastMode: true, + agentCommand: "custom-codex-acp", + warmHandleIdleMs: 25, + })).toMatchObject({ + agent: "codex", + cwd: "/repo", + command: "codex", + model: "gpt-5.5", + modelReasoningEffort: "high", + fastMode: true, + agentCommand: "custom-codex-acp", + mode: "persistent", + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + warmHandleIdleMs: 25, + }); + }); + + it("checks the Node version required by the ACPX runtime", () => { + setNodeVersion("v22.12.0"); + expect(nodeVersionMeetsCodexAcpMinimum()).toBe(false); + setNodeVersion("v22.13.0"); + expect(nodeVersionMeetsCodexAcpMinimum()).toBe(true); + }); + + it("reports ACP prerequisites for the ACP lane", async () => { + const root = await makeTempRoot("paperclip-codex-acp-env-"); + const commandPath = path.join(root, "bin", "codex-acp"); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + setNodeVersion("v22.13.0"); + + const result = await testCodexAcpEnvironment({ + adapterType: "codex_local", + companyId: "company-1", + config: { + engine: "acp", + cwd: root, + agentCommand: commandPath, + env: { OPENAI_API_KEY: "test-key" }, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "codex_engine_selected", + level: "info", + }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "codex_acp_command_resolvable", + level: "info", + }), + ); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "codex_acp_runtime_scaffold", + level: "info", + }), + ); + }); + + it("executes through ACPX with Codex session config and ephemeral skills", async () => { + const root = await makeTempRoot("paperclip-codex-acp-exec-"); + const skill = await createRuntimeSkill(root); + const runtimes: FakeRuntime[] = []; + const meta: AdapterInvocationMeta[] = []; + const execute = createCodexAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime as never; + }, + }); + + const result = await execute(buildContext(root, { + config: { + engine: "acp", + cwd: root, + stateDir: path.join(root, "state"), + env: { + CODEX_HOME: path.join(root, "codex-home"), + }, + model: "gpt-5.5", + modelReasoningEffort: "high", + fastMode: true, + promptTemplate: "Do the assigned work.", + paperclipRuntimeSkills: [skill], + paperclipSkillSync: { desiredSkills: [skill.key] }, + }, + onMeta: async (payload: AdapterInvocationMeta) => { + meta.push(payload); + }, + })); + + expect(result.exitCode).toBe(0); + expect(result.sessionParams).toMatchObject({ + agent: "codex", + mode: "persistent", + acpSessionId: "acp-1", + workspaceId: "workspace-1", + }); + expect(result.sessionParams?.skills).toMatchObject({ + mode: "codex", + selectedSkills: ["review"], + }); + const skillsHome = (result.sessionParams?.skills as { skillsHome?: string }).skillsHome; + expect(skillsHome).toBeTruthy(); + await expect(fs.readFile(path.join(skillsHome!, "review", "SKILL.md"), "utf8")).resolves.toContain("review skill"); + expect(runtimes[0]?.ensureInputs[0]).toMatchObject({ + agent: "codex", + mode: "persistent", + cwd: root, + }); + expect(runtimes[0]?.setConfigInputs.map((input) => [input.key, input.value])).toEqual([ + ["model", "gpt-5.5"], + ["reasoning_effort", "high"], + ["service_tier", "fast"], + ["features.fast_mode", "true"], + ]); + expect(meta[0]?.commandNotes?.join("\n")).toContain("Prepared ACPX Codex skill home"); + expect(meta[0]?.env?.CODEX_HOME).toBe(path.join(root, "codex-home")); + }); + + it("resumes compatible ACP sessions on later Codex ACP runs", async () => { + const root = await makeTempRoot("paperclip-codex-acp-resume-"); + const runtimes: FakeRuntime[] = []; + const execute = createCodexAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => { + const runtime = new FakeRuntime(options); + runtimes.push(runtime); + return runtime as never; + }, + }); + + const first = await execute(buildContext(root)); + const second = await execute(buildContext(root, { + runtime: { + sessionId: first.sessionId ?? null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: first.sessionDisplayId ?? null, + taskKey: "PAP-1", + }, + })); + + expect(second.exitCode).toBe(0); + expect(runtimes).toHaveLength(2); + expect(runtimes[1]?.ensureInputs[0]?.resumeSessionId).toBe("acp-1"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts new file mode 100644 index 0000000000..8336babae6 --- /dev/null +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -0,0 +1,359 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, + AdapterExecutionContext, + AdapterExecutionResult, +} from "@paperclipai/adapter-utils"; +import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_PERMISSION_MODE, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; +import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute"; +import { + asNumber, + asString, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRootDir = path.resolve(moduleDir, "../.."); +const MIN_ACP_NODE_VERSION = "22.13.0"; + +export type CodexExecutionEngine = "cli" | "acp"; + +export interface CodexEngineSelection { + engine: CodexExecutionEngine; + explicit: boolean; + fallbackReason?: string; +} + +type CodexEngineResolutionInput = + Pick & + Partial>; + +type CodexAcpExecutorOptions = Omit< + AcpxEngineExecutorOptions, + "adapterType" | "moduleDir" | "packageRootDir" +>; + +type CodexAcpExecutor = (ctx: AdapterExecutionContext) => Promise; + +function normalizeEngine(value: unknown): CodexEngineSelection { + const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (raw === "acp") return { engine: "acp", explicit: true }; + if (raw === "cli") return { engine: "cli", explicit: true }; + return { engine: "acp", explicit: false }; +} + +export function resolveCodexExecutionEngine(config: Record): CodexEngineSelection { + return normalizeEngine(config.engine); +} + +export async function resolveCodexExecutionEngineForRun( + input: CodexEngineResolutionInput, +): Promise { + const selection = normalizeEngine(input.config.engine); + if (selection.explicit || selection.engine !== "acp") return selection; + + const fallbackReason = await defaultCodexAcpFallbackReason(input); + if (!fallbackReason) return selection; + return { engine: "cli", explicit: false, fallbackReason }; +} + +export function formatCodexAcpFallbackMessage(reason: string): string { + return `[paperclip] Codex ACP default unavailable; falling back to Codex CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed.length > 0) return trimmed; + } + return undefined; +} + +export function buildCodexAcpConfig(config: Record): Record { + const agentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir); + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const permissionMode = + firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ?? + DEFAULT_ACP_ENGINE_PERMISSION_MODE; + const nonInteractivePermissions = + firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ?? + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS; + const warmHandleIdleMs = + config.warmHandleIdleMs ?? + config.acpWarmHandleIdleMs ?? + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS; + + return { + ...config, + agent: "codex", + mode, + permissionMode, + nonInteractivePermissions, + warmHandleIdleMs, + ...(agentCommand ? { agentCommand } : {}), + ...(stateDir ? { stateDir } : {}), + }; +} + +function withCodexAcpDefaults(options: CodexAcpExecutorOptions): AcpxEngineExecutorOptions { + return { + ...options, + adapterType: "codex_local", + moduleDir, + packageRootDir, + }; +} + +export function createCodexAcpExecutor(options: CodexAcpExecutorOptions = {}): CodexAcpExecutor { + let executor: CodexAcpExecutor | null = null; + return async (ctx) => { + let currentExecutor = executor; + if (!currentExecutor) { + const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute"); + currentExecutor = createAcpxEngineExecutor(withCodexAcpDefaults(options)); + executor = currentExecutor; + } + return currentExecutor({ + ...ctx, + config: buildCodexAcpConfig(ctx.config), + }); + }; +} + +function parseVersion(version: string): [number, number, number] { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function nodeVersionMeetsCodexAcpMinimum(version = process.version): boolean { + const [major, minor, patch] = parseVersion(version); + const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION); + if (major !== minMajor) return major > minMajor; + if (minor !== minMinor) return minor > minMinor; + return patch >= minPatch; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +function hasPathSeparator(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +function looksLikeShellCommand(command: string): boolean { + return /\s/.test(command.trim()); +} + +async function findCommandOnPath(binName: string): Promise { + const pathValue = process.env.PATH ?? ""; + for (const segment of pathValue.split(path.delimiter)) { + if (!segment) continue; + const candidate = path.join(segment, binName); + if (await pathExists(candidate)) return candidate; + } + return null; +} + +async function findAncestorBin(startDir: string, binName: string): Promise { + let current = path.resolve(startDir); + while (true) { + const candidate = path.join(current, "node_modules", ".bin", binName); + if (await pathExists(candidate)) return candidate; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +async function commandIsResolvable(command: string): Promise { + const trimmed = command.trim(); + if (!trimmed) return false; + if (looksLikeShellCommand(trimmed)) return true; + if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed); + return (await findCommandOnPath(trimmed)) !== null; +} + +async function resolveCodexAcpCommand(config: Record): Promise { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + return ( + (await findAncestorBin(packageRootDir, "codex-acp")) ?? + (await findCommandOnPath("codex-acp")) ?? + path.join(packageRootDir, "node_modules", ".bin", "codex-acp") + ); +} + +async function defaultCodexAcpFallbackReason( + input: CodexEngineResolutionInput, +): Promise { + const target = readAdapterExecutionTarget({ + executionTarget: input.executionTarget, + legacyRemoteExecution: input.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + return "Codex ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + } + if (!nodeVersionMeetsCodexAcpMinimum()) { + return `Node ${process.version} does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + } + const command = await resolveCodexAcpCommand(input.config); + if (!(await commandIsResolvable(command))) { + return `Codex ACP server command is not available: ${command}.`; + } + return null; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +async function hasCodexNativeCredentials(codexHome: string): Promise { + const raw = await fs.readFile(path.join(codexHome, "auth.json"), "utf8").catch(() => null); + if (!raw) return false; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const record = parsed as Record; + return isNonEmpty(record.OPENAI_API_KEY) || isNonEmpty(record.refresh_token); + } catch { + return false; + } +} + +export async function testCodexAcpEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + + checks.push({ + code: "codex_engine_selected", + level: "info", + message: "Execution engine selected: ACP.", + hint: "Set engine=cli to use the existing Codex CLI lane.", + }); + + if (targetIsRemote) { + checks.push({ + code: "codex_acp_remote_target_unsupported", + level: "error", + message: "Codex ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", + hint: "Use engine=cli for remote or sandbox Codex runs.", + }); + } + + const cwd = asString(config.cwd, process.cwd()); + try { + await fs.mkdir(cwd, { recursive: true }); + checks.push({ + code: "codex_acp_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "codex_acp_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + checks.push({ + code: nodeVersionMeetsCodexAcpMinimum() ? "codex_acp_node_supported" : "codex_acp_node_unsupported", + level: nodeVersionMeetsCodexAcpMinimum() ? "info" : "error", + message: nodeVersionMeetsCodexAcpMinimum() + ? `Node ${process.version} satisfies ACP runtime requirements.` + : `Node ${process.version} does not satisfy ACP runtime requirements.`, + hint: nodeVersionMeetsCodexAcpMinimum() + ? undefined + : `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, + }); + + const command = await resolveCodexAcpCommand(config); + const commandResolvable = await commandIsResolvable(command); + checks.push({ + code: commandResolvable ? "codex_acp_command_resolvable" : "codex_acp_command_missing", + level: commandResolvable ? "info" : "error", + message: commandResolvable + ? `Codex ACP server command is executable: ${command}` + : `Codex ACP server command is not available: ${command}`, + hint: commandResolvable + ? undefined + : "Install dependencies so @agentclientprotocol/codex-acp is present, or set agentCommand to a valid Codex ACP server command.", + }); + + const envConfig = parseObject(config.env); + const considerHostEnv = !targetIsRemote; + const configApiKey = envConfig.OPENAI_API_KEY; + const hostApiKey = considerHostEnv ? process.env.OPENAI_API_KEY : undefined; + if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) { + const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment"; + checks.push({ + code: "codex_acp_openai_api_key_detected", + level: "info", + message: "OPENAI_API_KEY is set for Codex ACP authentication.", + detail: `Detected in ${source}.`, + }); + } else if (!targetIsRemote) { + const codexHome = isNonEmpty(envConfig.CODEX_HOME) + ? envConfig.CODEX_HOME + : path.join(process.env.HOME ?? "", ".codex"); + if (codexHome && await hasCodexNativeCredentials(codexHome)) { + checks.push({ + code: "codex_acp_native_auth_detected", + level: "info", + message: "Codex ACP can use Codex native authentication.", + detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`, + }); + } else { + checks.push({ + code: "codex_acp_credentials_missing", + level: "warn", + message: "No Codex ACP credentials were detected.", + hint: "Set OPENAI_API_KEY or run `codex login` before starting a Codex ACP agent.", + }); + } + } + + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const warmHandleIdleMs = asNumber( + config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + ); + checks.push({ + code: "codex_acp_runtime_scaffold", + level: "info", + message: "Codex ACP runtime execution is available through the shared ACP engine.", + detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`, + }); + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/codex-local/src/server/config-schema.ts b/packages/adapters/codex-local/src/server/config-schema.ts new file mode 100644 index 0000000000..3e026b4c5a --- /dev/null +++ b/packages/adapters/codex-local/src/server/config-schema.ts @@ -0,0 +1,73 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; + +const acpVisible = { visibleWhen: { key: "engine", values: ["acp"] } }; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "engine", + label: "Execution engine", + type: "select", + default: "auto", + options: [ + { value: "auto", label: "Auto (ACP preferred)" }, + { value: "cli", label: "Codex CLI" }, + { value: "acp", label: "ACP" }, + ], + hint: "Auto uses ACP when prerequisites pass and falls back to Codex CLI with diagnostics.", + }, + { + key: "agentCommand", + label: "ACP server command", + type: "text", + hint: "Optional override for the Codex ACP server command. Defaults to the package-local codex-acp binary.", + meta: acpVisible, + }, + { + key: "mode", + label: "ACP session mode", + type: "select", + default: DEFAULT_ACP_ENGINE_MODE, + options: [ + { value: "persistent", label: "Persistent" }, + { value: "oneshot", label: "One-shot" }, + ], + hint: "Persistent keeps ACP session state between runs. One-shot starts fresh each run.", + meta: acpVisible, + }, + { + key: "nonInteractivePermissions", + label: "ACP non-interactive permissions", + type: "select", + default: DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + options: [ + { value: "deny", label: "Deny" }, + { value: "fail", label: "Fail" }, + ], + hint: "Fallback if the ACP agent asks for input outside an interactive session.", + meta: acpVisible, + }, + { + key: "stateDir", + label: "ACP state directory", + type: "text", + hint: "Optional ACP session state directory. Defaults to Paperclip-managed company/agent scoped storage.", + meta: acpVisible, + }, + { + key: "warmHandleIdleMs", + label: "ACP warm process idle ms", + type: "number", + default: DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + hint: "Defaults to 0, which closes the ACP process after each run while retaining persistent session state.", + meta: acpVisible, + }, + ], + }; +} diff --git a/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts new file mode 100644 index 0000000000..85203b0b51 --- /dev/null +++ b/packages/adapters/codex-local/src/server/execute.acp-fallback.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + executeCodexAcp, + prepareCodexRuntimeConfig, + readPaperclipRuntimeSkillEntries, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + tempCodexHome, +} = vi.hoisted(() => ({ + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined), + executeCodexAcp: vi.fn(async () => { + throw new Error('Transform failed with 1 error: execute.ts:818:0: ERROR: Unexpected "<<"'); + }), + prepareCodexRuntimeConfig: vi.fn(async () => ({ cleanup: vi.fn(async () => undefined), notes: [] })), + readPaperclipRuntimeSkillEntries: vi.fn(async () => []), + resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "codex"), + runAdapterExecutionTargetProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "thread.started", thread_id: "codex-thread-1" }), + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: "hello" }, + }), + JSON.stringify({ + type: "turn.completed", + usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + tempCodexHome: "/tmp/paperclip-codex-acp-fallback-test-home", +})); + +vi.mock("./acp.js", () => ({ + createCodexAcpExecutor: () => executeCodexAcp, + formatCodexAcpFallbackMessage: (reason: string) => + `[paperclip] Codex ACP default unavailable; falling back to Codex CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`, + resolveCodexExecutionEngineForRun: async (ctx: { config: Record }) => + ctx.config.engine === "acp" + ? { engine: "acp", explicit: true } + : { engine: "acp", explicit: false }, +})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + readPaperclipRuntimeSkillEntries, + }; +}); + +vi.mock("./codex-home.js", async () => { + const actual = await vi.importActual("./codex-home.js"); + return { + ...actual, + evaluateCodexCredentialReadiness: vi.fn(async () => ({ + managed: true, + authMode: "api", + ready: true, + effectiveHome: tempCodexHome, + sharedSourceHome: tempCodexHome, + })), + isManagedCodexHomePath: vi.fn(() => true), + prepareManagedCodexHome: vi.fn(async () => ({ status: "seeded", home: tempCodexHome })), + resolveManagedCodexHomeDir: vi.fn(() => tempCodexHome), + seedManagedCodexHome: vi.fn(async () => ({ status: "seeded", home: tempCodexHome })), + }; +}); + +vi.mock("./runtime-config.js", async () => { + const actual = await vi.importActual("./runtime-config.js"); + return { + ...actual, + prepareCodexRuntimeConfig, + }; +}); + +import { execute } from "./execute.js"; + +function buildContext(config: Record = {}) { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + outputInactivityTimeoutMs: null, + env: { OPENAI_API_KEY: "test-key" }, + ...config, + }, + context: {}, + onLog: vi.fn(async () => {}), + }; +} + +describe("codex_local ACP startup fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("falls back to Codex CLI when auto-selected ACP fails before execution starts", async () => { + const ctx = buildContext(); + + const result = await execute(ctx as never); + + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("hello"); + expect(executeCodexAcp).toHaveBeenCalledTimes(1); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + expect(ctx.onLog).toHaveBeenCalledWith( + "stderr", + expect.stringContaining("Codex ACP startup failed"), + ); + expect(ctx.onLog).toHaveBeenCalledWith( + "stderr", + expect.stringContaining('Unexpected "<<"'), + ); + }); + + it("keeps explicit ACP strict when startup fails", async () => { + const ctx = buildContext({ engine: "acp" }); + + await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"'); + + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.auth.test.ts b/packages/adapters/codex-local/src/server/execute.auth.test.ts index 10ec5dc3c8..2140c0dfc5 100644 --- a/packages/adapters/codex-local/src/server/execute.auth.test.ts +++ b/packages/adapters/codex-local/src/server/execute.auth.test.ts @@ -50,7 +50,7 @@ describe("codex managed-home auth fail-fast", () => { companyId: "company-1", name: "CodexCoder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -59,6 +59,7 @@ describe("codex managed-home auth fail-fast", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", cwd: workspaceDir, env: { diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 71522b2add..976b63cf98 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -64,8 +64,14 @@ import { formatOutputInactivityMonitorErrorMessage, resolveCodexInactivityTimeout, } from "./output-inactivity-monitor.js"; +import { + createCodexAcpExecutor, + formatCodexAcpFallbackMessage, + resolveCodexExecutionEngineForRun, +} from "./acp.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const executeCodexAcp = createCodexAcpExecutor(); const CODEX_ROLLOUT_NOISE_RE = /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; @@ -322,6 +328,23 @@ export async function ensureCodexSkillsInjected( } export async function execute(ctx: AdapterExecutionContext): Promise { + const engineSelection = await resolveCodexExecutionEngineForRun(ctx); + if (engineSelection.engine === "acp") { + try { + return await executeCodexAcp(ctx); + } catch (err) { + if (engineSelection.explicit) throw err; + const reason = err instanceof Error ? err.message : String(err); + await ctx.onLog( + "stderr", + formatCodexAcpFallbackMessage(`Codex ACP startup failed: ${reason}`), + ); + } + } + if (!engineSelection.explicit && engineSelection.fallbackReason) { + await ctx.onLog("stderr", formatCodexAcpFallbackMessage(engineSelection.fallbackReason)); + } + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const promptTemplate = asString( diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index e5b4e72547..02643e2c47 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,4 +1,6 @@ export { execute, ensureCodexSkillsInjected } from "./execute.js"; +export * from "./acp.js"; +export { getConfigSchema } from "./config-schema.js"; export { reconcileManagedCodexHome, isManagedCodexHomePath, @@ -25,6 +27,7 @@ export { codexHomeDir, } from "./quota.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; +import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec"; function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -35,7 +38,7 @@ export const sessionCodec: AdapterSessionCodec = { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; const record = raw as Record; const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); - if (!sessionId) return null; + if (!sessionId) return acpxSessionCodec.deserialize(raw); const cwd = readNonEmptyString(record.cwd) ?? readNonEmptyString(record.workdir) ?? @@ -54,7 +57,7 @@ export const sessionCodec: AdapterSessionCodec = { serialize(params: Record | null) { if (!params) return null; const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); - if (!sessionId) return null; + if (!sessionId) return acpxSessionCodec.serialize(params); const cwd = readNonEmptyString(params.cwd) ?? readNonEmptyString(params.workdir) ?? @@ -72,6 +75,11 @@ export const sessionCodec: AdapterSessionCodec = { }, getDisplayId(params: Record | null) { if (!params) return null; - return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + acpxSessionCodec.getDisplayId?.(params) ?? + null + ); }, }; diff --git a/packages/adapters/codex-local/src/server/test.remote.test.ts b/packages/adapters/codex-local/src/server/test.remote.test.ts index 3d77649f5e..42c61c6b6d 100644 --- a/packages/adapters/codex-local/src/server/test.remote.test.ts +++ b/packages/adapters/codex-local/src/server/test.remote.test.ts @@ -126,6 +126,7 @@ describe("codex remote environment diagnostics", () => { companyId: "company-1", adapterType: "codex_local", config: { + engine: "cli", command: "codex", }, executionTarget: remoteTarget, @@ -199,6 +200,7 @@ describe("codex remote environment diagnostics", () => { companyId: "company-1", adapterType: "codex_local", config: { + engine: "cli", command: "codex", env: { OPENAI_API_KEY: "sk-test", @@ -249,7 +251,7 @@ describe("codex remote environment diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "codex_local", - config: { command: "codex" }, + config: { engine: "cli", command: "codex" }, executionTarget: remoteTarget, environmentName: "QA Daytona", }); diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 675bc5384c..3d1ab9fb56 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -25,6 +25,7 @@ import { SANDBOX_INSTALL_COMMAND } from "../index.js"; import { codexHomeDir, readCodexAuthInfo } from "./quota.js"; import { buildCodexExecArgs } from "./codex-args.js"; import { prepareManagedCodexHome } from "./codex-home.js"; +import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -194,7 +195,24 @@ async function prepareCodexHelloProbe(input: { export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { + const engineSelection = await resolveCodexExecutionEngineForRun({ + config: parseObject(ctx.config), + executionTarget: ctx.executionTarget, + }); + if (engineSelection.engine === "acp") { + return testCodexAcpEnvironment(ctx); + } + const checks: AdapterEnvironmentCheck[] = []; + if (!engineSelection.explicit && engineSelection.fallbackReason) { + checks.push({ + code: "codex_acp_default_fallback", + level: "warn", + message: "Codex ACP default is unavailable; testing the Codex CLI fallback lane.", + detail: engineSelection.fallbackReason, + hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", + }); + } const config = parseObject(ctx.config); const command = asString(config.command, "codex"); const target = ctx.executionTarget ?? null; diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index 8126c0fb29..afb7326551 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -36,6 +36,17 @@ function makeValues(overrides: Partial = {}): CreateConfigVa } describe("buildCodexLocalConfig", () => { + it("omits engine for the auto default so runtime fallback remains available", () => { + const config = buildCodexLocalConfig(makeValues({ codexEngine: "auto" })); + + expect(config).not.toHaveProperty("engine"); + }); + + it("persists explicit engine pins", () => { + expect(buildCodexLocalConfig(makeValues({ codexEngine: "cli" }))).toMatchObject({ engine: "cli" }); + expect(buildCodexLocalConfig(makeValues({ codexEngine: "acp" }))).toMatchObject({ engine: "acp" }); + }); + it("persists the fastMode toggle into adapter config", () => { const config = buildCodexLocalConfig( makeValues({ diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index ab67feb604..e82293fefe 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -69,6 +69,14 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -120,6 +121,11 @@ export function printGeminiStreamEvent(raw: string, _debug: boolean): void { const type = asString(parsed.type); + if (type.startsWith("acpx.")) { + printAcpxStreamEvent(line, _debug); + return; + } + if (type === "system") { const subtype = asString(parsed.subtype); if (subtype === "init") { diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 827fac3ef1..d4f99331be 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -52,16 +52,23 @@ Core fields: - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt - promptTemplate (string, optional): run prompt template - model (string, optional): Gemini model id. Defaults to auto. +- engine (string, optional): leave unset/auto to use ACP when prerequisites pass and fall back to the Gemini CLI with diagnostics. Use "cli" to pin the CLI lane or "acp" to require ACP. - sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none) - command (string, optional): defaults to "gemini" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables +- agentCommand (string, optional): ACP server command override used only when engine="acp"; defaults to gemini --acp +- mode (string, optional): ACP session mode when engine="acp"; persistent or oneshot +- nonInteractivePermissions (string, optional): ACP non-interactive permission fallback when engine="acp"; deny or fail +- stateDir (string, optional): ACP state directory override when engine="acp" +- warmHandleIdleMs (number, optional): warm ACP process idle timeout when engine="acp"; defaults to 0 Operational fields: - timeoutSec (number, optional): run timeout in seconds - graceSec (number, optional): SIGTERM grace period in seconds Notes: +- Gemini ACP is the preferred auto lane when Node >=20 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Auto selection falls back to the CLI lane when ACP prerequisites are unavailable; explicit engine="acp" fails loudly. - Runs use --prompt for non-interactive execution, not stdin. - The adapter sets a headless-safe terminal/browser environment for Gemini CLI child processes so unattended runs do not wait on browser auth or 256-color terminal prompts. - Sessions resume with --resume when stored session cwd matches the current cwd. diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts new file mode 100644 index 0000000000..9f0a7cfdc0 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -0,0 +1,368 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AdapterExecutionContext, AdapterInvocationMeta } from "@paperclipai/adapter-utils"; +import { + buildGeminiAcpConfig, + createGeminiAcpExecutor, + nodeVersionMeetsGeminiAcpMinimum, + resolveGeminiExecutionEngine, + resolveGeminiExecutionEngineForRun, + testGeminiAcpEnvironment, +} from "./acp.js"; + +type FakeRuntimeOptions = Record; +type FakeRuntimeEvent = { type: string; text?: string; stream?: string; tag?: string }; +type FakeRuntimeHandle = { + sessionKey: string; + backend: string; + runtimeSessionName: string; + cwd?: string; + acpxRecordId: string; + backendSessionId: string; + agentSessionId: string; +}; +type FakeRuntimeTurnResult = { status: "completed" | "failed" | "cancelled"; stopReason?: string }; +type FakeRuntimeTurn = { + requestId: string; + events: AsyncIterable; + result: Promise; + cancel: () => Promise; + closeStream: () => Promise; +}; + +const tempRoots: string[] = []; +const originalNodeVersion = process.version; +const originalPath = process.env.PATH; +const originalHome = process.env.HOME; +const originalGeminiApiKey = process.env.GEMINI_API_KEY; + +function setNodeVersion(version: string): void { + Object.defineProperty(process, "version", { + configurable: true, + enumerable: true, + value: version, + }); +} + +afterEach(async () => { + setNodeVersion(originalNodeVersion); + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalGeminiApiKey === undefined) delete process.env.GEMINI_API_KEY; + else process.env.GEMINI_API_KEY = originalGeminiApiKey; + await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +class FakeRuntime { + ensureInputs: Array<{ + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + resumeSessionId?: string; + }> = []; + startInputs: Array<{ handle: FakeRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = []; + closeInputs: Array<{ handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }> = []; + setConfigInputs: Array<{ handle: FakeRuntimeHandle; key: string; value: string }> = []; + ensureCount = 0; + + constructor( + readonly options: FakeRuntimeOptions, + readonly events: FakeRuntimeEvent[] = [ + { type: "text_delta", text: "hello", stream: "output", tag: "agent_message_chunk" }, + ], + readonly terminal: FakeRuntimeTurnResult = { status: "completed", stopReason: "end_turn" }, + ) {} + + async ensureSession(input: { + sessionKey: string; + agent: string; + mode: "persistent" | "oneshot"; + cwd?: string; + resumeSessionId?: string; + }): Promise { + this.ensureInputs.push(input); + this.ensureCount += 1; + return { + sessionKey: input.sessionKey, + backend: "acpx", + runtimeSessionName: `runtime-${this.ensureCount}`, + cwd: input.cwd, + acpxRecordId: `record-${this.ensureCount}`, + backendSessionId: `acp-${this.ensureCount}`, + agentSessionId: `agent-${this.ensureCount}`, + }; + } + + startTurn(input: { + handle: FakeRuntimeHandle; + text: string; + requestId: string; + timeoutMs?: number; + }): FakeRuntimeTurn { + this.startInputs.push(input); + const events = this.events; + const terminal = this.terminal; + return { + requestId: input.requestId, + events: { + [Symbol.asyncIterator]: async function* () { + for (const event of events) yield event; + }, + }, + result: Promise.resolve(terminal), + cancel: async () => {}, + closeStream: async () => {}, + }; + } + + runTurn(): AsyncIterable { + throw new Error("not used"); + } + + getCapabilities() { + return { controls: [] }; + } + + getStatus() { + return Promise.resolve({}); + } + + async setConfigOption(input: { handle: FakeRuntimeHandle; key: string; value: string }) { + this.setConfigInputs.push(input); + } + + async setMode() {} + + async cancel() {} + + async close(input: { handle: FakeRuntimeHandle; reason: string; discardPersistentState?: boolean }) { + this.closeInputs.push(input); + } +} + +async function makeTempRoot(prefix: string) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +function buildContext(root: string, overrides: Partial = {}): AdapterExecutionContext { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini ACP", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: "PAP-1", + }, + config: { + engine: "acp", + cwd: root, + stateDir: path.join(root, "state"), + command: "fake-gemini", + promptTemplate: "Do the assigned work.", + }, + context: { + issueId: "issue-1", + paperclipTaskMarkdown: "Task context", + paperclipWorkspace: { + cwd: root, + source: "project_workspace", + workspaceId: "workspace-1", + }, + }, + onLog: async () => {}, + ...overrides, + }; +} + +describe("gemini_local ACP lane", () => { + it("maps Gemini config to the ACPX Gemini target", () => { + expect(buildGeminiAcpConfig({ + engine: "acp", + cwd: "/repo", + model: "gemini-2.5-pro", + command: "/opt/gemini", + warmHandleIdleMs: 25, + })).toMatchObject({ + agent: "gemini", + cwd: "/repo", + model: "gemini-2.5-pro", + agentCommand: "/opt/gemini --acp", + mode: "persistent", + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + warmHandleIdleMs: 25, + }); + + expect(buildGeminiAcpConfig({ engine: "acp", model: "auto" })).not.toHaveProperty("model"); + expect(buildGeminiAcpConfig({ engine: "acp", agentCommand: "custom-gemini-acp" })).toMatchObject({ + agentCommand: "custom-gemini-acp", + }); + }); + + it("checks the Node version required by the Gemini ACP runtime", () => { + setNodeVersion("v19.9.0"); + expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(false); + setNodeVersion("v20.0.0"); + expect(nodeVersionMeetsGeminiAcpMinimum()).toBe(true); + }); + + it("defaults to ACP when prerequisites pass and falls back to CLI only for auto resolution", async () => { + const root = await makeTempRoot("paperclip-gemini-acp-default-"); + const commandPath = path.join(root, "bin", "gemini"); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + setNodeVersion("v20.0.0"); + + expect(resolveGeminiExecutionEngine({})).toEqual({ engine: "acp", explicit: false }); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { command: commandPath }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { engine: "cli", command: commandPath }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "cli", explicit: true }); + expect(resolveGeminiExecutionEngine({ engine: "acp" })).toEqual({ + engine: "acp", + explicit: true, + }); + + setNodeVersion("v19.9.0"); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { command: commandPath }, + executionTarget: null, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("Node"), + }); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { engine: "acp", command: "/missing/gemini" }, + executionTarget: null, + }), + ).resolves.toEqual({ engine: "acp", explicit: true }); + }); + + it("falls back to the CLI lane for remote auto runs", async () => { + await expect( + resolveGeminiExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/work", + spec: { + host: "127.0.0.1", + port: 22, + username: "fixture", + remoteCwd: "/work", + remoteWorkspacePath: "/work", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("remote environment"), + }); + }); + + it("executes Gemini through the shared ACP runtime", async () => { + const root = await makeTempRoot("paperclip-gemini-acp-run-"); + process.env.HOME = path.join(root, "home"); + const runtime = new FakeRuntime({}); + const metas: AdapterInvocationMeta[] = []; + const logs: Array<{ stream: string; text: string }> = []; + const execute = createGeminiAcpExecutor({ + createRuntime: (options) => { + Object.assign(runtime.options, options); + return runtime as never; + }, + }); + + const result = await execute(buildContext(root, { + onMeta: async (meta) => { + metas.push(meta); + }, + onLog: async (stream, text) => { + logs.push({ stream, text }); + }, + })); + + expect(runtime.ensureInputs[0]).toMatchObject({ + agent: "gemini", + mode: "persistent", + cwd: root, + }); + expect(runtime.startInputs[0]?.text).toContain("Do the assigned work."); + expect(result).toMatchObject({ + exitCode: 0, + provider: "acpx", + sessionId: "acp-1", + sessionDisplayId: "agent-1", + summary: "hello", + }); + expect(result.sessionParams).toMatchObject({ + agent: "gemini", + acpSessionId: "acp-1", + cwd: root, + }); + expect(metas[0]).toMatchObject({ + adapterType: "gemini_local", + command: "fake-gemini --acp", + }); + expect(logs.some((entry) => entry.text.includes("\"type\":\"acpx.session\""))).toBe(true); + }); + + it("reports Gemini ACP environment readiness", async () => { + const root = await makeTempRoot("paperclip-gemini-acp-env-"); + const bin = path.join(root, "bin"); + await fs.mkdir(bin, { recursive: true }); + await fs.writeFile(path.join(bin, "gemini"), "#!/usr/bin/env sh\n", "utf8"); + process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.GEMINI_API_KEY = "test-key"; + setNodeVersion("v20.0.0"); + + const result = await testGeminiAcpEnvironment({ + adapterType: "gemini_local", + companyId: "company-1", + config: { + engine: "acp", + cwd: root, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "gemini_engine_selected" }), + expect.objectContaining({ code: "gemini_acp_command_resolvable" }), + expect.objectContaining({ code: "gemini_acp_credentials_detected" }), + ]), + ); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts new file mode 100644 index 0000000000..f357933d67 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -0,0 +1,347 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, + AdapterExecutionContext, + AdapterExecutionResult, +} from "@paperclipai/adapter-utils"; +import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_PERMISSION_MODE, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; +import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute"; +import { + asNumber, + asString, + parseObject, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRootDir = path.resolve(moduleDir, "../.."); +const MIN_ACP_NODE_VERSION = "20.0.0"; + +export type GeminiExecutionEngine = "cli" | "acp"; + +export interface GeminiEngineSelection { + engine: GeminiExecutionEngine; + explicit: boolean; + fallbackReason?: string; +} + +type GeminiEngineResolutionInput = + Pick & + Partial>; + +type GeminiAcpExecutorOptions = Omit< + AcpxEngineExecutorOptions, + "adapterType" | "moduleDir" | "packageRootDir" +>; + +type GeminiAcpExecutor = (ctx: AdapterExecutionContext) => Promise; + +function normalizeEngine(value: unknown): GeminiEngineSelection { + const raw = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (raw === "acp") return { engine: "acp", explicit: true }; + if (raw === "cli") return { engine: "cli", explicit: true }; + return { engine: "acp", explicit: false }; +} + +export function resolveGeminiExecutionEngine(config: Record): GeminiEngineSelection { + return normalizeEngine(config.engine); +} + +export async function resolveGeminiExecutionEngineForRun( + input: GeminiEngineResolutionInput, +): Promise { + const selection = normalizeEngine(input.config.engine); + if (selection.explicit || selection.engine !== "acp") return selection; + + const fallbackReason = await defaultGeminiAcpFallbackReason(input); + if (!fallbackReason) return selection; + return { engine: "cli", explicit: false, fallbackReason }; +} + +export function formatGeminiAcpFallbackMessage(reason: string): string { + return `[paperclip] Gemini ACP default unavailable; falling back to Gemini CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`; +} + +function firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed.length > 0) return trimmed; + } + return undefined; +} + +export function buildGeminiAcpConfig(config: Record): Record { + const configuredAgentCommand = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + const configuredGeminiCommand = firstNonEmptyString(config.command); + const agentCommand = configuredAgentCommand ?? (configuredGeminiCommand ? `${configuredGeminiCommand} --acp` : undefined); + const stateDir = firstNonEmptyString(config.stateDir, config.acpStateDir); + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const permissionMode = + firstNonEmptyString(config.permissionMode, config.acpPermissionMode) ?? + DEFAULT_ACP_ENGINE_PERMISSION_MODE; + const nonInteractivePermissions = + firstNonEmptyString(config.nonInteractivePermissions, config.acpNonInteractivePermissions) ?? + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS; + const warmHandleIdleMs = + config.warmHandleIdleMs ?? + config.acpWarmHandleIdleMs ?? + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS; + + const next: Record = { + ...config, + agent: "gemini", + mode, + permissionMode, + nonInteractivePermissions, + warmHandleIdleMs, + ...(agentCommand ? { agentCommand } : {}), + ...(stateDir ? { stateDir } : {}), + }; + const model = asString(next.model, "").trim(); + if (!model || model === DEFAULT_GEMINI_LOCAL_MODEL) delete next.model; + return next; +} + +function withGeminiAcpDefaults(options: GeminiAcpExecutorOptions): AcpxEngineExecutorOptions { + return { + ...options, + adapterType: "gemini_local", + moduleDir, + packageRootDir, + }; +} + +export function createGeminiAcpExecutor(options: GeminiAcpExecutorOptions = {}): GeminiAcpExecutor { + let executor: GeminiAcpExecutor | null = null; + return async (ctx) => { + let currentExecutor = executor; + if (!currentExecutor) { + const { createAcpxEngineExecutor } = await import("@paperclipai/adapter-utils/acpx-engine/execute"); + currentExecutor = createAcpxEngineExecutor(withGeminiAcpDefaults(options)); + executor = currentExecutor; + } + return currentExecutor({ + ...ctx, + config: buildGeminiAcpConfig(ctx.config), + }); + }; +} + +function parseVersion(version: string): [number, number, number] { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function nodeVersionMeetsGeminiAcpMinimum(version = process.version): boolean { + const [major, minor, patch] = parseVersion(version); + const [minMajor, minMinor, minPatch] = parseVersion(MIN_ACP_NODE_VERSION); + if (major !== minMajor) return major > minMajor; + if (minor !== minMinor) return minor > minMinor; + return patch >= minPatch; +} + +async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +function hasPathSeparator(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +function firstShellToken(command: string): string | null { + const trimmed = command.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("'") || trimmed.startsWith("\"")) return null; + return trimmed.split(/\s+/, 1)[0] ?? null; +} + +async function findCommandOnPath(binName: string, pathValue = process.env.PATH ?? ""): Promise { + for (const segment of pathValue.split(path.delimiter)) { + if (!segment) continue; + const candidate = path.join(segment, binName); + if (await pathExists(candidate)) return candidate; + } + return null; +} + +function resolveConfigPath(config: Record): string { + const envConfig = parseObject(config.env); + return typeof envConfig.PATH === "string" && envConfig.PATH.trim().length > 0 + ? envConfig.PATH + : process.env.PATH ?? ""; +} + +async function commandIsResolvable(command: string, pathValue = process.env.PATH ?? ""): Promise { + const token = firstShellToken(command); + if (!token) return true; + if (path.isAbsolute(token) || hasPathSeparator(token)) return pathExists(token); + return (await findCommandOnPath(token, pathValue)) !== null; +} + +function resolveGeminiAcpCommand(config: Record): string { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + const geminiCommand = firstNonEmptyString(config.command) ?? "gemini"; + return `${geminiCommand} --acp`; +} + +async function defaultGeminiAcpFallbackReason( + input: GeminiEngineResolutionInput, +): Promise { + const target = readAdapterExecutionTarget({ + executionTarget: input.executionTarget, + legacyRemoteExecution: input.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + return "Gemini ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + } + if (!nodeVersionMeetsGeminiAcpMinimum()) { + return `Node ${process.version} does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; + } + const command = resolveGeminiAcpCommand(input.config); + if (!(await commandIsResolvable(command, resolveConfigPath(input.config)))) { + return `Gemini ACP command is not available: ${command}.`; + } + return null; +} + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +export async function testGeminiAcpEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + + checks.push({ + code: "gemini_engine_selected", + level: "info", + message: "Execution engine selected: ACP.", + hint: "Set engine=cli to use the existing Gemini CLI lane.", + }); + + if (targetIsRemote) { + checks.push({ + code: "gemini_acp_remote_target_unsupported", + level: "error", + message: "Gemini ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", + hint: "Use engine=cli for remote or sandbox Gemini runs.", + }); + } + + const cwd = asString(config.cwd, process.cwd()); + try { + await fs.mkdir(cwd, { recursive: true }); + checks.push({ + code: "gemini_acp_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "gemini_acp_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + checks.push({ + code: nodeVersionMeetsGeminiAcpMinimum() ? "gemini_acp_node_supported" : "gemini_acp_node_unsupported", + level: nodeVersionMeetsGeminiAcpMinimum() ? "info" : "error", + message: nodeVersionMeetsGeminiAcpMinimum() + ? `Node ${process.version} satisfies ACP runtime requirements.` + : `Node ${process.version} does not satisfy ACP runtime requirements.`, + hint: nodeVersionMeetsGeminiAcpMinimum() + ? undefined + : `Run Gemini ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, + }); + + const command = resolveGeminiAcpCommand(config); + const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config)); + checks.push({ + code: commandResolvable ? "gemini_acp_command_resolvable" : "gemini_acp_command_missing", + level: commandResolvable ? "info" : "error", + message: commandResolvable + ? `Gemini ACP command is executable: ${command}` + : `Gemini ACP command is not available: ${command}`, + hint: commandResolvable + ? undefined + : "Install the Gemini CLI with ACP support, or set agentCommand to a valid Gemini ACP server command.", + }); + + const envConfig = parseObject(config.env); + const considerHostEnv = !targetIsRemote; + const hasGca = envConfig.GOOGLE_GENAI_USE_GCA === "true" || (considerHostEnv && process.env.GOOGLE_GENAI_USE_GCA === "true"); + const configGeminiApiKey = envConfig.GEMINI_API_KEY; + const hostGeminiApiKey = considerHostEnv ? process.env.GEMINI_API_KEY : undefined; + const configGoogleApiKey = envConfig.GOOGLE_API_KEY; + const hostGoogleApiKey = considerHostEnv ? process.env.GOOGLE_API_KEY : undefined; + if ( + isNonEmpty(configGeminiApiKey) || + isNonEmpty(hostGeminiApiKey) || + isNonEmpty(configGoogleApiKey) || + isNonEmpty(hostGoogleApiKey) || + hasGca + ) { + const source = hasGca + ? "Google account login (GCA)" + : isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey) + ? "adapter config env" + : "server environment"; + checks.push({ + code: "gemini_acp_credentials_detected", + level: "info", + message: "Gemini credentials are set for ACP authentication.", + detail: `Detected in ${source}.`, + }); + } else if (!targetIsRemote) { + checks.push({ + code: "gemini_acp_credentials_not_detected", + level: "warn", + message: "No Gemini ACP credentials were detected.", + hint: "Set GEMINI_API_KEY / GOOGLE_API_KEY, enable Google account auth, or run `gemini auth login` before starting a Gemini ACP agent.", + }); + } + + const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE; + const warmHandleIdleMs = asNumber( + config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + ); + checks.push({ + code: "gemini_acp_runtime_scaffold", + level: "info", + message: "Gemini ACP runtime execution is available through the shared ACP engine.", + detail: `mode=${mode}; warmHandleIdleMs=${warmHandleIdleMs}`, + }); + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/gemini-local/src/server/config-schema.ts b/packages/adapters/gemini-local/src/server/config-schema.ts new file mode 100644 index 0000000000..8d1308c203 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/config-schema.ts @@ -0,0 +1,73 @@ +import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; +import { + DEFAULT_ACP_ENGINE_MODE, + DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, +} from "@paperclipai/adapter-utils/acpx-engine/constants"; + +const acpVisible = { visibleWhen: { key: "engine", values: ["acp"] } }; + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + { + key: "engine", + label: "Execution engine", + type: "select", + default: "auto", + options: [ + { value: "auto", label: "Auto (ACP preferred)" }, + { value: "cli", label: "Gemini CLI" }, + { value: "acp", label: "ACP" }, + ], + hint: "Auto uses ACP when prerequisites pass and falls back to Gemini CLI with diagnostics.", + }, + { + key: "agentCommand", + label: "ACP server command", + type: "text", + hint: "Optional override for the Gemini ACP server command. Defaults to gemini --acp.", + meta: acpVisible, + }, + { + key: "mode", + label: "ACP session mode", + type: "select", + default: DEFAULT_ACP_ENGINE_MODE, + options: [ + { value: "persistent", label: "Persistent" }, + { value: "oneshot", label: "One-shot" }, + ], + hint: "Persistent keeps ACP session state between runs. One-shot starts fresh each run.", + meta: acpVisible, + }, + { + key: "nonInteractivePermissions", + label: "ACP non-interactive permissions", + type: "select", + default: DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, + options: [ + { value: "deny", label: "Deny" }, + { value: "fail", label: "Fail" }, + ], + hint: "Fallback if the ACP agent asks for input outside an interactive session.", + meta: acpVisible, + }, + { + key: "stateDir", + label: "ACP state directory", + type: "text", + hint: "Optional ACP session state directory. Defaults to Paperclip-managed company/agent scoped storage.", + meta: acpVisible, + }, + { + key: "warmHandleIdleMs", + label: "ACP warm process idle ms", + type: "number", + default: DEFAULT_ACP_ENGINE_WARM_HANDLE_IDLE_MS, + hint: "Defaults to 0, which closes the ACP process after each run while retaining persistent session state.", + meta: acpVisible, + }, + ], + }; +} diff --git a/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts b/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts new file mode 100644 index 0000000000..99329bbbce --- /dev/null +++ b/packages/adapters/gemini-local/src/server/execute.acp-fallback.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + executeGeminiAcp, + readPaperclipRuntimeSkillEntries, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, +} = vi.hoisted(() => ({ + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined), + executeGeminiAcp: vi.fn(async () => { + throw new Error('Transform failed with 1 error: execute.ts:818:0: ERROR: Unexpected "<<"'); + }), + readPaperclipRuntimeSkillEntries: vi.fn(async () => []), + resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "gemini"), + runAdapterExecutionTargetProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "init", session_id: "gemini-session-1" }), + JSON.stringify({ type: "message", role: "assistant", content: "hello" }), + JSON.stringify({ + type: "result", + status: "success", + stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), +})); + +vi.mock("./acp.js", () => ({ + createGeminiAcpExecutor: () => executeGeminiAcp, + formatGeminiAcpFallbackMessage: (reason: string) => + `[paperclip] Gemini ACP default unavailable; falling back to Gemini CLI. ${reason} Set engine=acp to require ACP or engine=cli to silence this fallback.\n`, + resolveGeminiExecutionEngineForRun: async (ctx: { config: Record }) => + ctx.config.engine === "acp" + ? { engine: "acp", explicit: true } + : { engine: "acp", explicit: false }, +})); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + readPaperclipRuntimeSkillEntries, + }; +}); + +import { execute } from "./execute.js"; + +function buildContext(config: Record = {}) { + return { + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Coder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + env: { GEMINI_API_KEY: "test-key" }, + ...config, + }, + context: {}, + onLog: vi.fn(async () => {}), + }; +} + +describe("gemini_local ACP startup fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("falls back to Gemini CLI when auto-selected ACP fails before execution starts", async () => { + const ctx = buildContext(); + + const result = await execute(ctx as never); + + expect(result.exitCode).toBe(0); + expect(result.summary).toBe("hello"); + expect(executeGeminiAcp).toHaveBeenCalledTimes(1); + expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); + expect(ctx.onLog).toHaveBeenCalledWith( + "stderr", + expect.stringContaining("Gemini ACP startup failed"), + ); + expect(ctx.onLog).toHaveBeenCalledWith( + "stderr", + expect.stringContaining('Unexpected "<<"'), + ); + }); + + it("keeps explicit ACP strict when startup fails", async () => { + const ctx = buildContext({ engine: "acp" }); + + await expect(execute(ctx as never)).rejects.toThrow('Unexpected "<<"'); + + expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index c73b388125..530b545aa3 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -57,8 +57,14 @@ import { parseGeminiJsonl, } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; +import { + createGeminiAcpExecutor, + formatGeminiAcpFallbackMessage, + resolveGeminiExecutionEngineForRun, +} from "./acp.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const executeGeminiAcp = createGeminiAcpExecutor(); function hasNonEmptyEnvValue(env: Record, key: string): boolean { const raw = env[key]; @@ -195,6 +201,23 @@ async function buildGeminiSkillsDir( } export async function execute(ctx: AdapterExecutionContext): Promise { + const engineSelection = await resolveGeminiExecutionEngineForRun(ctx); + if (engineSelection.engine === "acp") { + try { + return await executeGeminiAcp(ctx); + } catch (err) { + if (engineSelection.explicit) throw err; + const reason = err instanceof Error ? err.message : String(err); + await ctx.onLog( + "stderr", + formatGeminiAcpFallbackMessage(`Gemini ACP startup failed: ${reason}`), + ); + } + } + if (!engineSelection.explicit && engineSelection.fallbackReason) { + await ctx.onLog("stderr", formatGeminiAcpFallbackMessage(engineSelection.fallbackReason)); + } + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const executionTarget = readAdapterExecutionTarget({ executionTarget: ctx.executionTarget, diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts index b388f4d21a..ce0f50c9be 100644 --- a/packages/adapters/gemini-local/src/server/index.ts +++ b/packages/adapters/gemini-local/src/server/index.ts @@ -1,4 +1,6 @@ export { execute } from "./execute.js"; +export * from "./acp.js"; +export { getConfigSchema } from "./config-schema.js"; export { listGeminiSkills, syncGeminiSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { @@ -10,6 +12,7 @@ export { isGeminiTurnLimitResult, } from "./parse.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; +import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec"; function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -23,7 +26,7 @@ export const sessionCodec: AdapterSessionCodec = { readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id) ?? readNonEmptyString(record.sessionID); - if (!sessionId) return null; + if (!sessionId) return acpxSessionCodec.deserialize(raw); const cwd = readNonEmptyString(record.cwd) ?? readNonEmptyString(record.workdir) ?? @@ -45,7 +48,7 @@ export const sessionCodec: AdapterSessionCodec = { readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id) ?? readNonEmptyString(params.sessionID); - if (!sessionId) return null; + if (!sessionId) return acpxSessionCodec.serialize(params); const cwd = readNonEmptyString(params.cwd) ?? readNonEmptyString(params.workdir) ?? @@ -66,7 +69,9 @@ export const sessionCodec: AdapterSessionCodec = { return ( readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id) ?? - readNonEmptyString(params.sessionID) + readNonEmptyString(params.sessionID) ?? + acpxSessionCodec.getDisplayId?.(params) ?? + null ); }, }; diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 555355f652..0ea2d8fd86 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -23,6 +23,10 @@ import { import { DEFAULT_GEMINI_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js"; import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; +import { + resolveGeminiExecutionEngineForRun, + testGeminiAcpEnvironment, +} from "./acp.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -50,7 +54,24 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { + const engineSelection = await resolveGeminiExecutionEngineForRun({ + config: parseObject(ctx.config), + executionTarget: ctx.executionTarget, + }); + if (engineSelection.engine === "acp") { + return testGeminiAcpEnvironment(ctx); + } + const checks: AdapterEnvironmentCheck[] = []; + if (!engineSelection.explicit && engineSelection.fallbackReason) { + checks.push({ + code: "gemini_acp_default_fallback", + level: "warn", + message: "Gemini ACP default is unavailable; testing the Gemini CLI fallback lane.", + detail: engineSelection.fallbackReason, + hint: "Fix the ACP prerequisite to use the default ACP lane, or set engine=cli to pin the CLI lane.", + }); + } const config = parseObject(ctx.config); const command = asString(config.command, "gemini"); const target = ctx.executionTarget ?? null; diff --git a/packages/adapters/gemini-local/src/ui/build-config.test.ts b/packages/adapters/gemini-local/src/ui/build-config.test.ts new file mode 100644 index 0000000000..a10be0270c --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/build-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { buildGeminiLocalConfig } from "./build-config.js"; + +function makeValues(overrides: Partial = {}): CreateConfigValues { + return { + adapterType: "gemini_local", + cwd: "", + instructionsFilePath: "", + promptTemplate: "", + model: "gemini-2.5-pro", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: true, + search: false, + fastMode: false, + dangerouslyBypassSandbox: false, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "", + bootstrapPrompt: "", + payloadTemplateJson: "", + workspaceStrategyType: "project_primary", + workspaceBaseRef: "", + workspaceBranchTemplate: "", + worktreeParentDir: "", + runtimeServicesJson: "", + maxTurnsPerRun: 1000, + heartbeatEnabled: false, + intervalSec: 300, + ...overrides, + }; +} + +describe("buildGeminiLocalConfig", () => { + it("omits engine for the auto default so runtime fallback remains available", () => { + const config = buildGeminiLocalConfig(makeValues({ geminiEngine: "auto" })); + + expect(config).not.toHaveProperty("engine"); + }); + + it("persists explicit engine pins", () => { + expect(buildGeminiLocalConfig(makeValues({ geminiEngine: "cli" }))).toMatchObject({ engine: "cli" }); + expect(buildGeminiLocalConfig(makeValues({ geminiEngine: "acp" }))).toMatchObject({ engine: "acp" }); + }); + + it("persists ACP fields when Gemini ACP is selected", () => { + const config = buildGeminiLocalConfig(makeValues({ + geminiEngine: "acp", + geminiAcpAgentCommand: "custom-gemini --acp", + geminiAcpMode: "oneshot", + geminiAcpNonInteractivePermissions: "fail", + geminiAcpStateDir: "/tmp/gemini-acp", + geminiAcpWarmHandleIdleMs: 30, + })); + + expect(config).toMatchObject({ + engine: "acp", + agentCommand: "custom-gemini --acp", + mode: "oneshot", + nonInteractivePermissions: "fail", + stateDir: "/tmp/gemini-acp", + warmHandleIdleMs: 30, + }); + }); +}); diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts index aa5c84d565..baa5418308 100644 --- a/packages/adapters/gemini-local/src/ui/build-config.ts +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -55,6 +55,14 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record = {}; if (v.cwd) ac.cwd = v.cwd; if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.geminiEngine === "cli" || v.geminiEngine === "acp") ac.engine = v.geminiEngine; + if (v.geminiEngine === "acp") { + if (v.geminiAcpAgentCommand) ac.agentCommand = v.geminiAcpAgentCommand; + ac.mode = v.geminiAcpMode ?? "persistent"; + ac.nonInteractivePermissions = v.geminiAcpNonInteractivePermissions ?? "deny"; + if (v.geminiAcpStateDir) ac.stateDir = v.geminiAcpStateDir; + ac.warmHandleIdleMs = v.geminiAcpWarmHandleIdleMs ?? 0; + } ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL; ac.timeoutSec = 0; ac.graceSec = 15; diff --git a/packages/adapters/gemini-local/src/ui/parse-stdout.test.ts b/packages/adapters/gemini-local/src/ui/parse-stdout.test.ts index 45245db1e0..fa228b72f9 100644 --- a/packages/adapters/gemini-local/src/ui/parse-stdout.test.ts +++ b/packages/adapters/gemini-local/src/ui/parse-stdout.test.ts @@ -70,4 +70,12 @@ describe("parseGeminiStdoutLine", () => { const line = JSON.stringify({ type: "message", role: "system", content: "ignored" }); expect(parseGeminiStdoutLine(line, ts)).toEqual([]); }); + + it("delegates ACPX events to the shared ACPX transcript parser", () => { + const line = JSON.stringify({ type: "acpx.text_delta", text: "hello from acp" }); + + expect(parseGeminiStdoutLine(line, ts)).toEqual([ + { kind: "assistant", ts, text: "hello from acp", delta: true }, + ]); + }); }); diff --git a/packages/adapters/gemini-local/src/ui/parse-stdout.ts b/packages/adapters/gemini-local/src/ui/parse-stdout.ts index b09b16e104..060a1b55d0 100644 --- a/packages/adapters/gemini-local/src/ui/parse-stdout.ts +++ b/packages/adapters/gemini-local/src/ui/parse-stdout.ts @@ -1,4 +1,5 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui"; function safeJsonParse(text: string): unknown { try { @@ -216,6 +217,10 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry const type = asString(parsed.type); + if (type.startsWith("acpx.")) { + return parseAcpxStdoutLine(line, ts); + } + if (type === "system") { const subtype = asString(parsed.subtype); if (subtype === "init") { diff --git a/packages/db/src/migrations/0136_acpx_default_engine_migration.sql b/packages/db/src/migrations/0136_acpx_default_engine_migration.sql new file mode 100644 index 0000000000..3a9afdab99 --- /dev/null +++ b/packages/db/src/migrations/0136_acpx_default_engine_migration.sql @@ -0,0 +1,97 @@ +WITH migrated_agents AS ( + SELECT + "id", + "company_id", + CASE lower(COALESCE(NULLIF("adapter_config" ->> 'agent', ''), 'claude')) + WHEN 'codex' THEN 'codex_local' + ELSE 'claude_local' + END AS "next_adapter_type", + CASE lower(COALESCE(NULLIF("adapter_config" ->> 'agent', ''), 'claude')) + WHEN 'codex' THEN + ( + "adapter_config" + - 'agent' + - 'effort' + - 'reasoningEffort' + - 'thinkingEffort' + ) + || jsonb_build_object('engine', 'acp') + || CASE + WHEN COALESCE( + "adapter_config" -> 'modelReasoningEffort', + "adapter_config" -> 'reasoningEffort', + "adapter_config" -> 'thinkingEffort', + "adapter_config" -> 'effort' + ) IS NULL THEN '{}'::jsonb + ELSE jsonb_build_object( + 'modelReasoningEffort', + COALESCE( + "adapter_config" -> 'modelReasoningEffort', + "adapter_config" -> 'reasoningEffort', + "adapter_config" -> 'thinkingEffort', + "adapter_config" -> 'effort' + ) + ) + END + ELSE + ( + "adapter_config" + - 'agent' + - 'modelReasoningEffort' + - 'reasoningEffort' + - 'thinkingEffort' + ) + || jsonb_build_object('engine', 'acp') + || CASE + WHEN COALESCE( + "adapter_config" -> 'effort', + "adapter_config" -> 'thinkingEffort', + "adapter_config" -> 'reasoningEffort', + "adapter_config" -> 'modelReasoningEffort' + ) IS NULL THEN '{}'::jsonb + ELSE jsonb_build_object( + 'effort', + COALESCE( + "adapter_config" -> 'effort', + "adapter_config" -> 'thinkingEffort', + "adapter_config" -> 'reasoningEffort', + "adapter_config" -> 'modelReasoningEffort' + ) + ) + END + END AS "next_adapter_config" + FROM "agents" + WHERE "adapter_type" = 'acpx_local' + AND lower(COALESCE(NULLIF("adapter_config" ->> 'agent', ''), 'claude')) IN ('claude', 'codex') +), +updated_agents AS ( + UPDATE "agents" + SET + "adapter_type" = migrated_agents."next_adapter_type", + "adapter_config" = migrated_agents."next_adapter_config", + "updated_at" = now() + FROM migrated_agents + WHERE "agents"."id" = migrated_agents."id" + RETURNING + "agents"."id", + "agents"."company_id", + migrated_agents."next_adapter_type" +), +cleared_task_sessions AS ( + DELETE FROM "agent_task_sessions" + USING updated_agents + WHERE "agent_task_sessions"."agent_id" = updated_agents."id" + AND "agent_task_sessions"."company_id" = updated_agents."company_id" + AND "agent_task_sessions"."adapter_type" = 'acpx_local' + RETURNING "agent_task_sessions"."id" +) +UPDATE "agent_runtime_state" +SET + "adapter_type" = updated_agents."next_adapter_type", + "session_id" = NULL, + "state_json" = '{}'::jsonb, + "last_error" = NULL, + "updated_at" = now() +FROM updated_agents +WHERE "agent_runtime_state"."agent_id" = updated_agents."id" + AND "agent_runtime_state"."company_id" = updated_agents."company_id"; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5118110ea8..8269c18347 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -939,6 +939,13 @@ "when": 1783025724120, "tag": "0135_repair_run_responsible_user_updated_at_sweep", "breakpoints": true + }, + { + "idx": 136, + "version": "7", + "when": 1783555200000, + "tag": "0136_acpx_default_engine_migration", + "breakpoints": true } ] } diff --git a/packages/plugins/sandbox-providers/kubernetes/README.md b/packages/plugins/sandbox-providers/kubernetes/README.md index 88a6592575..6f37a525b6 100644 --- a/packages/plugins/sandbox-providers/kubernetes/README.md +++ b/packages/plugins/sandbox-providers/kubernetes/README.md @@ -61,7 +61,7 @@ Common optional fields: | Field | Default | Purpose | |---|---|---| | `backend` | `"sandbox-cr"` | `sandbox-cr` (alpha, requires agent-sandbox controller) or `job` (stable, one-shot entrypoint). | -| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, acpx_local, pi_local). Determines runtime image + env keys + egress allow-list. | +| `adapterType` | `"claude_local"` | One of the supported adapter types (claude_local, codex_local, gemini_local, cursor_local, opencode_local, pi_local). Determines runtime image + env keys + egress allow-list. | | `namespacePrefix` | `"paperclip-"` | Prefix for the per-company tenant namespace. | | `companySlug` | derived from companyId | Override the auto-derived company slug. | | `imageRegistry` | (none) | Override the default registry for agent runtime images. | diff --git a/packages/plugins/sandbox-providers/kubernetes/src/adapter-defaults.ts b/packages/plugins/sandbox-providers/kubernetes/src/adapter-defaults.ts index f30a121675..eb19679962 100644 --- a/packages/plugins/sandbox-providers/kubernetes/src/adapter-defaults.ts +++ b/packages/plugins/sandbox-providers/kubernetes/src/adapter-defaults.ts @@ -40,12 +40,6 @@ const REGISTRY: Record = { allowFqdns: ["api.anthropic.com", "api.openai.com", "openrouter.ai"], probeCommand: ["opencode", "--version"], }, - acpx_local: { - runtimeImage: "ghcr.io/paperclipai/agent-runtime-acpx:v1", - envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"], - allowFqdns: ["api.anthropic.com", "api.openai.com"], - probeCommand: ["acpx", "--version"], - }, pi_local: { runtimeImage: "ghcr.io/paperclipai/agent-runtime-pi:v1", envKeys: ["ANTHROPIC_API_KEY"], diff --git a/packages/plugins/sandbox-providers/kubernetes/test/unit/adapter-defaults.test.ts b/packages/plugins/sandbox-providers/kubernetes/test/unit/adapter-defaults.test.ts index 96851f228a..513c250680 100644 --- a/packages/plugins/sandbox-providers/kubernetes/test/unit/adapter-defaults.test.ts +++ b/packages/plugins/sandbox-providers/kubernetes/test/unit/adapter-defaults.test.ts @@ -28,7 +28,7 @@ describe("adapter-defaults (built-in)", () => { expect(() => getAdapterDefaults("nonexistent_local")).toThrow(/unknown adapter type/i); }); - it("KNOWN_ADAPTER_TYPES contains all 7 supported adapters", () => { + it("KNOWN_ADAPTER_TYPES contains all 6 supported adapters", () => { expect(KNOWN_ADAPTER_TYPES).toEqual( new Set([ "claude_local", @@ -36,7 +36,6 @@ describe("adapter-defaults (built-in)", () => { "gemini_local", "cursor_local", "opencode_local", - "acpx_local", "pi_local", ]), ); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index cf100e91c9..f326328dbd 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -30,7 +30,6 @@ export type AgentStatus = (typeof AGENT_STATUSES)[number]; export const AGENT_ADAPTER_TYPES = [ "process", "http", - "acpx_local", "claude_local", "codex_local", "cursor_cloud", diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index 69a2458ecc..7373a9bd67 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -37,7 +37,6 @@ export interface EnvironmentCapabilities { } const REMOTE_MANAGED_ADAPTERS = new Set([ - "acpx_local", "claude_local", "codex_local", "cursor", diff --git a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts index 05ef53b9c4..57fe14cab8 100644 --- a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts +++ b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts @@ -165,7 +165,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = { "adapter_type": { "process": "Agent runtime uses a local process adapter.", "http": "Agent runtime uses a generic HTTP adapter.", - "acpx_local": "Agent runtime uses the local ACPX adapter.", + "acpx_local": "Agent runtime used the retired local ACPX adapter.", "claude_local": "Agent runtime uses the local Claude adapter.", "codex_local": "Agent runtime uses the local Codex adapter.", "cursor_cloud": "Agent runtime uses the Cursor cloud adapter.", @@ -218,7 +218,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = { "adapter_type": { "process": "Agent runtime uses a local process adapter.", "http": "Agent runtime uses a generic HTTP adapter.", - "acpx_local": "Agent runtime uses the local ACPX adapter.", + "acpx_local": "Agent runtime used the retired local ACPX adapter.", "claude_local": "Agent runtime uses the local Claude adapter.", "codex_local": "Agent runtime uses the local Codex adapter.", "cursor_cloud": "Agent runtime uses the Cursor cloud adapter.", diff --git a/scripts/bootstrap-npm-package.mjs b/scripts/bootstrap-npm-package.mjs index b255d59ab6..d1b0fa0de6 100644 --- a/scripts/bootstrap-npm-package.mjs +++ b/scripts/bootstrap-npm-package.mjs @@ -20,8 +20,8 @@ function usage() { " node scripts/bootstrap-npm-package.mjs [--publish --otp ] [--skip-build]", "", "Examples:", - " node scripts/bootstrap-npm-package.mjs @paperclipai/adapter-acpx-local", - " node scripts/bootstrap-npm-package.mjs packages/adapters/acpx-local --publish", + " node scripts/bootstrap-npm-package.mjs @paperclipai/plugin-workspace-diff", + " node scripts/bootstrap-npm-package.mjs packages/plugins/plugin-workspace-diff --publish", "", ].join("\n"), ); diff --git a/scripts/bootstrap-npm-package.test.mjs b/scripts/bootstrap-npm-package.test.mjs index 2f68662a90..2780eb5a0f 100644 --- a/scripts/bootstrap-npm-package.test.mjs +++ b/scripts/bootstrap-npm-package.test.mjs @@ -4,9 +4,9 @@ import test from "node:test"; import { buildPublishArgs, parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs"; test("parseArgs recognizes publish and skip-build flags", () => { - assert.deepEqual(parseArgs(["@paperclipai/adapter-acpx-local", "--publish", "--skip-build"]), { + assert.deepEqual(parseArgs(["@paperclipai/plugin-workspace-diff", "--publish", "--skip-build"]), { help: false, - selector: "@paperclipai/adapter-acpx-local", + selector: "@paperclipai/plugin-workspace-diff", publish: true, skipBuild: true, otp: null, @@ -14,9 +14,9 @@ test("parseArgs recognizes publish and skip-build flags", () => { }); test("parseArgs accepts an explicit otp value", () => { - assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish", "--otp", "123456"]), { + assert.deepEqual(parseArgs(["packages/plugins/plugin-workspace-diff", "--publish", "--otp", "123456"]), { help: false, - selector: "packages/adapters/acpx-local", + selector: "packages/plugins/plugin-workspace-diff", publish: true, skipBuild: false, otp: "123456", @@ -24,9 +24,9 @@ test("parseArgs accepts an explicit otp value", () => { }); test("parseArgs leaves otp null when omitted", () => { - assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish"]), { + assert.deepEqual(parseArgs(["packages/plugins/plugin-workspace-diff", "--publish"]), { help: false, - selector: "packages/adapters/acpx-local", + selector: "packages/plugins/plugin-workspace-diff", publish: true, skipBuild: false, otp: null, diff --git a/scripts/capture-acpx-skills-screenshots.mjs b/scripts/capture-acpx-skills-screenshots.mjs deleted file mode 100644 index ee184c6614..0000000000 --- a/scripts/capture-acpx-skills-screenshots.mjs +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -import path from "node:path"; -import fs from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); -const playwrightPkgRoot = path.join(repoRoot, "node_modules/.pnpm/playwright@1.58.2/node_modules/playwright"); -const { chromium } = await import(path.join(playwrightPkgRoot, "index.mjs")); - -const baseUrl = process.env.STORYBOOK_BASE_URL ?? "http://127.0.0.1:6007"; -const outDir = process.env.OUT_DIR ?? path.join(repoRoot, "screenshots/pap-2999"); -await fs.mkdir(outDir, { recursive: true }); - -const stories = [ - { id: "adapters-acpx-local--skills-tab-claude", slug: "skills-claude" }, - { id: "adapters-acpx-local--skills-tab-codex", slug: "skills-codex" }, - { id: "adapters-acpx-local--skills-tab-custom", slug: "skills-custom" }, - { id: "adapters-acpx-local--skills-tab-loading", slug: "skills-loading" }, - { id: "adapters-acpx-local--skills-tab-empty-library", slug: "skills-empty-library" }, -]; - -const themes = [ - { name: "light", apply: false }, - { name: "dark", apply: true }, -]; - -const browser = await chromium.launch(); -try { - const context = await browser.newContext({ viewport: { width: 1280, height: 1100 } }); - const page = await context.newPage(); - for (const story of stories) { - for (const theme of themes) { - const url = `${baseUrl}/iframe.html?args=&id=${story.id}&viewMode=story&globals=theme:${theme.name}`; - await page.goto(url, { waitUntil: "load" }); - await page.waitForTimeout(1500); - const target = path.join(outDir, `${story.slug}-${theme.name}.png`); - await page.screenshot({ path: target, fullPage: true }); - console.log(`captured ${target}`); - } - } -} finally { - await browser.close(); -} diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json index d0c1e83579..e5f7ced9b6 100644 --- a/scripts/release-package-manifest.json +++ b/scripts/release-package-manifest.json @@ -4,11 +4,6 @@ "name": "@paperclipai/adapter-utils", "publishFromCi": true }, - { - "dir": "packages/adapters/acpx-local", - "name": "@paperclipai/adapter-acpx-local", - "publishFromCi": true - }, { "dir": "packages/adapters/claude-local", "name": "@paperclipai/adapter-claude-local", diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 28a6888f14..69cca3b188 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -13,7 +13,6 @@ const nonServerProjects = [ "@paperclipai/skills-catalog", "@paperclipai/db", "@paperclipai/adapter-utils", - "@paperclipai/adapter-acpx-local", "@paperclipai/adapter-codex-local", "@paperclipai/adapter-opencode-local", "@paperclipai/plugin-sdk", diff --git a/server/package.json b/server/package.json index aa2b7bf28d..13d54c02a4 100644 --- a/server/package.json +++ b/server/package.json @@ -44,7 +44,6 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1075.0", - "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", diff --git a/server/src/__tests__/acpx-local-adapter-environment.test.ts b/server/src/__tests__/acpx-local-adapter-environment.test.ts deleted file mode 100644 index 0883fae0b6..0000000000 --- a/server/src/__tests__/acpx-local-adapter-environment.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { testEnvironment } from "@paperclipai/adapter-acpx-local/server"; -import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; - -function credentialChecks(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentCheck[] { - return checks.filter((check) => check.code.startsWith("acpx_claude_") || check.code.startsWith("acpx_codex_")); -} - -describe("acpx_local environment credential diagnostics", () => { - beforeEach(() => { - vi.stubEnv("ANTHROPIC_API_KEY", ""); - vi.stubEnv("ANTHROPIC_BEDROCK_BASE_URL", ""); - vi.stubEnv("CLAUDE_CODE_USE_BEDROCK", ""); - vi.stubEnv("CLAUDE_CONFIG_DIR", ""); - vi.stubEnv("OPENAI_API_KEY", ""); - vi.stubEnv("CODEX_HOME", ""); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("emits an info-level Claude credential hint when ANTHROPIC_API_KEY is present", async () => { - const result = await testEnvironment({ - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "claude", - env: { - ANTHROPIC_API_KEY: "sk-ant-test", - }, - }, - }); - - expect(result.checks).toContainEqual(expect.objectContaining({ - code: "acpx_claude_anthropic_api_key_detected", - level: "info", - })); - expect(result.checks.some((check) => check.code.startsWith("acpx_codex_"))).toBe(false); - }); - - it("emits an info-level Claude missing credential hint without changing diagnostic health", async () => { - const root = path.join(os.tmpdir(), `paperclip-acpx-claude-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`); - const claudeConfigDir = path.join(root, ".claude"); - - try { - await fs.mkdir(claudeConfigDir, { recursive: true }); - - const result = await testEnvironment({ - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "claude", - env: { - CLAUDE_CONFIG_DIR: claudeConfigDir, - }, - }, - }); - - expect(result.checks).toContainEqual(expect.objectContaining({ - code: "acpx_claude_credentials_missing", - level: "info", - })); - expect(credentialChecks(result.checks).every((check) => check.level === "info")).toBe(true); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("emits an info-level Codex credential hint when native auth is present", async () => { - const root = path.join(os.tmpdir(), `paperclip-acpx-codex-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`); - const codexHome = path.join(root, ".codex"); - - try { - await fs.mkdir(codexHome, { recursive: true }); - await fs.writeFile(path.join(codexHome, "auth.json"), JSON.stringify({ accessToken: "token" }), "utf8"); - - const result = await testEnvironment({ - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "codex", - env: { - CODEX_HOME: codexHome, - }, - }, - }); - - expect(result.checks).toContainEqual(expect.objectContaining({ - code: "acpx_codex_native_auth_detected", - level: "info", - })); - expect(result.checks.some((check) => check.code.startsWith("acpx_claude_"))).toBe(false); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("emits an info-level Codex missing credential hint without changing diagnostic health", async () => { - const root = path.join(os.tmpdir(), `paperclip-acpx-codex-noauth-${Date.now()}-${Math.random().toString(16).slice(2)}`); - const codexHome = path.join(root, ".codex"); - - try { - await fs.mkdir(codexHome, { recursive: true }); - - const result = await testEnvironment({ - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "codex", - env: { - CODEX_HOME: codexHome, - }, - }, - }); - - expect(result.checks).toContainEqual(expect.objectContaining({ - code: "acpx_codex_credentials_missing", - level: "info", - })); - expect(credentialChecks(result.checks).every((check) => check.level === "info")).toBe(true); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); -}); diff --git a/server/src/__tests__/acpx-local-execute.test.ts b/server/src/__tests__/acpx-local-execute.test.ts deleted file mode 100644 index e4576beb4b..0000000000 --- a/server/src/__tests__/acpx-local-execute.test.ts +++ /dev/null @@ -1,767 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; -import { createAcpxLocalExecutor } from "@paperclipai/adapter-acpx-local/server"; -import type { - AcpRuntime, - AcpRuntimeEvent, - AcpRuntimeHandle, - AcpRuntimeOptions, - AcpRuntimeTurn, - AcpRuntimeTurnResult, -} from "acpx/runtime"; - -type LogEntry = { stream: "stdout" | "stderr"; chunk: string }; -type TestAcpRuntimeOptions = AcpRuntimeOptions & { - sessionOptions?: { - systemPrompt?: string | { append: string }; - additionalRoots?: string[]; - }; -}; - -class FakeRuntime implements AcpRuntime { - ensureInputs: Array<{ sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; resumeSessionId?: string }> = []; - startInputs: Array<{ handle: AcpRuntimeHandle; text: string; requestId: string; timeoutMs?: number }> = []; - closeInputs: Array<{ handle: AcpRuntimeHandle; reason: string; discardPersistentState?: boolean }> = []; - cancelInputs: Array<{ handle: AcpRuntimeHandle; reason?: string }> = []; - setModeInputs: Array<{ handle: AcpRuntimeHandle; mode: string }> = []; - setConfigInputs: Array<{ handle: AcpRuntimeHandle; key: string; value: string }> = []; - ensureCount = 0; - turnCount = 0; - nextEnsureError: Error | null = null; - - constructor( - readonly options: TestAcpRuntimeOptions, - readonly events: AcpRuntimeEvent[] = [ - { type: "status", text: "thinking", tag: "agent_thought_chunk" }, - { type: "text_delta", text: "hello ", stream: "output", tag: "agent_message_chunk" }, - { type: "tool_call", text: "read README.md", title: "read", status: "running", toolCallId: "tool-1" }, - { type: "text_delta", text: "world", stream: "output", tag: "agent_message_chunk" }, - ], - readonly terminal: AcpRuntimeTurnResult = { status: "completed", stopReason: "end_turn" }, - ) {} - - async ensureSession(input: { sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; resumeSessionId?: string }): Promise { - this.ensureInputs.push(input); - this.ensureCount += 1; - if (this.nextEnsureError) { - const err = this.nextEnsureError; - this.nextEnsureError = null; - throw err; - } - return { - sessionKey: input.sessionKey, - backend: "acpx", - runtimeSessionName: `runtime-${this.ensureCount}`, - cwd: input.cwd, - acpxRecordId: `record-${this.ensureCount}`, - backendSessionId: `acp-${this.ensureCount}`, - agentSessionId: `agent-${this.ensureCount}`, - }; - } - - startTurn(input: { handle: AcpRuntimeHandle; text: string; requestId: string; timeoutMs?: number }): AcpRuntimeTurn { - this.startInputs.push(input); - this.turnCount += 1; - let closed = false; - const events = this.events; - const terminal = this.terminal; - const cancelInputs = this.cancelInputs; - return { - requestId: input.requestId, - events: { - [Symbol.asyncIterator]: async function* () { - for (const event of events) { - if (closed) return; - yield event; - } - }, - }, - result: Promise.resolve(terminal), - cancel: async (args?: { reason?: string }) => { - cancelInputs.push({ handle: input.handle, reason: args?.reason }); - closed = true; - }, - closeStream: async () => { - closed = true; - }, - }; - } - - runTurn(): AsyncIterable { - throw new Error("not used"); - } - - getCapabilities() { - return { controls: [] }; - } - - getStatus() { - return Promise.resolve({}); - } - - async setMode(input: { handle: AcpRuntimeHandle; mode: string }) { - this.setModeInputs.push(input); - } - - async setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }) { - this.setConfigInputs.push(input); - } - - async cancel(input: { handle: AcpRuntimeHandle; reason?: string }) { - this.cancelInputs.push(input); - } - - async close(input: { handle: AcpRuntimeHandle; reason: string; discardPersistentState?: boolean }) { - this.closeInputs.push(input); - } -} - -async function createRuntimeSkill(root: string, input: { - key?: string; - runtimeName?: string; - body?: string; -}) { - const runtimeName = input.runtimeName ?? "paperclip-test-skill"; - const key = input.key ?? `company/${runtimeName}`; - const source = path.join(root, "skills", runtimeName); - await fs.mkdir(source, { recursive: true }); - await fs.writeFile(path.join(source, "SKILL.md"), input.body ?? "---\n---\nUse the test skill.\n", "utf8"); - return { - key, - runtimeName, - source, - }; -} - -function parseStdoutLogs(logs: LogEntry[]) { - return logs - .filter((entry) => entry.stream === "stdout") - .flatMap((entry) => entry.chunk.trim().split(/\n+/).filter(Boolean)) - .map((line) => JSON.parse(line) as Record); -} - -function buildContext(root: string, overrides: Partial = {}): AdapterExecutionContext { - return { - runId: "run-1", - agent: { - id: "agent-1", - companyId: "company-1", - name: "ACPX Coder", - adapterType: "acpx_local", - adapterConfig: {}, - }, - runtime: { - sessionId: null, - sessionParams: null, - sessionDisplayId: null, - taskKey: "PAP-1", - }, - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - }, - context: { - issueId: "issue-1", - paperclipTaskMarkdown: "Task context", - }, - onLog: async () => {}, - ...overrides, - }; -} - -describe("acpx_local execute", () => { - it("streams ACPX session, status, text, and tool events before returning success", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-success-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - const logs: LogEntry[] = []; - let metaPermissionNote = ""; - const execute = createAcpxLocalExecutor({ - createRuntime: () => runtime, - }); - const result = await execute(buildContext(root, { - onLog: async (stream, chunk) => logs.push({ stream, chunk }), - onMeta: async (meta) => { - metaPermissionNote = meta.commandNotes?.join("\n") ?? ""; - }, - })); - - expect(result.exitCode).toBe(0); - expect(result.summary).toBe("hello world"); - expect(result.sessionParams).toMatchObject({ - agent: "claude", - cwd: root, - mode: "persistent", - acpSessionId: "acp-1", - }); - expect(metaPermissionNote).toContain("Effective ACPX permission mode: approve-all"); - const parsed = parseStdoutLogs(logs); - expect(parsed.map((event) => event.type)).toEqual([ - "acpx.session", - "acpx.status", - "acpx.text_delta", - "acpx.tool_call", - "acpx.text_delta", - "acpx.result", - ]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("closes successful persistent runs by default while retaining session state", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-close-success-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - const execute = createAcpxLocalExecutor({ - createRuntime: () => runtime, - }); - const result = await execute(buildContext(root)); - - expect(result.exitCode).toBe(0); - expect(result.sessionParams).toMatchObject({ - mode: "persistent", - acpSessionId: "acp-1", - }); - expect(runtime.closeInputs).toEqual([ - expect.objectContaining({ - reason: "paperclip completed turn cleanup", - discardPersistentState: false, - }), - ]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("applies requested Codex model, reasoning effort, and fast mode before starting the turn", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-config-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - const execute = createAcpxLocalExecutor({ - createRuntime: () => runtime, - }); - const result = await execute(buildContext(root, { - config: { - agent: "codex", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - model: "gpt-5.4", - modelReasoningEffort: "xhigh", - fastMode: true, - }, - })); - - expect(result.exitCode).toBe(0); - expect(result.model).toBe("gpt-5.4"); - expect(runtime.setConfigInputs).toEqual([ - expect.objectContaining({ key: "model", value: "gpt-5.4" }), - expect.objectContaining({ key: "reasoning_effort", value: "xhigh" }), - expect.objectContaining({ key: "service_tier", value: "fast" }), - expect.objectContaining({ key: "features.fast_mode", value: "true" }), - ]); - expect(runtime.startInputs).toHaveLength(1); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("logs a clear error when configured session options need unsupported runtime controls", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-missing-config-controls-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - Object.defineProperty(runtime, "setConfigOption", { value: undefined }); - const logs: LogEntry[] = []; - const execute = createAcpxLocalExecutor({ - createRuntime: () => runtime, - }); - const result = await execute(buildContext(root, { - config: { - agent: "codex", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - model: "gpt-5.4", - }, - onLog: async (stream, chunk) => logs.push({ stream, chunk }), - })); - - expect(result.exitCode).toBe(1); - expect(result.errorMessage).toContain("does not expose session config controls"); - expect(logs).toEqual(expect.arrayContaining([ - expect.objectContaining({ - stream: "stderr", - chunk: expect.stringContaining("upgrade ACPX or remove configured model"), - }), - ])); - expect(runtime.closeInputs).toEqual([ - expect.objectContaining({ - reason: "paperclip config cleanup", - discardPersistentState: false, - }), - ]); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("reuses a compatible warm session and starts fresh when cwd changes", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-reuse-")); - const other = path.join(root, "other"); - await fs.mkdir(other); - try { - const runtimes: FakeRuntime[] = []; - const execute = createAcpxLocalExecutor({ - createRuntime: (options) => { - const runtime = new FakeRuntime(options); - runtimes.push(runtime); - return runtime; - }, - }); - const warmConfig = { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - warmHandleIdleMs: 60_000, - }; - - const first = await execute(buildContext(root, { config: warmConfig })); - const second = await execute(buildContext(root, { - runtime: { - sessionId: first.sessionId ?? null, - sessionParams: first.sessionParams ?? null, - sessionDisplayId: first.sessionDisplayId ?? null, - taskKey: "PAP-1", - }, - config: warmConfig, - })); - const third = await execute(buildContext(root, { - runtime: { - sessionId: first.sessionId ?? null, - sessionParams: first.sessionParams ?? null, - sessionDisplayId: first.sessionDisplayId ?? null, - taskKey: "PAP-1", - }, - config: { - agent: "claude", - cwd: other, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - warmHandleIdleMs: 60_000, - }, - })); - - expect(runtimes).toHaveLength(2); - expect(runtimes[0].ensureCount).toBe(1); - expect(runtimes[0].turnCount).toBe(2); - expect(runtimes[1].ensureCount).toBe(1); - expect(second.sessionParams?.acpSessionId).toBe("acp-1"); - expect(third.sessionParams?.cwd).toBe(other); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("closes duplicate warm handles from concurrent runs for the same session key", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-concurrent-")); - try { - const runtimes: FakeRuntime[] = []; - const warmHandles = new Map(); - const execute = createAcpxLocalExecutor({ - warmHandles, - createRuntime: (options) => { - const runtime = new FakeRuntime(options); - runtimes.push(runtime); - return runtime; - }, - }); - - const [first, second] = await Promise.all([ - execute(buildContext(root, { - runId: "run-1", - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - warmHandleIdleMs: 60_000, - }, - })), - execute(buildContext(root, { - runId: "run-2", - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - warmHandleIdleMs: 60_000, - }, - })), - ]); - - expect(first.exitCode).toBe(0); - expect(second.exitCode).toBe(0); - expect(runtimes).toHaveLength(2); - expect(warmHandles.size).toBe(1); - expect(runtimes.flatMap((runtime) => runtime.closeInputs).filter((input) => - input.reason === "paperclip duplicate warm handle cleanup" - )).toHaveLength(1); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("cleans configured warm handles after their idle window", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-warm-idle-")); - vi.useFakeTimers(); - try { - let clock = 0; - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - const warmHandles = new Map(); - const execute = createAcpxLocalExecutor({ - warmHandles, - now: () => clock, - createRuntime: () => runtime, - }); - - const result = await execute(buildContext(root, { - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - warmHandleIdleMs: 1_000, - }, - })); - - expect(result.exitCode).toBe(0); - expect(warmHandles.size).toBe(1); - clock = 1_000; - await vi.advanceTimersByTimeAsync(1_000); - - expect(warmHandles.size).toBe(0); - expect(runtime.closeInputs).toEqual([ - expect.objectContaining({ - reason: "paperclip idle cleanup", - discardPersistentState: false, - }), - ]); - } finally { - vi.useRealTimers(); - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("retries with a fresh session when ACPX cannot resume the saved backend session", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-resume-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - const firstExecute = createAcpxLocalExecutor({ - createRuntime: () => runtime, - warmHandles: new Map(), - }); - const initial = await firstExecute(buildContext(root)); - const compatibleParams = { - ...initial.sessionParams, - runtimeSessionName: "runtime-old", - acpSessionId: "acp-old", - }; - runtime.nextEnsureError = new Error("session/load failed: no session acp-old"); - const logs: LogEntry[] = []; - const execute = createAcpxLocalExecutor({ - createRuntime: () => runtime, - warmHandles: new Map(), - }); - const result = await execute(buildContext(root, { - runtime: { - sessionId: "acp-old", - sessionParams: compatibleParams, - sessionDisplayId: "acp-old", - taskKey: "PAP-1", - }, - onLog: async (stream, chunk) => logs.push({ stream, chunk }), - })); - - expect(result.exitCode).toBe(0); - expect(result.clearSession).toBe(true); - expect(runtime.ensureInputs.at(-2)?.resumeSessionId).toBe("acp-old"); - expect(runtime.ensureInputs.at(-1)?.resumeSessionId).toBeUndefined(); - expect(logs.some((entry) => entry.chunk.includes("retrying with a fresh session"))).toBe(true); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("cancels and closes stale handles on timeout", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-timeout-")); - try { - const neverFinishes = new FakeRuntime( - {} as AcpRuntimeOptions, - [], - { status: "cancelled", stopReason: "cancelled" }, - ); - neverFinishes.startTurn = function (input): AcpRuntimeTurn { - this.startInputs.push(input); - let resolveResult!: (value: AcpRuntimeTurnResult) => void; - const result = new Promise((resolve) => { - resolveResult = resolve; - }); - return { - requestId: input.requestId, - events: { - [Symbol.asyncIterator]: async function* () { - await new Promise((resolve) => setTimeout(resolve, 50)); - }, - }, - result, - cancel: async (args?: { reason?: string }) => { - this.cancelInputs.push({ handle: input.handle, reason: args?.reason }); - resolveResult({ status: "cancelled", stopReason: args?.reason }); - }, - closeStream: async () => {}, - }; - }; - const execute = createAcpxLocalExecutor({ createRuntime: () => neverFinishes }); - const result = await execute(buildContext(root, { - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - timeoutSec: 0.01, - }, - })); - - expect(result.timedOut).toBe(true); - expect(result.errorCode).toBe("acpx_timeout"); - expect(neverFinishes.cancelInputs.length).toBeGreaterThan(0); - expect(neverFinishes.closeInputs.at(-1)?.discardPersistentState).toBe(true); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("returns structured auth errors", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-error-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - runtime.nextEnsureError = new Error("authentication required: login first"); - const execute = createAcpxLocalExecutor({ createRuntime: () => runtime }); - const result = await execute(buildContext(root)); - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("acpx_auth_required"); - expect(result.errorMeta).toMatchObject({ category: "auth" }); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("returns structured ACP protocol errors", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-protocol-")); - try { - const runtime = new FakeRuntime({} as AcpRuntimeOptions); - runtime.nextEnsureError = Object.assign(new Error("protocol init failed"), { - code: "ACP_SESSION_INIT_FAILED", - }); - const execute = createAcpxLocalExecutor({ createRuntime: () => runtime }); - const result = await execute(buildContext(root)); - expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("acpx_session_init_failed"); - expect(result.errorMeta).toMatchObject({ - category: "protocol", - acpCode: "ACP_SESSION_INIT_FAILED", - }); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("materializes selected skills for ACPX Claude and passes public session metadata", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-skills-")); - try { - const skill = await createRuntimeSkill(root, {}); - let runtime: FakeRuntime | null = null; - let meta: Record | null = null; - const execute = createAcpxLocalExecutor({ - createRuntime: (options) => { - runtime = new FakeRuntime(options); - return runtime; - }, - }); - - const result = await execute(buildContext(root, { - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - paperclipRuntimeSkills: [skill], - paperclipSkillSync: { - desiredSkills: [skill.key], - }, - }, - onMeta: async (payload) => { - meta = payload as Record; - }, - })); - - expect(result.exitCode).toBe(0); - expect(runtime?.options).not.toHaveProperty("sessionOptions"); - const skillRoot = result.sessionParams?.skills && typeof result.sessionParams.skills === "object" - ? (result.sessionParams.skills as { skillRoot?: string | null }).skillRoot - : null; - expect(skillRoot).toContain(path.join("state", "runtime-skills", "claude")); - await expect(fs.lstat(path.join(skillRoot!, skill.runtimeName))).resolves.toMatchObject({}); - expect(result.sessionParams?.skills).toMatchObject({ - mode: "claude", - selectedSkills: [skill.runtimeName], - }); - expect(String(meta?.prompt ?? "")).toContain(`Skill root: ${skillRoot}`); - expect((meta?.commandNotes as string[]).join("\n")).toContain("Materialized 1 Paperclip skill"); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("includes skill content in the ACPX Claude session fingerprint", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-claude-fingerprint-")); - try { - const skill = await createRuntimeSkill(root, { body: "---\n---\nFirst version.\n" }); - const runtimes: FakeRuntime[] = []; - const execute = createAcpxLocalExecutor({ - createRuntime: (options) => { - const runtime = new FakeRuntime(options); - runtimes.push(runtime); - return runtime; - }, - }); - const context = buildContext(root, { - config: { - agent: "claude", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - paperclipRuntimeSkills: [skill], - paperclipSkillSync: { - desiredSkills: [skill.key], - }, - }, - }); - - const first = await execute(context); - await fs.writeFile(path.join(skill.source, "SKILL.md"), "---\n---\nSecond version.\n", "utf8"); - const second = await execute({ - ...context, - runtime: { - sessionId: first.sessionId ?? null, - sessionParams: first.sessionParams ?? null, - sessionDisplayId: first.sessionDisplayId ?? null, - taskKey: "PAP-1", - }, - }); - - expect(second.sessionParams?.configFingerprint).not.toBe(first.sessionParams?.configFingerprint); - expect(runtimes.at(-1)?.ensureInputs.at(-1)?.resumeSessionId).toBeUndefined(); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("materializes selected skills into the effective ACPX Codex CODEX_HOME", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-codex-skills-")); - try { - const skill = await createRuntimeSkill(root, {}); - const codexHome = path.join(root, "codex-home"); - let runtime: FakeRuntime | null = null; - let meta: Record | null = null; - const execute = createAcpxLocalExecutor({ - createRuntime: (options) => { - runtime = new FakeRuntime(options); - return runtime; - }, - }); - - const result = await execute(buildContext(root, { - config: { - agent: "codex", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - env: { CODEX_HOME: codexHome }, - paperclipRuntimeSkills: [skill], - paperclipSkillSync: { - desiredSkills: [skill.key], - }, - }, - onMeta: async (payload) => { - meta = payload as Record; - }, - })); - - expect(result.exitCode).toBe(0); - await expect(fs.lstat(path.join(codexHome, "skills", skill.runtimeName))).resolves.toMatchObject({}); - const wrapperPath = runtime?.options.agentRegistry.resolve("codex"); - const wrapper = await fs.readFile(wrapperPath!, "utf8"); - expect(wrapper).not.toContain("CODEX_HOME"); - expect(wrapper).not.toContain(codexHome); - expect((meta?.env as Record).CODEX_HOME).toBe(codexHome); - expect(result.sessionParams?.skills).toMatchObject({ - mode: "codex", - codexHome, - selectedSkills: [skill.runtimeName], - }); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); - - it("keeps ACPX custom skill selection tracked without runtime materialization", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-custom-skills-")); - try { - const skill = await createRuntimeSkill(root, {}); - let runtime: FakeRuntime | null = null; - let meta: Record | null = null; - const execute = createAcpxLocalExecutor({ - createRuntime: (options) => { - runtime = new FakeRuntime(options); - return runtime; - }, - }); - - const result = await execute(buildContext(root, { - config: { - agent: "custom", - agentCommand: "custom-acp", - cwd: root, - stateDir: path.join(root, "state"), - promptTemplate: "Do the assigned work.", - paperclipRuntimeSkills: [skill], - paperclipSkillSync: { - desiredSkills: [skill.key], - }, - }, - onMeta: async (payload) => { - meta = payload as Record; - }, - })); - - expect(result.exitCode).toBe(0); - expect(runtime?.options.sessionOptions).toBeUndefined(); - await expect(fs.lstat(path.join(root, "state", "runtime-skills"))).rejects.toMatchObject({ code: "ENOENT" }); - expect(result.sessionParams?.skills).toMatchObject({ - mode: "custom_unsupported", - desiredSkillNames: [skill.key], - }); - expect((meta?.commandNotes as string[]).join("\n")).toContain("tracked only"); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } - }); -}); diff --git a/server/src/__tests__/acpx-local-skill-sync.test.ts b/server/src/__tests__/acpx-local-skill-sync.test.ts deleted file mode 100644 index d3583b4a41..0000000000 --- a/server/src/__tests__/acpx-local-skill-sync.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - listAcpxSkills, - syncAcpxSkills, -} from "@paperclipai/adapter-acpx-local/server"; - -describe("acpx local skill sync", () => { - const paperclipKey = "paperclipai/paperclip/paperclip"; - - it("reports ACPX Claude skills as supported runtime-mounted state", async () => { - const snapshot = await listAcpxSkills({ - agentId: "agent-1", - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "claude", - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, - }); - - expect(snapshot.adapterType).toBe("acpx_local"); - expect(snapshot.supported).toBe(true); - expect(snapshot.mode).toBe("ephemeral"); - expect(snapshot.desiredSkills).toContain(paperclipKey); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("ACPX Claude session"); - expect(snapshot.warnings).toEqual([]); - }); - - it("reports ACPX Codex skills with Codex home runtime detail", async () => { - const snapshot = await syncAcpxSkills({ - agentId: "agent-2", - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "codex", - paperclipSkillSync: { - desiredSkills: ["paperclip"], - }, - }, - }, ["paperclip"]); - - expect(snapshot.supported).toBe(true); - expect(snapshot.mode).toBe("ephemeral"); - expect(snapshot.desiredSkills).toContain(paperclipKey); - expect(snapshot.desiredSkills).not.toContain("paperclip"); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("CODEX_HOME/skills/"); - expect(snapshot.warnings).toEqual([]); - }); - - it("keeps ACPX custom skill selection tracked but unsupported", async () => { - const snapshot = await listAcpxSkills({ - agentId: "agent-3", - companyId: "company-1", - adapterType: "acpx_local", - config: { - agent: "custom", - paperclipSkillSync: { - desiredSkills: [paperclipKey], - }, - }, - }); - - expect(snapshot.supported).toBe(false); - expect(snapshot.mode).toBe("unsupported"); - expect(snapshot.desiredSkills).toContain(paperclipKey); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.desired).toBe(true); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("available"); - expect(snapshot.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("stored in Paperclip only"); - expect(snapshot.warnings).toContain( - "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", - ); - }); -}); diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index b72a729084..e82eec5c36 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -37,11 +37,10 @@ describe("adapter model listing", () => { expect(models).toEqual([]); }); - it("uses provider-prefixed ACPX fallback model labels", () => { + it("does not expose models for the retired acpx_local tombstone", () => { const adapter = listServerAdapters().find((candidate) => candidate.type === "acpx_local"); - expect(adapter?.models?.some((model) => model.label.startsWith("Claude: "))).toBe(true); - expect(adapter?.models?.some((model) => model.label.startsWith("Codex: "))).toBe(true); + expect(adapter?.models).toEqual([]); }); it("returns codex fallback models when no OpenAI key is available", async () => { diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 13e57a3539..1fcf1c380f 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -142,6 +142,7 @@ describe("adapter routes", () => { expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); + expect(typeof adapter.capabilities.supportsAcp).toBe("boolean"); } }); @@ -160,6 +161,15 @@ describe("adapter routes", () => { supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, + supportsAcp: true, + }); + expect(codexLocal.acp).toMatchObject({ + agentId: "codex", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=22.13.0", + packages: ["@agentclientprotocol/codex-acp"], + }, }); // process adapter should have no local capabilities @@ -170,6 +180,7 @@ describe("adapter routes", () => { supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsAcp: false, }); // cursor adapter should require materialized runtime skills @@ -177,6 +188,25 @@ describe("adapter routes", () => { expect(cursorAdapter).toBeDefined(); expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + expect(cursorAdapter.capabilities.supportsAcp).toBe(false); + + const geminiAdapter = res.body.find((a: any) => a.type === "gemini_local"); + expect(geminiAdapter).toBeDefined(); + expect(geminiAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: true, + supportsAcp: true, + }); + expect(geminiAdapter.acp).toMatchObject({ + agentId: "gemini", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=20.0.0", + packages: ["@google/gemini-cli"], + }, + }); const grokAdapter = res.body.find((a: any) => a.type === "grok_local"); expect(grokAdapter).toBeDefined(); @@ -185,6 +215,7 @@ describe("adapter routes", () => { supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, + supportsAcp: false, }); const hermesLocal = res.body.find((a: any) => a.type === "hermes_local"); @@ -195,6 +226,7 @@ describe("adapter routes", () => { supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, + supportsAcp: false, }); const hermesGateway = res.body.find((a: any) => a.type === "hermes_gateway"); @@ -205,6 +237,7 @@ describe("adapter routes", () => { supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsAcp: false, }); }); @@ -224,10 +257,10 @@ describe("adapter routes", () => { expect(codexLocal).toBeDefined(); expect(codexLocal.capabilities.supportsSkills).toBe(true); - // acpx_local exposes runtime-aware skill snapshots for Claude/Codex/custom ACP agents + // acpx_local remains registered only as a tombstone for legacy rows. const acpxLocal = res.body.find((a: any) => a.type === "acpx_local"); expect(acpxLocal).toBeDefined(); - expect(acpxLocal.capabilities.supportsSkills).toBe(true); + expect(acpxLocal.capabilities.supportsSkills).toBe(false); }); it("uses the active adapter when resolving config schema for a paused builtin override", async () => { @@ -251,27 +284,98 @@ describe("adapter routes", () => { }); }); - it("serves the built-in acpx_local config schema", async () => { + it("serves an empty tombstone config schema for retired acpx_local", async () => { const app = createApp(); const res = await request(app).get("/api/adapters/acpx_local/config-schema"); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.fields).toEqual([]); + }); + + it("serves the built-in claude_local ACP engine config schema", async () => { + const app = createApp(); + + const paused = await request(app) + .patch("/api/adapters/claude_local/override") + .send({ paused: true }); + expect(paused.status, JSON.stringify(paused.body)).toBe(200); + + const res = await request(app).get("/api/adapters/claude_local/config-schema"); + expect(res.status, JSON.stringify(res.body)).toBe(200); expect(res.body.fields).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: "agent", - default: "claude", + key: "engine", + default: "auto", options: expect.arrayContaining([ - expect.objectContaining({ value: "claude" }), - expect.objectContaining({ value: "codex" }), - expect.objectContaining({ value: "custom" }), + expect.objectContaining({ value: "auto" }), + expect.objectContaining({ value: "cli" }), + expect.objectContaining({ value: "acp" }), ]), }), expect.objectContaining({ - key: "fastMode", - default: false, - meta: { visibleWhen: { key: "agent", values: ["codex"] } }, + key: "agentCommand", + meta: { visibleWhen: { key: "engine", values: ["acp"] } }, + }), + expect.objectContaining({ + key: "warmHandleIdleMs", + default: 0, + }), + ]), + ); + }); + + it("serves the built-in codex_local ACP engine config schema", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters/codex_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "engine", + default: "auto", + options: expect.arrayContaining([ + expect.objectContaining({ value: "auto" }), + expect.objectContaining({ value: "cli" }), + expect.objectContaining({ value: "acp" }), + ]), + }), + expect.objectContaining({ + key: "agentCommand", + meta: { visibleWhen: { key: "engine", values: ["acp"] } }, + }), + expect.objectContaining({ + key: "warmHandleIdleMs", + default: 0, + }), + ]), + ); + }); + + it("serves the built-in gemini_local ACP engine config schema", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters/gemini_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "engine", + default: "auto", + options: expect.arrayContaining([ + expect.objectContaining({ value: "auto" }), + expect.objectContaining({ value: "cli" }), + expect.objectContaining({ value: "acp" }), + ]), + }), + expect.objectContaining({ + key: "agentCommand", + meta: { visibleWhen: { key: "engine", values: ["acp"] } }, }), expect.objectContaining({ key: "warmHandleIdleMs", @@ -279,12 +383,6 @@ describe("adapter routes", () => { }), ]), ); - const keys = res.body.fields.map((field: { key: string }) => field.key); - expect(keys).not.toContain("mode"); - expect(keys).not.toContain("permissionMode"); - expect(keys).not.toContain("instructionsFilePath"); - expect(keys).not.toContain("promptTemplate"); - expect(keys).not.toContain("bootstrapPromptTemplate"); }); it("serves built-in Hermes config schemas", async () => { @@ -309,7 +407,7 @@ describe("adapter routes", () => { ); }); - it("GET /api/adapters includes ACPX model availability", async () => { + it("GET /api/adapters lists acpx_local only as a model-less tombstone", async () => { const app = createApp(); const res = await request(app).get("/api/adapters"); @@ -317,7 +415,7 @@ describe("adapter routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); const acpxLocal = res.body.find((a: any) => a.type === "acpx_local"); expect(acpxLocal).toBeDefined(); - expect(acpxLocal.modelsCount).toBeGreaterThan(0); + expect(acpxLocal.modelsCount).toBe(0); }); it("rejects signed-in users without org access", async () => { diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index fd5fa60e7a..613180674c 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -13,7 +13,7 @@ import { sessionCodec as opencodeSessionCodec, isOpenCodeUnknownSessionError, } from "@paperclipai/adapter-opencode-local/server"; -import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-acpx-local/server"; +import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec"; describe("adapter session codecs", () => { it("normalizes claude session params with cwd", () => { @@ -37,6 +37,33 @@ describe("adapter session codecs", () => { expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1"); }); + it("preserves claude ACP session params for ACP lane resumes", () => { + const parsed = claudeSessionCodec.deserialize({ + sessionKey: "paperclip:company:agent:task:fingerprint", + runtimeSessionName: "runtime-session-1", + acpxRecordId: "record-1", + acpSessionId: "acp-session-1", + agentSessionId: "agent-session-1", + agent: "claude", + cwd: "/tmp/claude-acp", + mode: "persistent", + stateDir: "/tmp/claude-acp-state", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + }); + + expect(parsed).toMatchObject({ + runtimeSessionName: "runtime-session-1", + acpSessionId: "acp-session-1", + agent: "claude", + cwd: "/tmp/claude-acp", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + }); + expect(claudeSessionCodec.serialize(parsed)).toEqual(parsed); + expect(claudeSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1"); + }); + it("normalizes codex session params with cwd", () => { const parsed = codexSessionCodec.deserialize({ sessionId: "codex-session-1", @@ -55,6 +82,33 @@ describe("adapter session codecs", () => { expect(codexSessionCodec.getDisplayId?.(serialized ?? null)).toBe("codex-session-1"); }); + it("preserves codex ACP session params for ACP lane resumes", () => { + const parsed = codexSessionCodec.deserialize({ + sessionKey: "paperclip:company:agent:task:fingerprint", + runtimeSessionName: "runtime-session-1", + acpxRecordId: "record-1", + acpSessionId: "acp-session-1", + agentSessionId: "agent-session-1", + agent: "codex", + cwd: "/tmp/codex-acp", + mode: "persistent", + stateDir: "/tmp/codex-acp-state", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + }); + + expect(parsed).toMatchObject({ + runtimeSessionName: "runtime-session-1", + acpSessionId: "acp-session-1", + agent: "codex", + cwd: "/tmp/codex-acp", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + }); + expect(codexSessionCodec.serialize(parsed)).toEqual(parsed); + expect(codexSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1"); + }); + it("normalizes opencode session params with cwd", () => { const parsed = opencodeSessionCodec.deserialize({ sessionID: "opencode-session-1", @@ -109,6 +163,33 @@ describe("adapter session codecs", () => { expect(geminiSessionCodec.getDisplayId?.(serialized ?? null)).toBe("gemini-session-1"); }); + it("preserves gemini ACP session params for ACP lane resumes", () => { + const parsed = geminiSessionCodec.deserialize({ + sessionKey: "paperclip:company:agent:task:fingerprint", + runtimeSessionName: "runtime-session-1", + acpxRecordId: "record-1", + acpSessionId: "acp-session-1", + agentSessionId: "agent-session-1", + agent: "gemini", + cwd: "/tmp/gemini-acp", + mode: "persistent", + stateDir: "/tmp/gemini-acp-state", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + }); + + expect(parsed).toMatchObject({ + runtimeSessionName: "runtime-session-1", + acpSessionId: "acp-session-1", + agent: "gemini", + cwd: "/tmp/gemini-acp", + configFingerprint: "fingerprint", + workspaceId: "workspace-1", + }); + expect(geminiSessionCodec.serialize(parsed)).toEqual(parsed); + expect(geminiSessionCodec.getDisplayId?.(parsed)).toBe("runtime-session-1"); + }); + it("preserves acpx session params required for compatibility checks", () => { const parsed = acpxSessionCodec.deserialize({ sessionKey: "paperclip:company:agent:task:fingerprint", diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 1316c9e9bb..c83dc8d79f 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -108,6 +108,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, cwd: process.cwd(), }, @@ -133,6 +134,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, cwd: process.cwd(), env: { @@ -160,6 +162,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, cwd: process.cwd(), }, @@ -187,6 +190,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, cwd: process.cwd(), env: { @@ -217,6 +221,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, cwd: process.cwd(), }, @@ -241,6 +246,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, cwd, }, @@ -258,6 +264,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: process.execPath, }, executionTarget: { @@ -298,6 +305,7 @@ describe("claude_local environment diagnostics", () => { companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: "claude", }, executionTarget: { @@ -408,6 +416,7 @@ console.log(JSON.stringify({ type: "result", result: "hello", usage: { input_tok companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: commandPath, env: { HOME: remoteHome }, }, @@ -443,6 +452,7 @@ console.log(JSON.stringify({ type: "result", result: "hello", usage: { input_tok companyId: "company-1", adapterType: "claude_local", config: { + engine: "cli", command: commandPath, cwd: workspace, effort: "low", diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index bb212f7858..7fe01345a7 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -360,9 +360,10 @@ describe("claude execute", () => { try { await execute({ runId: "run-fresh", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, @@ -390,9 +391,10 @@ describe("claude execute", () => { try { await execute({ runId: "run-resume", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, @@ -428,9 +430,10 @@ describe("claude execute", () => { try { await execute({ runId: "run-notes-fresh", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: {}, @@ -458,9 +461,10 @@ describe("claude execute", () => { try { await execute({ runId: "run-notes-resume", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: {}, @@ -490,9 +494,10 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-resume-fallback", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: "11111111-1111-4111-8111-111111111111", sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -559,9 +564,10 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-max-turns", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Do work.", @@ -598,9 +604,10 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-max-turns-text", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Do work.", @@ -633,9 +640,10 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-max-turns-fallback-text", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Do work.", @@ -684,7 +692,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -693,6 +701,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: "claude", cwd: workspace, env: { @@ -755,7 +764,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -764,6 +773,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: localWorkspace, env: { @@ -833,7 +843,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -842,6 +852,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, effort: "low", @@ -889,7 +900,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -898,6 +909,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, effort: "low", @@ -1030,7 +1042,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1039,6 +1051,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, instructionsFilePath: instructionsPath, @@ -1070,7 +1083,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1079,6 +1092,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, instructionsFilePath: instructionsPath, @@ -1195,7 +1209,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1204,6 +1218,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, instructionsFilePath: instructionsPath, @@ -1226,7 +1241,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1235,6 +1250,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, instructionsFilePath: instructionsPath, @@ -1301,7 +1317,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1310,6 +1326,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Follow the paperclip heartbeat.", @@ -1366,7 +1383,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1375,6 +1392,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Follow the paperclip heartbeat.", @@ -1422,7 +1440,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1431,6 +1449,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Follow the paperclip heartbeat.", @@ -1479,7 +1498,7 @@ describe("claude execute", () => { companyId: "company-1", name: "Claude Coder", adapterType: "claude_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1488,6 +1507,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Follow the paperclip heartbeat.", @@ -1515,9 +1535,10 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-poisoned-msgid", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -1561,9 +1582,10 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-poisoned-fresh", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, @@ -1604,7 +1626,7 @@ describe("claude execute", () => { try { const result = await execute({ runId: "run-poisoned-retry", - agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: "aaaaaaaa-0000-4000-8000-000000000004", sessionParams: null, @@ -1612,6 +1634,7 @@ describe("claude execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, diff --git a/server/src/__tests__/codex-local-adapter-environment.test.ts b/server/src/__tests__/codex-local-adapter-environment.test.ts index ba92a22454..46ad1c9f05 100644 --- a/server/src/__tests__/codex-local-adapter-environment.test.ts +++ b/server/src/__tests__/codex-local-adapter-environment.test.ts @@ -26,6 +26,7 @@ describe("codex_local environment diagnostics", () => { companyId: "company-1", adapterType: "codex_local", config: { + engine: "cli", command: process.execPath, cwd, }, @@ -57,6 +58,7 @@ describe("codex_local environment diagnostics", () => { companyId: "company-1", adapterType: "codex_local", config: { + engine: "cli", command: process.execPath, cwd, env: { CODEX_HOME: codexHome }, @@ -86,6 +88,7 @@ describe("codex_local environment diagnostics", () => { companyId: "company-1", adapterType: "codex_local", config: { + engine: "cli", command: process.execPath, cwd, env: { CODEX_HOME: codexHome }, @@ -124,6 +127,7 @@ describe("codex_local environment diagnostics", () => { companyId: "company-1", adapterType: "codex_local", config: { + engine: "cli", command: "codex", cwd, env: { diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index d8b1c5b104..3281834470 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -58,8 +58,20 @@ type LogEntry = { chunk: string; }; +const codexHomeOverrides: Array = []; + +afterEach(() => { + while (codexHomeOverrides.length > 0) { + const previous = codexHomeOverrides.pop(); + if (previous === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previous; + } +}); + async function seedSharedCodexAuth(homeRoot: string): Promise { const sharedCodexHome = path.join(homeRoot, ".codex"); + codexHomeOverrides.push(process.env.CODEX_HOME); + process.env.CODEX_HOME = sharedCodexHome; await fs.mkdir(sharedCodexHome, { recursive: true }); await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); } @@ -140,7 +152,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -149,6 +161,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -218,7 +231,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -227,6 +240,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -280,7 +294,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -289,6 +303,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: "codex", cwd: workspace, env: { @@ -346,7 +361,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -355,6 +370,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: localWorkspace, env: { @@ -414,7 +430,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -423,6 +439,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -525,7 +542,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -534,6 +551,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, promptTemplate: "Follow the paperclip heartbeat.", @@ -578,7 +596,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: "codex-session-usage-limit", @@ -590,6 +608,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, model: "gpt-5.3-codex-spark", @@ -639,7 +658,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -651,6 +670,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, fastMode: true, @@ -715,7 +735,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -724,6 +744,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -784,7 +805,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -793,6 +814,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -870,7 +892,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -879,6 +901,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -972,7 +995,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -984,6 +1007,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, instructionsFilePath: instructionsPath, @@ -1100,7 +1124,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1109,6 +1133,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { @@ -1210,7 +1235,7 @@ describe("codex execute", () => { companyId: "company-1", name: "Codex Coder", adapterType: "codex_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -1219,6 +1244,7 @@ describe("codex execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts index bb187f2b77..e18b82763a 100644 --- a/server/src/__tests__/gemini-local-adapter-environment.test.ts +++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts @@ -55,6 +55,7 @@ describe("gemini_local environment diagnostics", () => { companyId: "company-1", adapterType: "gemini_local", config: { + engine: "cli", command: process.execPath, cwd, }, @@ -82,6 +83,7 @@ describe("gemini_local environment diagnostics", () => { companyId: "company-1", adapterType: "gemini_local", config: { + engine: "cli", command: "gemini", cwd, model: "gemini-2.5-pro", @@ -118,6 +120,7 @@ describe("gemini_local environment diagnostics", () => { companyId: "company-1", adapterType: "gemini_local", config: { + engine: "cli", command: "gemini", cwd, env: { @@ -139,6 +142,7 @@ describe("gemini_local environment diagnostics", () => { companyId: "company-1", adapterType: "gemini_local", config: { + engine: "cli", command: "gemini", }, executionTarget: { diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 98381d8b8d..83924852a5 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -94,7 +94,7 @@ describe("gemini execute", () => { companyId: "company-1", name: "Gemini Coder", adapterType: "gemini_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: null, @@ -103,6 +103,7 @@ describe("gemini execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, model: "gemini-2.5-pro", @@ -170,9 +171,10 @@ describe("gemini execute", () => { try { await execute({ runId: "run-yolo", - agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, @@ -221,9 +223,10 @@ describe("gemini execute", () => { try { const result = await execute({ runId: "run-turn-limit", - agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, }, @@ -262,9 +265,10 @@ describe("gemini execute", () => { try { const result = await execute({ runId: "run-exit-53", - agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, }, @@ -311,9 +315,10 @@ describe("gemini execute", () => { try { const result = await execute({ runId: "run-turn-limit-text", - agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: { engine: "cli" } }, runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, config: { + engine: "cli", command: commandPath, cwd: workspace, }, @@ -355,7 +360,7 @@ describe("gemini execute", () => { companyId: "company-1", name: "Gemini Coder", adapterType: "gemini_local", - adapterConfig: {}, + adapterConfig: { engine: "cli" }, }, runtime: { sessionId: "gemini-session-1", @@ -364,6 +369,7 @@ describe("gemini execute", () => { taskKey: null, }, config: { + engine: "cli", command: commandPath, cwd: workspace, model: "gemini-2.5-pro", diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 0f713c9cf0..a701e01713 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -13,6 +13,7 @@ export { } from "./registry.js"; export type { ServerAdapterModule, + AcpTargetDescriptor, AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index c60d91948a..60d8461688 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,5 +1,4 @@ import type { - AdapterModel, AdapterModelProfileDefinition, AdapterRuntimeCommandSpec, ServerAdapterModule, @@ -10,18 +9,6 @@ import { buildSandboxNpmInstallCommand, getAdapterSessionManagement, } from "@paperclipai/adapter-utils"; -import { - execute as acpxExecute, - testEnvironment as acpxTestEnvironment, - sessionCodec as acpxSessionCodec, - getConfigSchema as getAcpxConfigSchema, - listAcpxSkills, - syncAcpxSkills, -} from "@paperclipai/adapter-acpx-local/server"; -import { - agentConfigurationDoc as acpxAgentConfigurationDoc, - models as acpxModels, -} from "@paperclipai/adapter-acpx-local"; import { execute as claudeExecute, listClaudeSkills, @@ -31,6 +18,7 @@ import { testEnvironment as claudeTestEnvironment, sessionCodec as claudeSessionCodec, getQuotaWindows as claudeGetQuotaWindows, + getConfigSchema as getClaudeConfigSchema, } from "@paperclipai/adapter-claude-local/server"; import { agentConfigurationDoc as claudeAgentConfigurationDoc, @@ -44,6 +32,7 @@ import { testEnvironment as codexTestEnvironment, sessionCodec as codexSessionCodec, getQuotaWindows as codexGetQuotaWindows, + getConfigSchema as getCodexConfigSchema, } from "@paperclipai/adapter-codex-local/server"; import { agentConfigurationDoc as codexAgentConfigurationDoc, @@ -75,6 +64,7 @@ import { syncGeminiSkills, testEnvironment as geminiTestEnvironment, sessionCodec as geminiSessionCodec, + getConfigSchema as getGeminiConfigSchema, } from "@paperclipai/adapter-gemini-local/server"; import { agentConfigurationDoc as geminiAgentConfigurationDoc, @@ -176,42 +166,33 @@ function buildCursorRuntimeCommandSpec(config: Record): Adapter }; } -function dedupeAdapterModels(models: AdapterModel[]): AdapterModel[] { - const seen = new Set(); - const result: AdapterModel[] = []; - for (const model of models) { - const id = model.id.trim(); - if (!id || seen.has(id)) continue; - seen.add(id); - result.push({ ...model, id }); - } - return result; -} +const retiredAcpxMessage = + "The acpx_local adapter has been retired. Existing Claude and Codex ACPX agents should be migrated to claude_local or codex_local with adapterConfig.engine=\"acp\"."; -function prefixAdapterModelLabels(models: AdapterModel[], provider: "Claude" | "Codex"): AdapterModel[] { - const prefix = `${provider}: `; - return models.map((model) => ({ - ...model, - label: model.label.startsWith(prefix) ? model.label : `${prefix}${model.label}`, - })); -} +const retiredAcpxAgentConfigurationDoc = `# acpx_local retired -async function listAcpxModels(): Promise { - const [claude, codex] = await Promise.all([ - listClaudeModels().catch(() => claudeModels), - listCodexModels().catch(() => codexModels), - ]); - return dedupeAdapterModels([ - ...acpxModels, - ...prefixAdapterModelLabels(claude, "Claude"), - ...prefixAdapterModelLabels(codex, "Codex"), - ]); -} +Adapter: acpx_local + +The standalone ACPX adapter has been retired. Use: + +- claude_local with adapterConfig.engine="acp" for Claude ACP execution. +- codex_local with adapterConfig.engine="acp" for Codex ACP execution. + +Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly instead of falling back to the process adapter. +`; const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: stampClaudeAgentIdHeader(claudeExecute), testEnvironment: claudeTestEnvironment, + acp: { + agentId: "claude", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=22.12.0", + packages: ["@agentclientprotocol/claude-agent-acp"], + }, + }, listSkills: listClaudeSkills, syncSkills: syncClaudeSkills, sessionCodec: claudeSessionCodec, @@ -227,34 +208,64 @@ const claudeLocalAdapter: ServerAdapterModule = { getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "claude", "@anthropic-ai/claude-code"), agentConfigurationDoc: claudeAgentConfigurationDoc, + getConfigSchema: getClaudeConfigSchema, getQuotaWindows: claudeGetQuotaWindows, }; const acpxLocalAdapter: ServerAdapterModule = { type: "acpx_local", - execute: acpxExecute, - testEnvironment: acpxTestEnvironment, - listSkills: listAcpxSkills, - syncSkills: syncAcpxSkills, - sessionCodec: acpxSessionCodec, - sessionManagement: getAdapterSessionManagement("acpx_local") ?? undefined, - models: dedupeAdapterModels([ - ...prefixAdapterModelLabels(claudeModels, "Claude"), - ...prefixAdapterModelLabels(codexModels, "Codex"), - ]), - listModels: listAcpxModels, - supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", + async execute(ctx) { + await ctx.onLog("stderr", `${retiredAcpxMessage}\n`); + await ctx.onMeta?.({ + adapterType: "acpx_local", + command: "acpx_local-retired", + commandNotes: [retiredAcpxMessage], + }); + return { + exitCode: 1, + signal: null, + timedOut: false, + errorMessage: retiredAcpxMessage, + errorCode: "acpx_local_retired", + provider: "acpx", + summary: retiredAcpxMessage, + }; + }, + async testEnvironment() { + return { + adapterType: "acpx_local", + status: "fail", + testedAt: new Date().toISOString(), + checks: [ + { + code: "acpx_local_retired", + level: "error", + message: retiredAcpxMessage, + hint: "Set the agent adapter to claude_local or codex_local and set adapterConfig.engine to acp.", + }, + ], + }; + }, + models: [], + supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, requiresMaterializedRuntimeSkills: false, - agentConfigurationDoc: acpxAgentConfigurationDoc, - getConfigSchema: getAcpxConfigSchema, + agentConfigurationDoc: retiredAcpxAgentConfigurationDoc, + getConfigSchema: () => ({ fields: [] }), }; const codexLocalAdapter: ServerAdapterModule = { type: "codex_local", execute: codexExecute, testEnvironment: codexTestEnvironment, + acp: { + agentId: "codex", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=22.13.0", + packages: ["@agentclientprotocol/codex-acp"], + }, + }, listSkills: listCodexSkills, syncSkills: syncCodexSkills, sessionCodec: codexSessionCodec, @@ -269,6 +280,7 @@ const codexLocalAdapter: ServerAdapterModule = { requiresMaterializedRuntimeSkills: false, getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"), agentConfigurationDoc: codexAgentConfigurationDoc, + getConfigSchema: getCodexConfigSchema, getQuotaWindows: codexGetQuotaWindows, }; @@ -310,6 +322,14 @@ const geminiLocalAdapter: ServerAdapterModule = { type: "gemini_local", execute: geminiExecute, testEnvironment: geminiTestEnvironment, + acp: { + agentId: "gemini", + skillsMode: "ephemeral", + prerequisites: { + nodeRange: ">=20.0.0", + packages: ["@google/gemini-cli"], + }, + }, listSkills: listGeminiSkills, syncSkills: syncGeminiSkills, sessionCodec: geminiSessionCodec, @@ -323,6 +343,7 @@ const geminiLocalAdapter: ServerAdapterModule = { getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "gemini", "@google/gemini-cli"), agentConfigurationDoc: geminiAgentConfigurationDoc, + getConfigSchema: getGeminiConfigSchema, }; const grokLocalAdapter: ServerAdapterModule = { diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 55d766f409..07132c4f2c 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -67,6 +67,7 @@ interface AdapterCapabilities { supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; supportsModelProfiles: boolean; + supportsAcp: boolean; } interface AdapterInfo { @@ -77,6 +78,7 @@ interface AdapterInfo { loaded: boolean; disabled: boolean; capabilities: AdapterCapabilities; + acp?: ServerAdapterModule["acp"]; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -121,6 +123,7 @@ function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabili supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles), + supportsAcp: Boolean(adapter.acp), }; } @@ -134,6 +137,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), capabilities: buildAdapterCapabilities(adapter), + ...(adapter.acp ? { acp: adapter.acp } : {}), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 685fe3900c..d43481375b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -84,12 +84,6 @@ import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; -import { - DEFAULT_ACPX_LOCAL_AGENT, - DEFAULT_ACPX_LOCAL_MODE, - DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, - DEFAULT_ACPX_LOCAL_PERMISSION_MODE, -} from "@paperclipai/adapter-acpx-local"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; @@ -137,7 +131,6 @@ export function agentRoutes( // Legacy hardcoded maps — used as fallback when adapter module does not // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { - acpx_local: "instructionsFilePath", claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", droid_local: "instructionsFilePath", @@ -1195,21 +1188,6 @@ export function agentRoutes( adapterConfig: Record, ): Record { const next = { ...adapterConfig }; - if (adapterType === "acpx_local") { - if (!asNonEmptyString(next.agent)) { - next.agent = DEFAULT_ACPX_LOCAL_AGENT; - } - if (!asNonEmptyString(next.mode)) { - next.mode = DEFAULT_ACPX_LOCAL_MODE; - } - if (!asNonEmptyString(next.permissionMode)) { - next.permissionMode = DEFAULT_ACPX_LOCAL_PERMISSION_MODE; - } - if (!asNonEmptyString(next.nonInteractivePermissions)) { - next.nonInteractivePermissions = DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS; - } - return ensureGatewayDeviceKey(adapterType, next); - } if (adapterType === "codex_local") { const hasBypassFlag = typeof next.dangerouslyBypassApprovalsAndSandbox === "boolean" || diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index c88c25cc3f..079b3563b7 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -34,7 +34,6 @@ export async function resolveEnvironmentExecutionTarget(input: { if (input.environment.driver === "sandbox") { if ( - input.adapterType !== "acpx_local" && input.adapterType !== "codex_local" && input.adapterType !== "claude_local" && input.adapterType !== "gemini_local" && @@ -112,7 +111,6 @@ export async function resolveEnvironmentExecutionTarget(input: { if ( ( input.adapterType !== "codex_local" && - input.adapterType !== "acpx_local" && input.adapterType !== "claude_local" && input.adapterType !== "gemini_local" && input.adapterType !== "opencode_local" && diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 41cd18af7a..a88f597d27 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -311,7 +311,6 @@ const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow"; const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const; // Keep this in sync with local adapters that require a git workspace before launch. const GIT_SENSITIVE_LOCAL_ADAPTER_TYPES = new Set([ - "acpx_local", "claude_local", "codex_local", "cursor", diff --git a/ui/package.json b/ui/package.json index 72774245e4..cab88c52e9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,7 +34,6 @@ "@dnd-kit/utilities": "^3.2.2", "@lexical/link": "0.46.0", "@mdxeditor/editor": "^3.55.0", - "@paperclipai/adapter-acpx-local": "workspace:*", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-cloud": "workspace:*", diff --git a/ui/src/adapters/acpx-local/index.ts b/ui/src/adapters/acpx-local/index.ts deleted file mode 100644 index 4308d63522..0000000000 --- a/ui/src/adapters/acpx-local/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { UIAdapterModule } from "../types"; -import { parseAcpxStdoutLine, buildAcpxLocalConfig } from "@paperclipai/adapter-acpx-local/ui"; -import { SchemaConfigFields } from "../schema-config-fields"; - -export const acpxLocalUIAdapter: UIAdapterModule = { - type: "acpx_local", - label: "ACPX", - parseStdoutLine: parseAcpxStdoutLine, - ConfigFields: SchemaConfigFields, - buildAdapterConfig: buildAcpxLocalConfig, -}; diff --git a/ui/src/adapters/adapter-display-registry.test.ts b/ui/src/adapters/adapter-display-registry.test.ts index 8c32f162b7..6d23749710 100644 --- a/ui/src/adapters/adapter-display-registry.test.ts +++ b/ui/src/adapters/adapter-display-registry.test.ts @@ -6,7 +6,7 @@ describe("adapter display registry", () => { it("uses user-facing labels without the legacy local qualifier for built-in adapters", () => { expect(getAdapterLabel("codex_local")).toBe("Codex"); expect(getAdapterLabel("claude_local")).toBe("Claude Code"); - expect(getAdapterLabel("acpx_local")).toBe("ACPX"); + expect(getAdapterLabel("acpx_local")).toBe("ACPX (retired)"); expect(getAdapterLabel("cursor")).toBe("Cursor"); expect(getAdapterLabel("gemini_local")).toBe("Gemini CLI"); expect(getAdapterLabel("grok_local")).toBe("Grok Build"); @@ -18,7 +18,7 @@ describe("adapter display registry", () => { expect(getAdapterLabels()).toMatchObject({ codex_local: "Codex", claude_local: "Claude Code", - acpx_local: "ACPX", + acpx_local: "ACPX (retired)", cursor: "Cursor", gemini_local: "Gemini CLI", grok_local: "Grok Build", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index 032332d43e..4b7013fc06 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -60,10 +60,11 @@ export interface AdapterDisplayInfo { const adapterDisplayMap: Record = { acpx_local: { - label: "ACPX", - description: "Experimental ACPX multi-agent harness", + label: "ACPX (retired)", + description: "Retired standalone ACPX adapter", icon: Bot, - experimental: true, + comingSoon: true, + disabledLabel: "Use Claude Code or Codex with the ACP engine", hideFromVisualSelection: true, }, claude_local: { diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx index fd46352beb..ab546ebbb5 100644 --- a/ui/src/adapters/claude-local/config-fields.tsx +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -78,8 +78,142 @@ export function ClaudeLocalAdvancedFields({ eff, mark, }: AdapterConfigFieldsProps) { + const rawEngine = isCreate + ? values!.claudeEngine ?? "auto" + : eff("adapterConfig", "engine", String(config.engine ?? "auto")); + const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; + const acpSelected = engine === "acp"; + return ( <> + + + + {acpSelected && ( + <> + + + isCreate + ? set!({ claudeAcpAgentCommand: v }) + : mark("adapterConfig", "agentCommand", v || undefined) + } + immediate + className={inputClass} + placeholder="claude-agent-acp" + /> + + + + + + + + +
+ + isCreate + ? set!({ claudeAcpStateDir: v }) + : mark("adapterConfig", "stateDir", v || undefined) + } + immediate + className={inputClass} + placeholder="/path/to/acp-state" + /> + +
+
+ + {isCreate ? ( + set!({ claudeAcpWarmHandleIdleMs: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "warmHandleIdleMs", v || 0)} + immediate + className={inputClass} + /> + )} + + + )} + + + + {acpSelected && ( + <> + + + isCreate + ? set!({ codexAcpAgentCommand: v }) + : mark("adapterConfig", "agentCommand", v || undefined) + } + immediate + className={inputClass} + placeholder="codex-acp" + /> + + + + + + + + +
+ + isCreate + ? set!({ codexAcpStateDir: v }) + : mark("adapterConfig", "stateDir", v || undefined) + } + immediate + className={inputClass} + placeholder="/path/to/acp-state" + /> + +
+
+ + {isCreate ? ( + set!({ codexAcpWarmHandleIdleMs: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "warmHandleIdleMs", v || 0)} + immediate + className={inputClass} + /> + )} + + + )} {!hideInstructionsFile && (
diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx index 7825ea57ed..df7d3d58d8 100644 --- a/ui/src/adapters/gemini-local/config-fields.tsx +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -1,5 +1,6 @@ import type { AdapterConfigFieldsProps } from "../types"; import { + DraftNumberInput, DraftInput, Field, } from "../../components/agent-config-primitives"; @@ -19,33 +20,168 @@ export function GeminiLocalConfigFields({ mark, hideInstructionsFile, }: AdapterConfigFieldsProps) { - if (hideInstructionsFile) return null; + const rawEngine = isCreate + ? values!.geminiEngine ?? "auto" + : eff("adapterConfig", "engine", String(config.engine ?? "auto")); + const engine = rawEngine === "acp" || rawEngine === "cli" ? rawEngine : "auto"; + const acpSelected = engine === "acp"; + return ( <> - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
+ + + {acpSelected && ( + <> + + + isCreate + ? set!({ geminiAcpAgentCommand: v }) + : mark("adapterConfig", "agentCommand", v || undefined) + } + immediate + className={inputClass} + placeholder="gemini --acp" + /> + + + + + + + + +
+ + isCreate + ? set!({ geminiAcpStateDir: v }) + : mark("adapterConfig", "stateDir", v || undefined) + } + immediate + className={inputClass} + placeholder="/path/to/acp-state" + /> + +
+
+ + {isCreate ? ( + set!({ geminiAcpWarmHandleIdleMs: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "warmHandleIdleMs", v || 0)} + immediate + className={inputClass} + /> + )} + + + )} + {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )} ); } diff --git a/ui/src/adapters/metadata.test.ts b/ui/src/adapters/metadata.test.ts index c08c2f2142..7422fd28d4 100644 --- a/ui/src/adapters/metadata.test.ts +++ b/ui/src/adapters/metadata.test.ts @@ -37,9 +37,9 @@ describe("adapter metadata", () => { expect(isEnabledAdapterType("http")).toBe(false); }); - it("keeps ACPX selectable from explicit configuration but out of visual pickers", () => { - expect(isEnabledAdapterType("acpx_local")).toBe(true); - expect(isValidAdapterType("acpx_local")).toBe(true); + it("marks the retired ACPX adapter as unavailable for new selections", () => { + expect(isEnabledAdapterType("acpx_local")).toBe(false); + expect(isValidAdapterType("acpx_local")).toBe(false); expect(isVisualAdapterChoice("acpx_local")).toBe(false); expect( @@ -53,9 +53,9 @@ describe("adapter metadata", () => { { value: "acpx_local", label: "acpx_local", - comingSoon: false, + comingSoon: true, hidden: false, - experimental: true, + experimental: false, }, ]); }); diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index d698e86b31..c4d6b009c0 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -1,5 +1,4 @@ import type { UIAdapterModule } from "./types"; -import { acpxLocalUIAdapter } from "./acpx-local"; import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorCloudUIAdapter } from "./cursor-cloud"; @@ -53,7 +52,6 @@ setDynamicParserResultNotifier(notifyAdapterChange); function registerBuiltInUIAdapters() { for (const adapter of [ - acpxLocalUIAdapter, claudeLocalUIAdapter, codexLocalUIAdapter, cursorCloudUIAdapter, diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 14e466e67a..78d81f5d33 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -9,6 +9,7 @@ const ALL_FALSE: AdapterCapabilities = { supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, + supportsAcp: false, }; /** @@ -16,14 +17,13 @@ const ALL_FALSE: AdapterCapabilities = { * return correct values on first render before the /api/adapters call resolves. */ const KNOWN_DEFAULTS: Record = { - acpx_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, - claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, - codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, - cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, - gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, - grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, - opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, - pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, + claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true }, + codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true }, + cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, + gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: true }, + grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false }, + opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false }, + pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false }, openclaw_gateway: ALL_FALSE, }; diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 3df1340c0f..7fdabf9e03 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -10,6 +10,16 @@ export interface AdapterCapabilities { supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; supportsModelProfiles: boolean; + supportsAcp: boolean; +} + +export interface AcpTargetDescriptor { + agentId: string; + skillsMode: "ephemeral" | "unsupported"; + prerequisites: { + nodeRange?: string; + packages?: string[]; + }; } export interface AdapterInfo { @@ -20,6 +30,7 @@ export interface AdapterInfo { loaded: boolean; disabled: boolean; capabilities: AdapterCapabilities; + acp?: AcpTargetDescriptor; /** Installed version (for external npm adapters) */ version?: string; /** Package name (for external adapters) */ diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index 50c812de30..1f9a11b045 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -82,6 +82,7 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({ supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, + supportsAcp: false, } : { supportsInstructionsBundle: true, @@ -89,6 +90,7 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({ supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, + supportsAcp: true, }, })); diff --git a/ui/src/components/AgentConfigForm.test.ts b/ui/src/components/AgentConfigForm.test.ts index befe41c340..112c8473f9 100644 --- a/ui/src/components/AgentConfigForm.test.ts +++ b/ui/src/components/AgentConfigForm.test.ts @@ -4,10 +4,10 @@ import { supportsAdapterModelRefresh } from "./AgentConfigForm"; import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment"; describe("supportsAdapterModelRefresh", () => { - it("enables the model refresh action for Claude, Codex, and ACPX adapters", () => { + it("enables the model refresh action for Claude and Codex adapters", () => { expect(supportsAdapterModelRefresh("claude_local")).toBe(true); expect(supportsAdapterModelRefresh("codex_local")).toBe(true); - expect(supportsAdapterModelRefresh("acpx_local")).toBe(true); + expect(supportsAdapterModelRefresh("acpx_local")).toBe(false); }); it("keeps the refresh action hidden for adapters without a live refresh hook", () => { diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 7bad3c8117..518b2df2a5 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -57,7 +57,6 @@ import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display- import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; -import { filterAcpxModelsByAgent } from "../lib/acpx-model-filter"; import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment"; /* ---- Create mode values ---- */ @@ -117,7 +116,7 @@ const emptyOverlay: AgentConfigOverlay = { const EMPTY_ENV: Record = {}; export function supportsAdapterModelRefresh(adapterType: string): boolean { - return adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "acpx_local"; + return adapterType === "claude_local" || adapterType === "codex_local"; } function isOverlayDirty(o: AgentConfigOverlay): boolean { @@ -465,20 +464,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); const [refreshModelsError, setRefreshModelsError] = useState(null); const [refreshingModels, setRefreshingModels] = useState(false); - const rawModels = fetchedModels ?? externalModels ?? []; + const models = fetchedModels ?? externalModels ?? []; const adapterCommandField = "command"; - const acpxAgent = - adapterType === "acpx_local" - ? isCreate - ? String(val!.adapterSchemaValues?.agent ?? "claude") - : eff("adapterConfig", "agent", String(config.agent ?? "claude")) - : ""; - const models = useMemo( - () => adapterType === "acpx_local" - ? filterAcpxModelsByAgent(rawModels, acpxAgent) - : rawModels, - [adapterType, rawModels, acpxAgent], - ); const { data: detectedModelData, refetch: refetchDetectedModel, @@ -781,23 +768,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" - : adapterType === "acpx_local" && acpxAgent === "codex" - ? "modelReasoningEffort" - : adapterType === "cursor" - ? "mode" - : adapterType === "opencode_local" - ? "variant" - : "effort"; + : adapterType === "cursor" + ? "mode" + : adapterType === "opencode_local" + ? "variant" + : "effort"; const thinkingEffortOptions = adapterType === "codex_local" ? codexThinkingEffortOptions - : adapterType === "acpx_local" && acpxAgent === "codex" - ? codexThinkingEffortOptions - : adapterType === "cursor" - ? cursorModeOptions - : adapterType === "opencode_local" - ? openCodeThinkingEffortOptions - : claudeThinkingEffortOptions; + : adapterType === "cursor" + ? cursorModeOptions + : adapterType === "opencode_local" + ? openCodeThinkingEffortOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -806,17 +789,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) - : adapterType === "acpx_local" && acpxAgent === "codex" - ? eff( - "adapterConfig", - "modelReasoningEffort", - String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? ""), - ) - : adapterType === "cursor" - ? eff("adapterConfig", "mode", String(config.mode ?? "")) - : adapterType === "opencode_local" - ? eff("adapterConfig", "variant", String(config.variant ?? "")) - : eff("adapterConfig", "effort", String(config.effort ?? "")); + : adapterType === "cursor" + ? eff("adapterConfig", "mode", String(config.mode ?? "")) + : adapterType === "opencode_local" + ? eff("adapterConfig", "variant", String(config.variant ?? "")) + : eff("adapterConfig", "effort", String(config.effort ?? "")); const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud"; const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 15e1d5aed1..f9b7e39f1f 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { parseAcpxStdoutLine } from "@paperclipai/adapter-acpx-local/ui"; +import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui"; import type { TranscriptEntry } from "../../adapters"; import { buildTranscript, type RunLogChunk } from "../../adapters"; import { ThemeProvider } from "../../context/ThemeContext"; diff --git a/ui/src/lib/acpx-model-filter.test.ts b/ui/src/lib/acpx-model-filter.test.ts deleted file mode 100644 index ae4d157ecb..0000000000 --- a/ui/src/lib/acpx-model-filter.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { filterAcpxModelsByAgent } from "./acpx-model-filter"; - -const mixedModels = [ - { id: "claude-sonnet-4-6", label: "Claude: Claude Sonnet 4.6" }, - { id: "gpt-5.3-codex", label: "Codex: gpt-5.3-codex" }, - { id: "provider/custom-model", label: "Custom model" }, -]; - -describe("filterAcpxModelsByAgent", () => { - it("keeps only Claude models when ACPX Claude is selected", () => { - expect(filterAcpxModelsByAgent(mixedModels, "claude").map((model) => model.id)).toEqual([ - "claude-sonnet-4-6", - ]); - }); - - it("keeps only Codex models when ACPX Codex is selected", () => { - expect(filterAcpxModelsByAgent(mixedModels, "codex").map((model) => model.id)).toEqual([ - "gpt-5.3-codex", - ]); - }); - - it("does not show built-in provider models for custom ACP commands", () => { - expect(filterAcpxModelsByAgent(mixedModels, "custom")).toEqual([]); - }); -}); diff --git a/ui/src/lib/acpx-model-filter.ts b/ui/src/lib/acpx-model-filter.ts deleted file mode 100644 index ff13f9d311..0000000000 --- a/ui/src/lib/acpx-model-filter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { AdapterModel } from "../api/agents"; -import { models as CLAUDE_LOCAL_MODELS } from "@paperclipai/adapter-claude-local"; -import { models as CODEX_LOCAL_MODELS } from "@paperclipai/adapter-codex-local"; - -const claudeModelIds = new Set(CLAUDE_LOCAL_MODELS.map((model) => model.id)); -const codexModelIds = new Set(CODEX_LOCAL_MODELS.map((model) => model.id)); - -export function filterAcpxModelsByAgent(models: AdapterModel[], acpxAgent: string): AdapterModel[] { - if (acpxAgent === "claude") { - return models.filter((model) => claudeModelIds.has(model.id) || model.label.startsWith("Claude: ")); - } - if (acpxAgent === "codex") { - return models.filter((model) => codexModelIds.has(model.id) || model.label.startsWith("Codex: ")); - } - return []; -} diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index 887c2e7e68..6f75891dce 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -623,6 +623,7 @@ export function AdapterManager() { supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, + supportsAcp: false, }, }} canRemove={false} diff --git a/ui/storybook/stories/acpx-local.stories.tsx b/ui/storybook/stories/acpx-local.stories.tsx deleted file mode 100644 index d2b9daee1e..0000000000 --- a/ui/storybook/stories/acpx-local.stories.tsx +++ /dev/null @@ -1,903 +0,0 @@ -import { useMemo, useState, type ReactNode } from "react"; -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useQueryClient } from "@tanstack/react-query"; -import type { AdapterConfigSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; -import { parseAcpxStdoutLine } from "@paperclipai/adapter-acpx-local/ui"; -import type { - Agent, - AgentSkillSnapshot, - CompanySkillListItem, -} from "@paperclipai/shared"; -import { SchemaConfigFields } from "@/adapters/schema-config-fields"; -import type { TranscriptEntry } from "@/adapters"; -import { RunTranscriptView } from "@/components/transcript/RunTranscriptView"; -import { AgentSkillsTab } from "@/pages/AgentDetail"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { queryKeys } from "@/lib/queryKeys"; - -type SchemaWindow = typeof window & { - __paperclipStorybookAdapterSchemas?: Record; -}; - -// Mirrors packages/adapters/acpx-local/src/server/config-schema.ts. Inlined so the -// storybook bundle does not pull node-only imports from the adapter server entry. -const acpxLocalConfigSchema: AdapterConfigSchema = { - fields: [ - { - key: "agent", - label: "ACP agent", - type: "select", - default: "claude", - required: true, - options: [ - { value: "claude", label: "Claude via ACPX" }, - { value: "codex", label: "Codex via ACPX" }, - { value: "custom", label: "Custom ACP command" }, - ], - hint: "Choose the ACP agent launched through ACPX.", - }, - { - key: "agentCommand", - label: "Agent command", - type: "text", - hint: "Required for custom agents; optional override for built-in Claude or Codex ACP commands.", - }, - { - key: "nonInteractivePermissions", - label: "Non-interactive permissions", - type: "select", - default: "deny", - options: [ - { value: "deny", label: "Deny" }, - { value: "fail", label: "Fail" }, - ], - hint: "Fallback if the ACP agent asks for input outside an interactive session. Paperclip still auto-approves permissions by default.", - }, - { - key: "cwd", - label: "Working directory", - type: "text", - hint: "Absolute fallback directory. Paperclip execution workspaces can override this at runtime.", - }, - { - key: "stateDir", - label: "State directory", - type: "text", - hint: "Optional ACPX session state directory. Defaults to Paperclip-managed company/agent scoped storage.", - }, - { - key: "fastMode", - label: "Codex fast mode", - type: "toggle", - default: false, - hint: "Only applies when ACP agent is Codex. Requests Codex Fast mode through ACP session config.", - meta: { visibleWhen: { key: "agent", values: ["codex"] } }, - }, - { key: "timeoutSec", label: "Timeout seconds", type: "number", default: 0 }, - { - key: "warmHandleIdleMs", - label: "Warm process idle ms", - type: "number", - default: 0, - hint: "Defaults to 0, which closes the ACPX process after each run while retaining persistent session state.", - }, - { - key: "env", - label: "Environment JSON", - type: "textarea", - hint: "Optional JSON object of environment values or secret bindings.", - }, - ], -}; - -function installAcpxSchemaMock(): void { - if (typeof window === "undefined") return; - const win = window as SchemaWindow; - win.__paperclipStorybookAdapterSchemas = { - ...(win.__paperclipStorybookAdapterSchemas ?? {}), - acpx_local: acpxLocalConfigSchema, - }; -} - -function ConfigSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) { - return ( - - - {title} - {description && ( -

{description}

- )} -
- -
{children}
-
-
- ); -} - -function AcpxLocalConfigStory() { - installAcpxSchemaMock(); - - const [values, setValues] = useState(() => ({ - name: "", - role: "", - title: "", - capabilities: "", - icon: "code", - adapterType: "acpx_local", - command: "", - promptTemplate: "", - bootstrapPromptTemplate: "", - instructionsFilePath: "", - extraArgs: "", - envVars: "", - envBindings: {}, - runtimeServicesJson: "", - runtimeDesiredState: "manual", - runtimeServiceStates: {}, - heartbeatEnabled: false, - intervalSec: 900, - wakeOnDemand: true, - cooldownSec: 60, - maxConcurrentRuns: 1, - pauseOnIdle: false, - idleTimeoutSec: 0, - runtimeMaxStuckHeartbeats: 0, - adapterSchemaValues: {}, - } as unknown as CreateConfigValues)); - - return ( -
-
- - UX preview - -

Agent config — acpx_local

-

- Renders the schema-driven adapter config block exactly as the operator sees it inside the agent edit form. - Defaults reflect Phase 3 of PAP-2944: maximum-permission auto-approve, persistent session mode, Claude as the - default ACP agent. -

-
- - - setValues((current) => ({ ...current, ...patch }))} - config={{}} - eff={(_group, _field, original) => original} - mark={() => {}} - models={[]} - /> - - - -
-          {JSON.stringify(values.adapterSchemaValues ?? {}, null, 2)}
-        
-
-
- ); -} - -const ACPX_TS_BASE = new Date("2026-04-30T15:30:00.000Z").getTime(); - -function ts(offsetMs: number): string { - return new Date(ACPX_TS_BASE + offsetMs).toISOString(); -} - -function flattenLines(lines: Array<{ payload: Record; offsetMs: number }>): TranscriptEntry[] { - const entries: TranscriptEntry[] = []; - for (const { payload, offsetMs } of lines) { - const parsed = parseAcpxStdoutLine(JSON.stringify(payload), ts(offsetMs)); - entries.push(...parsed); - } - return entries; -} - -function useAcpxTranscript(): TranscriptEntry[] { - return useMemo( - () => - flattenLines([ - { - offsetMs: 0, - payload: { - type: "acpx.session", - agent: "claude", - mode: "persistent", - permissionMode: "approve-all", - acpSessionId: "acp_session_42a8c1", - runtimeSessionName: "acpx-claude-PAP-1812", - }, - }, - { - offsetMs: 800, - payload: { - type: "acpx.status", - tag: "context_window", - used: 12000, - size: 200000, - }, - }, - { - offsetMs: 1200, - payload: { - type: "acpx.text_delta", - text: "Looking at the failing test in `runtime-state.test.ts` — ", - channel: "thought", - }, - }, - { - offsetMs: 1500, - payload: { - type: "acpx.text_delta", - text: "the assertion expects `pendingRestart` but the new state machine uses `restartScheduled`.\n", - channel: "thought", - }, - }, - { - offsetMs: 1900, - payload: { - type: "acpx.text_delta", - text: "I'll inspect the test file to confirm the change.\n\n", - channel: "output", - tag: "agent_message_chunk", - }, - }, - { - offsetMs: 2200, - payload: { - type: "acpx.tool_call", - name: "read", - toolCallId: "tool_read_01", - status: "running", - text: "server/src/runtime-state.test.ts", - input: { path: "server/src/runtime-state.test.ts" }, - }, - }, - { - offsetMs: 3500, - payload: { - type: "acpx.tool_call", - name: "read", - toolCallId: "tool_read_01", - status: "completed", - text: "Read 142 lines", - }, - }, - { - offsetMs: 3700, - payload: { - type: "acpx.text_delta", - text: - "The test still references the old `pendingRestart` field. I'll update the assertion to use the renamed `restartScheduled` flag.\n\n", - channel: "output", - }, - }, - { - offsetMs: 4200, - payload: { - type: "acpx.tool_call", - name: "edit", - toolCallId: "tool_edit_02", - status: "running", - input: { - path: "server/src/runtime-state.test.ts", - find: "expect(state.pendingRestart).toBe(true)", - replace: "expect(state.restartScheduled).toBe(true)", - }, - }, - }, - { - offsetMs: 5400, - payload: { - type: "acpx.tool_call", - name: "edit", - toolCallId: "tool_edit_02", - status: "completed", - text: "1 replacement", - }, - }, - { - offsetMs: 5800, - payload: { - type: "acpx.status", - text: "Running vitest for runtime-state.test.ts", - }, - }, - { - offsetMs: 6100, - payload: { - type: "acpx.tool_call", - name: "command", - toolCallId: "tool_run_03", - status: "running", - input: { command: "pnpm exec vitest run server/src/runtime-state.test.ts" }, - }, - }, - { - offsetMs: 9100, - payload: { - type: "acpx.tool_call", - name: "command", - toolCallId: "tool_run_03", - status: "completed", - text: - "Test Files 1 passed (1)\nTests 6 passed (6)\nDuration 2.31s", - }, - }, - { - offsetMs: 9400, - payload: { - type: "acpx.text_delta", - text: - "**Test passes.** Updated `runtime-state.test.ts` to assert against `restartScheduled` instead of the renamed `pendingRestart` field.\n\n", - channel: "output", - }, - }, - { - offsetMs: 9600, - payload: { - type: "acpx.text_delta", - text: - "Next I'll update the issue with a summary and hand it back to QA for verification.", - channel: "output", - }, - }, - { - offsetMs: 9800, - payload: { - type: "acpx.status", - tag: "context_window", - used: 18450, - size: 200000, - }, - }, - { - offsetMs: 10000, - payload: { - type: "acpx.result", - summary: "completed", - stopReason: "end_turn", - inputTokens: 18450, - outputTokens: 412, - cachedTokens: 12000, - costUsd: 0.024, - subtype: "end_turn", - }, - }, - ]), - [], - ); -} - -function AcpxLocalTranscriptStory() { - const entries = useAcpxTranscript(); - - return ( -
-
- - UX preview - -

Run transcript — acpx_local streamed events

-

- Demonstrates how a streamed acpx_local run renders through the existing transcript pipeline. Events flow - through parseAcpxStdoutLine (session init, thought delta, assistant delta, tool call/result - pairs, context window status, final result) and into RunTranscriptView in nice mode. -

-
- - - - Run Transcript (nice mode) -

- Streaming, comfortable density. Mirrors the agent detail page transcript surface. -

-
- - - -
- - - - Run Transcript (compact density) -

- Same parsed events, compact density — matches the live-run widget on the issue thread. -

-
- - - -
-
- ); -} - -const SKILLS_COMPANY_ID = "company-storybook"; - -const defaultStoreSkillFields = { - iconUrl: null, - color: null, - tagline: null, - authorName: null, - homepageUrl: null, - categories: [], - sharingScope: "company" as const, - publicShareToken: null, - forkedFromSkillId: null, - forkedFromCompanyId: null, - starCount: 0, - installCount: 1, - forkCount: 0, - currentVersionId: null, -}; - -const acpxSkillsCompanyLibrary: CompanySkillListItem[] = [ - { - id: "skill-paperclip", - companyId: SKILLS_COMPANY_ID, - key: "paperclip", - slug: "paperclip", - name: "Paperclip", - description: - "Coordination skill: heartbeats, checkout, comments, and routine API patterns for Paperclip agents.", - sourceType: "local_path", - sourceLocator: "skills/paperclip", - sourceRef: null, - trustLevel: "scripts_executables", - compatibility: "compatible", - fileInventory: [{ path: "SKILL.md", kind: "skill" }], - ...defaultStoreSkillFields, - createdAt: new Date("2026-04-12T09:00:00.000Z"), - updatedAt: new Date("2026-04-22T15:30:00.000Z"), - attachedAgentCount: 4, - editable: false, - editableReason: "Required by Paperclip", - sourceLabel: "Paperclip", - sourceBadge: "paperclip", - sourcePath: "skills/paperclip", - catalogKind: null, - originHash: null, - packageName: null, - packageVersion: null, - }, - { - id: "skill-design-guide", - companyId: SKILLS_COMPANY_ID, - key: "design-guide", - slug: "design-guide", - name: "Design guide", - description: - "Paperclip UI design system reference: tokens, typography, status colors, and reusable component patterns.", - sourceType: "local_path", - sourceLocator: "skills/design-guide", - sourceRef: null, - trustLevel: "markdown_only", - compatibility: "compatible", - fileInventory: [{ path: "SKILL.md", kind: "skill" }], - ...defaultStoreSkillFields, - createdAt: new Date("2026-04-15T10:00:00.000Z"), - updatedAt: new Date("2026-04-25T12:00:00.000Z"), - attachedAgentCount: 2, - editable: true, - editableReason: null, - sourceLabel: "Local", - sourceBadge: "local", - sourcePath: "skills/design-guide", - catalogKind: null, - originHash: null, - packageName: null, - packageVersion: null, - }, - { - id: "skill-mobile-qa", - companyId: SKILLS_COMPANY_ID, - key: "mobile-app-qa", - slug: "mobile-app-qa", - name: "Mobile app QA", - description: - "Exploratory QA flows for mobile/web apps using Chrome automation. Captures bugs and writes a final report.", - sourceType: "local_path", - sourceLocator: "skills/mobile-app-qa", - sourceRef: null, - trustLevel: "assets", - compatibility: "compatible", - fileInventory: [{ path: "SKILL.md", kind: "skill" }], - ...defaultStoreSkillFields, - createdAt: new Date("2026-04-18T11:00:00.000Z"), - updatedAt: new Date("2026-04-26T09:30:00.000Z"), - attachedAgentCount: 1, - editable: true, - editableReason: null, - sourceLabel: "Local", - sourceBadge: "local", - sourcePath: "skills/mobile-app-qa", - catalogKind: null, - originHash: null, - packageName: null, - packageVersion: null, - }, -]; - -function buildAcpxAgent({ - agentId, - acpAgent, - desiredSkills, -}: { - agentId: string; - acpAgent: "claude" | "codex" | "custom"; - desiredSkills: string[]; -}): Agent { - return { - id: agentId, - companyId: SKILLS_COMPANY_ID, - name: `ACPX ${acpAgent === "custom" ? "Custom" : acpAgent === "codex" ? "Codex" : "Claude"}`, - urlKey: `acpx-${acpAgent}`, - role: "engineer", - title: `ACPX ${acpAgent} agent`, - icon: "code", - status: "idle", - reportsTo: null, - capabilities: "Routes work through the ACPX adapter for skill-tagged agent flows.", - adapterType: "acpx_local", - adapterConfig: { - agent: acpAgent, - mode: "persistent", - permissionMode: "approve-all", - paperclipSkillSync: { - desiredSkills, - }, - }, - runtimeConfig: {}, - budgetMonthlyCents: 100_000, - spentMonthlyCents: 0, - pauseReason: null, - pausedAt: null, - permissions: { canCreateAgents: false }, - lastHeartbeatAt: null, - metadata: null, - createdAt: new Date("2026-04-30T12:00:00.000Z"), - updatedAt: new Date("2026-04-30T12:00:00.000Z"), - } as Agent; -} - -function buildAcpxClaudeSnapshot(): AgentSkillSnapshot { - return { - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclip", "design-guide"], - warnings: [], - entries: [ - { - key: "paperclip", - runtimeName: "paperclip", - desired: true, - managed: true, - state: "configured", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/paperclip", - targetPath: null, - detail: "Will be mounted into the next ACPX Claude session.", - }, - { - key: "design-guide", - runtimeName: "design-guide", - desired: true, - managed: true, - state: "configured", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/design-guide", - targetPath: null, - detail: "Will be mounted into the next ACPX Claude session.", - }, - { - key: "mobile-app-qa", - runtimeName: "mobile-app-qa", - desired: false, - managed: true, - state: "available", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/mobile-app-qa", - targetPath: null, - detail: null, - }, - ], - }; -} - -function buildAcpxCodexSnapshot(): AgentSkillSnapshot { - return { - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: ["paperclip"], - warnings: [], - entries: [ - { - key: "paperclip", - runtimeName: "paperclip", - desired: true, - managed: true, - state: "configured", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/paperclip", - targetPath: null, - detail: "Will be linked into the effective CODEX_HOME/skills/ directory for the next ACPX Codex session.", - }, - { - key: "design-guide", - runtimeName: "design-guide", - desired: false, - managed: true, - state: "available", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/design-guide", - targetPath: null, - detail: null, - }, - { - key: "mobile-app-qa", - runtimeName: "mobile-app-qa", - desired: false, - managed: true, - state: "available", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/mobile-app-qa", - targetPath: null, - detail: null, - }, - ], - }; -} - -function buildAcpxCustomSnapshot(): AgentSkillSnapshot { - return { - adapterType: "acpx_local", - supported: false, - mode: "unsupported", - desiredSkills: ["design-guide"], - warnings: [ - "Custom ACP commands do not expose a Paperclip skill integration contract yet; selected skills are tracked only.", - ], - entries: [ - { - key: "paperclip", - runtimeName: "paperclip", - desired: false, - managed: true, - state: "available", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/paperclip", - targetPath: null, - detail: null, - }, - { - key: "design-guide", - runtimeName: "design-guide", - desired: true, - managed: true, - state: "configured", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/design-guide", - targetPath: null, - detail: - "Desired state is stored in Paperclip only; custom ACP commands need an explicit skill integration contract before runtime sync is available.", - }, - { - key: "mobile-app-qa", - runtimeName: "mobile-app-qa", - desired: false, - managed: true, - state: "available", - origin: "company_managed", - originLabel: "Managed by Paperclip", - readOnly: false, - sourcePath: "skills/mobile-app-qa", - targetPath: null, - detail: null, - }, - ], - }; -} - -function StoryFrame({ - title, - subtitle, - children, -}: { - title: string; - subtitle: string; - children: ReactNode; -}) { - return ( -
-
- - UX preview - -

{title}

-

{subtitle}

-
- - - - Agent detail — Skills tab - - {children} - -
- ); -} - -function AcpxSkillsState({ - agent, - snapshot, - library, -}: { - agent: Agent; - snapshot: AgentSkillSnapshot; - library: CompanySkillListItem[]; -}) { - const queryClient = useQueryClient(); - queryClient.setQueryData(queryKeys.companySkills.list(SKILLS_COMPANY_ID), library); - queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot); - return ; -} - -function AcpxClaudeSkillsStory() { - const agent = buildAcpxAgent({ - agentId: "agent-acpx-claude", - acpAgent: "claude", - desiredSkills: ["paperclip", "design-guide"], - }); - return ( - - - - ); -} - -function AcpxCodexSkillsStory() { - const agent = buildAcpxAgent({ - agentId: "agent-acpx-codex", - acpAgent: "codex", - desiredSkills: ["paperclip"], - }); - return ( - - - - ); -} - -function AcpxCustomSkillsStory() { - const agent = buildAcpxAgent({ - agentId: "agent-acpx-custom", - acpAgent: "custom", - desiredSkills: ["design-guide"], - }); - return ( - - - - ); -} - -function AcpxClaudeSkillsLoadingStory() { - const agent = buildAcpxAgent({ - agentId: "agent-acpx-claude-loading", - acpAgent: "claude", - desiredSkills: [], - }); - return ( - - - - ); -} - -function AcpxClaudeSkillsEmptyLibraryStory() { - const agent = buildAcpxAgent({ - agentId: "agent-acpx-claude-empty", - acpAgent: "claude", - desiredSkills: [], - }); - const emptySnapshot: AgentSkillSnapshot = { - adapterType: "acpx_local", - supported: true, - mode: "ephemeral", - desiredSkills: [], - warnings: [], - entries: [], - }; - return ( - - - - ); -} - -const meta: Meta = { - title: "Adapters / acpx_local", - parameters: { - layout: "fullscreen", - }, -}; - -export default meta; - -export const ConfigForm: StoryObj = { - name: "Agent config form", - render: () => , -}; - -export const Transcript: StoryObj = { - name: "Streamed run transcript", - render: () => , -}; - -export const SkillsTabClaude: StoryObj = { - name: "Skills tab — ACPX Claude", - render: () => , -}; - -export const SkillsTabCodex: StoryObj = { - name: "Skills tab — ACPX Codex", - render: () => , -}; - -export const SkillsTabCustom: StoryObj = { - name: "Skills tab — ACPX custom (unsupported)", - render: () => , -}; - -export const SkillsTabLoading: StoryObj = { - name: "Skills tab — loading", - render: () => , -}; - -export const SkillsTabEmptyLibrary: StoryObj = { - name: "Skills tab — empty company library", - render: () => , -}; diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agent-management.stories.tsx index 90483f5367..9dffe870e7 100644 --- a/ui/storybook/stories/agent-management.stories.tsx +++ b/ui/storybook/stories/agent-management.stories.tsx @@ -315,6 +315,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, + supportsAcp: true, }, }, { @@ -330,6 +331,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, + supportsAcp: true, }, }, { @@ -345,6 +347,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, + supportsAcp: false, }, }, ]; diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/dialogs-modals.stories.tsx index 24e25c74d2..b721e4a897 100644 --- a/ui/storybook/stories/dialogs-modals.stories.tsx +++ b/ui/storybook/stories/dialogs-modals.stories.tsx @@ -384,6 +384,7 @@ function hydrateDialogQueries(queryClient: ReturnType) { supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, + supportsAcp: true, }, }, { @@ -399,6 +400,7 @@ function hydrateDialogQueries(queryClient: ReturnType) { supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, + supportsAcp: true, }, }, ]); @@ -732,6 +734,7 @@ function useCheapLaneAdapterOverrides(variant: CheapLaneVariant) { supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, + supportsAcp: true, }, }, { @@ -747,6 +750,7 @@ function useCheapLaneAdapterOverrides(variant: CheapLaneVariant) { supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, + supportsAcp: false, }, }, ]); diff --git a/vitest.config.ts b/vitest.config.ts index a9a293267d..114dd2094c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,6 @@ export default defineConfig({ "packages/skills-catalog", "packages/db", "packages/adapter-utils", - "packages/adapters/acpx-local", "packages/adapters/claude-local", "packages/adapters/codex-local", "packages/adapters/cursor-cloud",