Make ACP the default engine for local adapters (#9238)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Adapter packages are the bridge between the control plane and local agent harnesses such as Claude Code, Codex, and Gemini CLI. > - ACP support was concentrated in a separate `acpx_local` adapter, which made ACP feel like a separate agent choice instead of an execution capability of the harness adapters. > - Claude, Codex, and Gemini now have ACP-capable harnesses, so the native adapter should own ACP selection, fallback, config, transcript parsing, and environment diagnostics. > - The standalone ACPX adapter still needs a compatibility path for existing rows, but it should not be offered as an active adapter for new agents. > - This pull request moves the shared ACP runtime into `@paperclipai/acpx-engine`, wires Claude/Codex/Gemini local adapters to prefer ACP when prerequisites are available, and retires `acpx_local` to a tombstone. > - The benefit is one adapter per harness, richer ACP transcripts by default where possible, and a migration path for existing Claude/Codex ACPX agents. ## Linked Issues or Issue Description Closes #5932 — the broken default `acpx_local` Claude path is replaced by native `claude_local` ACP support, existing Claude/Codex ACPX rows migrate to native adapters, and new agents no longer choose the standalone ACPX adapter. Refs #4893 — original merged ACPX local adapter runtime that this PR replaces with native per-harness ACP engines. Refs #6590 — prior ACPX-Claude seamlessness work folded into the new native Claude ACP path. Refs #197 — related open generic ACP/Kiro adapter work; this PR does not close it because Kiro/custom generic ACP remains a separate adapter decision. Refs #7018 — related Kimi-specific `acpx_local` shell failure; this PR retires the built-in standalone adapter but does not add a native Kimi adapter. Refs #8864 — related ACPX prompt/API guidance PR; this PR moves runtime guidance into the shared/native ACP engine path instead of the old standalone adapter. Refs #8881 — related `acpx_local` POSIX shell failure from the old `acpx` pin; this PR updates ACP dependencies but does not claim custom/OMP ACP support as a first-class native adapter. Refs #8964 — related open `acpx_local` stderr cleanup PR; this PR makes the old runtime path obsolete for new agents but keeps it as a non-closing reference. Problem description: - The standalone `acpx_local` adapter duplicates Claude/Codex agent choices that already have first-class local adapters. - ACP should be an execution engine capability of each harness adapter when the underlying harness supports ACP. - Existing `acpx_local` agents should either migrate to native harness adapters or fail with an explicit retirement message instead of silently falling back to the process adapter. ## What Changed - Added `@paperclipai/acpx-engine` as the shared ACP execution, session-codec, CLI formatter, and UI parser package. - Wired `claude_local`, `codex_local`, and `gemini_local` to auto-select ACP by default when prerequisites pass, with `engine=cli` opt-out and `engine=acp` strict mode. - Added ACP config schema/UI fields, environment checks, session-codec preservation, transcript parsing, and adapter capability metadata for the native adapters. - Retired `acpx_local` to a server tombstone, removed its UI/package/runtime image surface, and added a migration for existing Claude/Codex ACPX agents. - Updated package manifests, lockfile, release tooling, docs, Kubernetes sandbox defaults, and tests. ## Verification - `corepack pnpm --filter @paperclipai/acpx-engine typecheck` - `corepack pnpm --filter @paperclipai/adapter-claude-local typecheck` - `corepack pnpm --filter @paperclipai/adapter-codex-local typecheck` - `corepack pnpm --filter @paperclipai/adapter-gemini-local typecheck` - `corepack pnpm --filter @paperclipai/acpx-engine exec vitest run` - `corepack pnpm --filter @paperclipai/adapter-claude-local exec vitest run src/server/acp.test.ts src/server/execute.acp-fallback.test.ts src/ui/build-config.test.ts` - `corepack pnpm --filter @paperclipai/adapter-codex-local exec vitest run src/server/acp.test.ts src/ui/build-config.test.ts` - `corepack pnpm --filter @paperclipai/adapter-gemini-local exec vitest run src/server/acp.test.ts src/ui/build-config.test.ts src/ui/parse-stdout.test.ts` - `corepack pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && corepack pnpm --filter @paperclipai/server exec tsc --noEmit` - `corepack pnpm --filter @paperclipai/server exec vitest run src/__tests__/adapter-routes.test.ts src/__tests__/adapter-session-codecs.test.ts src/__tests__/adapter-models.test.ts` - `corepack pnpm --filter @paperclipai/ui typecheck` - `corepack pnpm --filter @paperclipai/ui exec vitest run src/adapters/metadata.test.ts src/adapters/adapter-display-registry.test.ts src/components/AgentConfigForm.test.ts src/components/AgentConfigForm.render.test.tsx src/components/transcript/RunTranscriptView.test.tsx` - `node --test scripts/bootstrap-npm-package.test.mjs scripts/release-package-map.test.mjs scripts/verify-release-registry-state.test.mjs` Note: the server typecheck script calls `pnpm` internally; this dev shell exposes pnpm through Corepack only, so I ran the two script steps manually with `corepack pnpm`. ## Risks - Migration changes existing `acpx_local` Claude/Codex agents to native adapter types and clears old ACPX task sessions/runtime state. - Custom ACP commands remain on the retired tombstone and will need a separate future adapter/plugin path. - ACP auto-selection depends on local Node and ACP server command prerequisites; remote and unsupported environments fall back to CLI unless `engine=acp` is explicit. - `@paperclipai/acpx-engine` is a new public package and needs npm trusted-publishing bootstrap before release automation can publish it. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 via Codex coding agent. Exact hosted model build and context-window size are not exposed in this runtime. Tool use included shell execution, repository editing, GitHub CLI operations, and local test/typecheck execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
187a929d97
commit
eedc7ddef2
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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<string, CLIAdapterModule>(
|
||||
[
|
||||
acpxLocalCLIAdapter,
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
|
|
|
|||
|
|
@ -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 <code>` 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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
executionTransport?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
executionTarget?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const runtimeOptions: Record<string, unknown>[] = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const execute = createAcpxLocalExecutor({
|
||||
const execute = createAcpxEngineExecutor({
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as unknown as Record<string, unknown>);
|
||||
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<string, number> | 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<string, boolean | undefined> = {};
|
||||
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<string> {
|
||||
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",
|
||||
|
|
@ -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<string, RuntimeCacheEntry>;
|
||||
adapterType?: string;
|
||||
moduleDir?: string;
|
||||
packageRootDir?: string;
|
||||
}
|
||||
|
||||
interface AcpxPreparedRuntime {
|
||||
|
|
@ -108,6 +121,15 @@ interface AcpxPreparedRuntime {
|
|||
|
||||
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
|
||||
|
||||
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/<binName>`. This matches
|
||||
// npm/pnpm binary hoisting in packaged installs while preserving monorepo dev.
|
||||
export async function findAncestorBin(startDir: string, binName: string): Promise<string | null> {
|
||||
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<BuiltInAgentCommand | null> {
|
||||
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<string> {
|
||||
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, unknown>): 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<boolean> {
|
||||
|
|
@ -301,8 +393,9 @@ async function buildSkillSetKey(input: {
|
|||
|
||||
async function resolveSelectedRuntimeSkills(
|
||||
config: Record<string, unknown>,
|
||||
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<string, unknown>;
|
||||
moduleDir: string;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<{
|
||||
identity: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
env: Record<string, string>;
|
||||
moduleDir: string;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<{ identity: Record<string, unknown>; 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, unknown>): 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<string, unknown>;
|
||||
moduleDir: string;
|
||||
onLog: AdapterExecutionContext["onLog"];
|
||||
}): Promise<{ identity: Record<string, unknown>; 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<string, unknown>): "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<string, unknown>): "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<string, unknown>): "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 | null | undefined>): 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<AcpxPreparedRuntime> {
|
||||
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, string>): 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, string>): 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<string, string>): Promise<{
|
||||
prompt: string;
|
||||
promptMetrics: Record<string, number>;
|
||||
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<AdapterExecutionResult> {
|
||||
const prepared = await buildRuntime({ ctx });
|
||||
return async function executeAcpxEngine(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
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();
|
||||
|
|
@ -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";
|
||||
|
|
@ -28,6 +28,7 @@ export type {
|
|||
ConfigFieldSchema,
|
||||
AdapterConfigSchema,
|
||||
AdapterRuntimeCommandSpec,
|
||||
AcpTargetDescriptor,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
|
|
|
|||
|
|
@ -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<string, AdapterSessionManagement> = {
|
||||
acpx_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
claude_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
|
|
|
|||
|
|
@ -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<AdapterExecutionResult>;
|
||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||
acp?: AcpTargetDescriptor;
|
||||
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
|
||||
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { printAcpxStreamEvent } from "./format-event.js";
|
||||
|
||||
function emit(payload: Record<string, unknown>): 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { printAcpxStreamEvent } from "./format-event.js";
|
||||
|
|
@ -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.
|
||||
`;
|
||||
|
|
@ -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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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<string, unknown>): 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<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
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<AdapterSkillSnapshot> {
|
||||
return buildAcpxSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncAcpxSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildAcpxSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string>, key: string): string | undefined {
|
||||
const configured = configEnv[key];
|
||||
if (typeof configured === "string") return configured;
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
function credentialSource(configEnv: Record<string, string>, key: string): string {
|
||||
return typeof configEnv[key] === "string" ? "adapter config env" : "server environment";
|
||||
}
|
||||
|
||||
async function readJsonObject(filePath: string): Promise<Record<string, unknown> | 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<string, unknown>
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readNestedString(record: Record<string, unknown>, 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<string, unknown>)[segment];
|
||||
}
|
||||
return isNonEmpty(current) ? current.trim() : null;
|
||||
}
|
||||
|
||||
async function hasClaudeSubscriptionCredentials(configDir: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<string, string>,
|
||||
): Promise<AdapterEnvironmentCheck[]> {
|
||||
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<AdapterEnvironmentCheck | null> {
|
||||
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<AdapterEnvironmentTestResult> {
|
||||
const config = parseObject(ctx.config);
|
||||
const envConfig = parseObject(config.env);
|
||||
const configEnv: Record<string, string> = {};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
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<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
} 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<string, unknown> {
|
||||
const schemaValues = v.adapterSchemaValues ?? {};
|
||||
const agent = String(schemaValues.agent || DEFAULT_ACPX_LOCAL_AGENT);
|
||||
const ac: Record<string, unknown> = {
|
||||
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<string, unknown>);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { parseAcpxStdoutLine } from "./parse-stdout.js";
|
||||
export { buildAcpxLocalConfig } from "./build-config.js";
|
||||
|
|
@ -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, unknown>): 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" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<FakeRuntimeEvent>;
|
||||
result: Promise<FakeRuntimeTurnResult>;
|
||||
cancel: () => Promise<void>;
|
||||
closeStream: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<FakeRuntimeHandle> {
|
||||
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<FakeRuntimeEvent> {
|
||||
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> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AdapterExecutionContext, "config"> &
|
||||
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
|
||||
|
||||
type ClaudeAcpExecutorOptions = Omit<
|
||||
AcpxEngineExecutorOptions,
|
||||
"adapterType" | "moduleDir" | "packageRootDir"
|
||||
>;
|
||||
|
||||
type ClaudeAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
|
||||
|
||||
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<string, unknown>): ClaudeEngineSelection {
|
||||
return normalizeEngine(config.engine);
|
||||
}
|
||||
|
||||
export async function resolveClaudeExecutionEngineForRun(
|
||||
input: ClaudeEngineResolutionInput,
|
||||
): Promise<ClaudeEngineSelection> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<string, unknown>): Promise<string> {
|
||||
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<string | null> {
|
||||
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<AdapterEnvironmentTestResult> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown> }) =>
|
||||
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<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
function buildContext(config: Record<string, unknown> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AdapterExecutionResult> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> | 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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AdapterEnvironmentTestResult> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -64,6 +64,19 @@ function parseJsonObject(text: string): Record<string, unknown> | null {
|
|||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pc from "picocolors";
|
||||
import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | 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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<FakeRuntimeEvent>;
|
||||
result: Promise<FakeRuntimeTurnResult>;
|
||||
cancel: () => Promise<void>;
|
||||
closeStream: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<FakeRuntimeHandle> {
|
||||
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<FakeRuntimeEvent> {
|
||||
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> = {}): 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AdapterExecutionContext, "config"> &
|
||||
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
|
||||
|
||||
type CodexAcpExecutorOptions = Omit<
|
||||
AcpxEngineExecutorOptions,
|
||||
"adapterType" | "moduleDir" | "packageRootDir"
|
||||
>;
|
||||
|
||||
type CodexAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
|
||||
|
||||
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<string, unknown>): CodexEngineSelection {
|
||||
return normalizeEngine(config.engine);
|
||||
}
|
||||
|
||||
export async function resolveCodexExecutionEngineForRun(
|
||||
input: CodexEngineResolutionInput,
|
||||
): Promise<CodexEngineSelection> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<string, unknown>): Promise<string> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<string, unknown>;
|
||||
return isNonEmpty(record.OPENAI_API_KEY) || isNonEmpty(record.refresh_token);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testCodexAcpEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown> }) =>
|
||||
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<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||
"@paperclipai/adapter-utils/server-utils",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./codex-home.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./codex-home.js")>("./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<typeof import("./runtime-config.js")>("./runtime-config.js");
|
||||
return {
|
||||
...actual,
|
||||
prepareCodexRuntimeConfig,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
function buildContext(config: Record<string, unknown> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<AdapterExecutionResult> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> | 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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AdapterEnvironmentTestResult> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,17 @@ function makeValues(overrides: Partial<CreateConfigValues> = {}): 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({
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
|||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||
if (v.codexEngine === "cli" || v.codexEngine === "acp") ac.engine = v.codexEngine;
|
||||
if (v.codexEngine === "acp") {
|
||||
if (v.codexAcpAgentCommand) ac.agentCommand = v.codexAcpAgentCommand;
|
||||
ac.mode = v.codexAcpMode ?? "persistent";
|
||||
ac.nonInteractivePermissions = v.codexAcpNonInteractivePermissions ?? "deny";
|
||||
if (v.codexAcpStateDir) ac.stateDir = v.codexAcpStateDir;
|
||||
ac.warmHandleIdleMs = v.codexAcpWarmHandleIdleMs ?? 0;
|
||||
}
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -228,6 +229,9 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
|||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
if (type.startsWith("acpx.")) {
|
||||
return parseAcpxStdoutLine(line, ts);
|
||||
}
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = asString(parsed.thread_id);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import pc from "picocolors";
|
||||
import { printAcpxStreamEvent } from "@paperclipai/adapter-utils/acpx-engine/cli";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | 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") {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<FakeRuntimeEvent>;
|
||||
result: Promise<FakeRuntimeTurnResult>;
|
||||
cancel: () => Promise<void>;
|
||||
closeStream: () => Promise<void>;
|
||||
};
|
||||
|
||||
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<FakeRuntimeHandle> {
|
||||
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<FakeRuntimeEvent> {
|
||||
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> = {}): 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" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AdapterExecutionContext, "config"> &
|
||||
Partial<Pick<AdapterExecutionContext, "executionTarget" | "executionTransport">>;
|
||||
|
||||
type GeminiAcpExecutorOptions = Omit<
|
||||
AcpxEngineExecutorOptions,
|
||||
"adapterType" | "moduleDir" | "packageRootDir"
|
||||
>;
|
||||
|
||||
type GeminiAcpExecutor = (ctx: AdapterExecutionContext) => Promise<AdapterExecutionResult>;
|
||||
|
||||
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<string, unknown>): GeminiEngineSelection {
|
||||
return normalizeEngine(config.engine);
|
||||
}
|
||||
|
||||
export async function resolveGeminiExecutionEngineForRun(
|
||||
input: GeminiEngineResolutionInput,
|
||||
): Promise<GeminiEngineSelection> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
...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<boolean> {
|
||||
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<string | null> {
|
||||
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, unknown>): 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<boolean> {
|
||||
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, unknown>): 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<string | null> {
|
||||
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<AdapterEnvironmentTestResult> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown> }) =>
|
||||
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<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
runAdapterExecutionTargetProcess,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||
"@paperclipai/adapter-utils/server-utils",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
};
|
||||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
function buildContext(config: Record<string, unknown> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
|
|
@ -195,6 +201,23 @@ async function buildGeminiSkillsDir(
|
|||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<AdapterEnvironmentTestResult> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -55,6 +55,14 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
|
|||
const ac: Record<string, unknown> = {};
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -40,12 +40,6 @@ const REGISTRY: Record<string, AdapterDefaults> = {
|
|||
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"],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ export interface EnvironmentCapabilities {
|
|||
}
|
||||
|
||||
const REMOTE_MANAGED_ADAPTERS = new Set<AgentAdapterType>([
|
||||
"acpx_local",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ function usage() {
|
|||
" node scripts/bootstrap-npm-package.mjs <package-name-or-dir> [--publish --otp <code>] [--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"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AcpRuntimeHandle> {
|
||||
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<AcpRuntimeEvent> {
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
function buildContext(root: string, overrides: Partial<AdapterExecutionContext> = {}): 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<AcpRuntimeTurnResult>((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<string, unknown> | 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<string, unknown>;
|
||||
},
|
||||
}));
|
||||
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
},
|
||||
}));
|
||||
|
||||
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<string, string>).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<string, unknown> | 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<string, unknown>;
|
||||
},
|
||||
}));
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<string | undefined> = [];
|
||||
|
||||
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<void> {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue