Files
PaperClipAI/server/src/__tests__/gemini-local-execute.test.ts
T
Devin FoleyandPaperclip eedc7ddef2 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>
2026-07-08 19:05:03 -07:00

442 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execute } from "@paperclipai/adapter-gemini-local/server";
async function writeFakeGeminiCommand(commandPath: string): Promise<void> {
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
const payload = {
argv: process.argv.slice(2),
paperclipEnvKeys: Object.keys(process.env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort(),
};
if (capturePath) {
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
}
console.log(JSON.stringify({
type: "system",
subtype: "init",
session_id: "gemini-session-1",
model: "gemini-2.5-pro",
}));
console.log(JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
}));
console.log(JSON.stringify({
type: "result",
subtype: "success",
session_id: "gemini-session-1",
result: "ok",
}));
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
async function writeFailingGeminiCommand(
commandPath: string,
options: {
stdoutLines?: Array<Record<string, unknown>>;
stdout?: string;
stderr?: string;
exitCode?: number;
},
): Promise<void> {
const stdoutLines = options.stdoutLines ?? [];
const stdout = options.stdout ?? "";
const stderr = options.stderr ?? "";
const exit = options.exitCode ?? 1;
const script = `#!/usr/bin/env node
for (const line of ${JSON.stringify(stdoutLines.map((line) => JSON.stringify(line)))}) {
console.log(line);
}
if (${JSON.stringify(stdout)}) {
process.stdout.write(${JSON.stringify(stdout)});
}
if (${JSON.stringify(stderr)}) {
console.error(${JSON.stringify(stderr)});
}
process.exit(${exit});
`;
await fs.writeFile(commandPath, script, "utf8");
await fs.chmod(commandPath, 0o755);
}
type CapturePayload = {
argv: string[];
paperclipEnvKeys: string[];
};
describe("gemini execute", () => {
it("passes prompt via --prompt and injects paperclip env vars", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-execute-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
let invocationPrompt = "";
try {
const result = await execute({
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
onMeta: async (meta) => {
invocationPrompt = meta.prompt ?? "";
},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--output-format");
expect(capture.argv).toContain("stream-json");
expect(capture.argv).toContain("--prompt");
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
const promptFlagIndex = capture.argv.indexOf("--prompt");
const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : "";
expect(promptArg).toContain("Follow the paperclip heartbeat.");
expect(promptArg).toContain("Paperclip runtime note:");
expect(capture.paperclipEnvKeys).toEqual(
expect.arrayContaining([
"PAPERCLIP_AGENT_ID",
"PAPERCLIP_API_KEY",
"PAPERCLIP_API_URL",
"PAPERCLIP_COMPANY_ID",
"PAPERCLIP_RUN_ID",
]),
);
expect(invocationPrompt).toContain("Paperclip runtime note:");
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
expect(invocationPrompt).toContain("Paperclip API access note:");
expect(invocationPrompt).toContain("run_shell_command");
expect(result.question).toBeNull();
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("always passes --approval-mode yolo", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
await execute({
runId: "run-yolo",
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 },
},
context: {},
authToken: "t",
onLog: async () => {},
});
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv).not.toContain("--policy");
expect(capture.argv).not.toContain("--allow-all");
expect(capture.argv).not.toContain("--allow-read");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("normalizes turn-limit exhaustion into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turns-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
await fs.mkdir(workspace, { recursive: true });
await writeFailingGeminiCommand(commandPath, {
stdoutLines: [
{
type: "result",
subtype: "error",
session_id: "gemini-session-1",
status: "turn_limit",
error: "Turn limit reached.",
},
],
});
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-turn-limit",
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,
},
context: {},
authToken: "t",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("max_turns_exhausted");
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
expect(result.clearSession).toBe(true);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("normalizes Gemini exit code 53 as max-turn exhaustion", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-exit-53-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
await fs.mkdir(workspace, { recursive: true });
await writeFailingGeminiCommand(commandPath, {
stderr: "Gemini stopped because the max turns limit was reached.",
exitCode: 53,
});
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-exit-53",
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,
},
context: {},
authToken: "t",
onLog: async () => {},
});
expect(result.exitCode).toBe(53);
expect(result.errorCode).toBe("max_turns_exhausted");
expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" });
expect(result.clearSession).toBe(true);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("does not normalize unstructured turn-limit text into scheduler stop metadata", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turn-text-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
await fs.mkdir(workspace, { recursive: true });
await writeFailingGeminiCommand(commandPath, {
stdoutLines: [
{
type: "result",
subtype: "error",
session_id: "gemini-session-1",
error: "Tool output said: maximum turns reached.",
},
],
stdout: "attacker-controlled transcript mentions turn limit reached\n",
stderr: "Gemini stopped because the max turns limit was reached.",
});
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-turn-limit-text",
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,
},
context: {},
authToken: "t",
onLog: async () => {},
});
expect(result.exitCode).toBe(1);
expect(result.errorCode).not.toBe("max_turns_exhausted");
expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted");
expect(result.clearSession).toBe(false);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
const result = await execute({
runId: "run-resume",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Gemini Coder",
adapterType: "gemini_local",
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: "gemini-session-1",
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {
issueId: "issue-1",
taskId: "issue-1",
wakeReason: "issue_commented",
wakeCommentId: "comment-2",
paperclipWake: {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-874",
title: "chat-speed issues",
status: "in_progress",
priority: "medium",
},
commentIds: ["comment-2"],
latestCommentId: "comment-2",
comments: [
{
id: "comment-2",
issueId: "issue-1",
body: "Second comment",
bodyTruncated: false,
createdAt: "2026-03-28T14:35:10.000Z",
author: { type: "user", id: "user-1" },
},
],
commentWindow: {
requestedCount: 1,
includedCount: 1,
missingCount: 0,
},
truncated: false,
fallbackFetchNeeded: false,
},
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
const promptFlagIndex = capture.argv.indexOf("--prompt");
const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : "";
expect(capture.argv).toContain("--resume");
expect(capture.argv).toContain("gemini-session-1");
expect(promptArg).toContain("## Paperclip Resume Delta");
expect(promptArg).toContain("Do not switch to another issue until you have handled this wake.");
expect(promptArg).toContain("Second comment");
expect(promptArg).not.toContain("Follow the paperclip heartbeat.");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});