feat(claude-local): add Claude Fable 5.1 support (#12730)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Claude local adapter lets operators select a Claude model for an agent. > - Claude Fable 5.1 was absent from the adapter model lists. > - The adapter runtime also used a Claude Code build that rejected Fable 5.1. > - This pull request adds the direct Anthropic ID and the AWS Bedrock inference profile ID. > - It also updates the Claude ACP runtime and keeps the Paperclip usage and isolation patches. > - The benefit is that operators can select and run Claude Fable 5.1 through the Claude adapter. ## Linked Issues or Issue Description Refs #8810. That issue covers related model ID handling. This change does not change provider-prefixed model IDs. **Agent or provider** Claude Code through the built-in `claude_local` adapter. The requested model is Claude Fable 5.1. **Why this adapter is useful** Operators can use Fable 5.1 without entering an undocumented model ID. The configured model also reaches both supported Claude execution lanes. **How the agent is invoked** The CLI lane sends `--model claude-fable-5-1`. The ACP lane sends `ANTHROPIC_MODEL=claude-fable-5-1` to `@agentclientprotocol/claude-agent-acp`. **Are you willing to implement it?** Yes. This pull request includes the implementation and tests. **Additional context** Claude Code 2.1.232 rejected Fable 5.1 and required version 2.1.251 or newer. ACP package 0.73.0 includes Claude Code 2.1.257. The update keeps Paperclip's usage metadata and isolated-context behavior. ## What Changed - Added `claude-fable-5-1` to the direct Claude fallback list. - Added `us.anthropic.claude-fable-5-1` to the AWS Bedrock list. - Kept the existing default model at the first position in each list. - Updated the Claude ACP dependency from 0.70 to 0.73. - Carried the Paperclip usage and isolated-context changes into the 0.73 patch. - Added a Claude Code 2.1.251 minimum-version preflight for Fable 5.1 when using the standard `claude` executable, surfaced in both adapter Test and execution. Explicit custom wrappers retain their existing compatibility contract. - Kept local adapter Tests from executing caller-selected binaries: when runtime `PATH` selects a different Claude executable than the trusted probe, the Test warns and defers the authoritative version check to execution instead of approving or rejecting the alternate installation. - Added tests for model listing, discovery deduplication, Bedrock filtering, model pass-through in both execution lanes, old-CLI rejection before launch, custom-wrapper compatibility, and local runtime-PATH mismatch handling. ## Verification - `pnpm --filter @paperclipai/adapter-claude-local typecheck` - `pnpm exec vitest run packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/claude-local/src/server/test.remote.test.ts packages/adapters/claude-local/src/server/test.probe.test.ts packages/adapters/claude-local/src/server/acp.test.ts server/src/__tests__/adapter-models.test.ts` (72 tests passed) - `node --test scripts/acpx-patch-packaging.test.mjs` (13 tests passed) - `pnpm -r typecheck` - `pnpm build` - A local Paperclip agent run completed with `usageJson.model` set to `claude-fable-5-1` through ACP 0.73.0 and its bundled Claude Code 2.1.257. - `pnpm test:run` completed 5,638 passing tests and 24 skipped tests. It also found 24 failures in unrelated workspace-runtime, path-canonicalization, and runtime-exposure tests on macOS with Node 26. These failures do not touch this diff. Clean pull request CI is the final full-suite gate. ## Risks - The ACP dependency update can change Claude runtime behavior outside model selection. Focused ACP tests, the full typecheck, the production build, and a real local Fable run reduce this risk. - The 0.73 patch must stay aligned with the installed ACP version. Dependency-resolution CI verifies the manifest and patch pair. - Fable 5.1 adds a short `claude --version` preflight to standard CLI-lane Tests and runs. The result is intentionally not cached so an in-place Claude Code upgrade takes effect without restarting Paperclip. Explicit custom wrappers are not version-probed because their output and compatibility contract can differ from the standard executable. - Local Tests preserve the existing deny-by-default probe boundary and do not execute a binary selected by caller-controlled `PATH`. A mismatched runtime binary produces an explicit warning without blocking an otherwise valid setup; execution independently validates the actual runtime-selected CLI before launch. - The AWS Bedrock identifier differs from earlier IDs because Fable 5.1 has no `-v1` suffix. The model-list test locks this exact value. - There is no schema change or migration. > 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 Provider: OpenAI. Model: GPT-5 Codex. The host did not expose a more specific model ID or context-window size. Capabilities used: agentic reasoning, repository editing, shell execution, web research, and local runtime verification. ## 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
0f94521017
commit
dfdfc8664e
|
|
@ -102,6 +102,7 @@
|
|||
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
|
||||
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
|
||||
"@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
|
||||
"@agentclientprotocol/claude-agent-acp@0.73.0": "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
|
||||
"@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.70.0",
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.73.0",
|
||||
"@anthropic-ai/sdk": "0.121.0",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code
|
|||
export const models = [
|
||||
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
{ id: "claude-fable-5-1", label: "Claude Fable 5.1" },
|
||||
{ id: "claude-fable-5", label: "Claude Fable 5" },
|
||||
{ id: "claude-mythos-5", label: "Claude Mythos 5" },
|
||||
{ id: "claude-opus-5", label: "Claude Opus 5" },
|
||||
|
|
|
|||
|
|
@ -514,6 +514,30 @@ describe("claude_local ACP lane", () => {
|
|||
expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"]));
|
||||
});
|
||||
|
||||
it("passes the exact configured Fable 5.1 ID through ANTHROPIC_MODEL on the ACP lane", async () => {
|
||||
const root = await makeTempRoot("paperclip-claude-acp-fable51-");
|
||||
const meta: AdapterInvocationMeta[] = [];
|
||||
const execute = createClaudeAcpExecutor({
|
||||
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
|
||||
});
|
||||
|
||||
const result = await execute(buildContext(root, {
|
||||
config: {
|
||||
engine: "acp",
|
||||
cwd: root,
|
||||
stateDir: path.join(root, "state"),
|
||||
model: "claude-fable-5-1",
|
||||
promptTemplate: "Do the assigned work.",
|
||||
},
|
||||
onMeta: async (payload: AdapterInvocationMeta) => {
|
||||
meta.push(payload);
|
||||
},
|
||||
}));
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-fable-5-1");
|
||||
});
|
||||
|
||||
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
|
||||
const root = await makeTempRoot("paperclip-claude-acp-remote-cwd-");
|
||||
const localCwd = path.join(root, "worktree");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import path from "node:path";
|
|||
|
||||
const effortFlagSupportCache = new Map<string, Promise<boolean | null>>();
|
||||
|
||||
export const CLAUDE_FABLE_5_1_MIN_CLI_VERSION = "2.1.251";
|
||||
|
||||
const CLAUDE_FABLE_5_1_MODEL_IDS = new Set([
|
||||
"claude-fable-5-1",
|
||||
"us.anthropic.claude-fable-5-1",
|
||||
]);
|
||||
|
||||
export function claudeCommandLooksLike(command: string, expected = "claude"): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
|
|
@ -33,6 +40,78 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul
|
|||
].join(":");
|
||||
}
|
||||
|
||||
export function minimumClaudeCliVersionForModel(model: string): string | null {
|
||||
return CLAUDE_FABLE_5_1_MODEL_IDS.has(model.trim())
|
||||
? CLAUDE_FABLE_5_1_MIN_CLI_VERSION
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseClaudeCliVersion(output: string): string | null {
|
||||
return output.match(/\b(\d+)\.(\d+)\.(\d+)\b/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function claudeCliVersionAtLeast(version: string, minimum: string): boolean {
|
||||
const parsedVersion = version.split(".").map(Number);
|
||||
const parsedMinimum = minimum.split(".").map(Number);
|
||||
if (
|
||||
parsedVersion.length !== 3 ||
|
||||
parsedMinimum.length !== 3 ||
|
||||
parsedVersion.some((part) => !Number.isInteger(part) || part < 0) ||
|
||||
parsedMinimum.some((part) => !Number.isInteger(part) || part < 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < parsedMinimum.length; index += 1) {
|
||||
if (parsedVersion[index] !== parsedMinimum[index]) {
|
||||
return parsedVersion[index] > parsedMinimum[index];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function probeClaudeCommandVersion(input: {
|
||||
runId: string;
|
||||
command: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<string | null> {
|
||||
const version = await runAdapterExecutionTargetProcess(
|
||||
input.runId,
|
||||
input.target,
|
||||
input.command,
|
||||
["--version"],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: Math.max(1, Math.min(input.timeoutSec, 20)),
|
||||
graceSec: Math.max(1, Math.min(input.graceSec, 5)),
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (version.timedOut || version.exitCode !== 0) return null;
|
||||
return parseClaudeCliVersion(`${version.stdout}\n${version.stderr}`);
|
||||
}
|
||||
|
||||
export async function readClaudeCommandVersion(input: {
|
||||
runId: string;
|
||||
command: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
}): Promise<string | null> {
|
||||
// Do not cache this probe: an operator may upgrade Claude Code while the
|
||||
// Paperclip server is running, and the next Test/run should recover without
|
||||
// requiring a server restart.
|
||||
return probeClaudeCommandVersion(input).catch(() => null);
|
||||
}
|
||||
|
||||
async function probeClaudeCommandSupportsEffortFlag(input: {
|
||||
runId: string;
|
||||
command: string;
|
||||
|
|
|
|||
|
|
@ -13,15 +13,17 @@ const {
|
|||
syncDirectoryToSsh,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} = vi.hoisted(() => ({
|
||||
runChildProcess: vi.fn(async (): Promise<RunProcessResult> => ({
|
||||
runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]): Promise<RunProcessResult> => ({
|
||||
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"),
|
||||
stdout: args.includes("--version")
|
||||
? "2.1.251 (Claude Code)\n"
|
||||
: [
|
||||
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(),
|
||||
|
|
@ -76,12 +78,14 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
});
|
||||
|
||||
import { execute } from "./execute.js";
|
||||
import { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js";
|
||||
|
||||
describe("claude remote execution", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
resetClaudeCliCapabilitiesCacheForTests();
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (!dir) continue;
|
||||
|
|
@ -403,4 +407,126 @@ describe("claude remote execution", () => {
|
|||
expect(result.errorCode).toBe("duplex_channel_lost");
|
||||
});
|
||||
|
||||
describe("CLI-lane model pass-through", () => {
|
||||
async function executeWithModel(prefix: string, config: Record<string, unknown>) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const result = await execute({
|
||||
runId: "run-model-passthrough",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Claude Coder",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "claude",
|
||||
...config,
|
||||
},
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
},
|
||||
executionTransport: {
|
||||
remoteExecution: {
|
||||
host: "127.0.0.1",
|
||||
port: 2222,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/remote/workspace",
|
||||
remoteCwd: "/remote/workspace",
|
||||
privateKey: "PRIVATE KEY",
|
||||
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const call = runChildProcess.mock.calls.find((candidate) =>
|
||||
(candidate[2] as string[]).includes("--print"),
|
||||
) as unknown as [string, string, string[]] | undefined;
|
||||
return { args: call?.[2] ?? [], result };
|
||||
}
|
||||
|
||||
it("passes the exact configured Fable 5.1 ID as --model on the CLI lane", async () => {
|
||||
const { args } = await executeWithModel("paperclip-claude-model-direct-", {
|
||||
model: "claude-fable-5-1",
|
||||
});
|
||||
|
||||
const modelFlag = args.indexOf("--model");
|
||||
expect(modelFlag).toBeGreaterThanOrEqual(0);
|
||||
expect(args[modelFlag + 1]).toBe("claude-fable-5-1");
|
||||
});
|
||||
|
||||
it("passes the Bedrock-native Fable 5.1 ID as --model under Bedrock auth", async () => {
|
||||
const { args } = await executeWithModel("paperclip-claude-model-bedrock-", {
|
||||
model: "us.anthropic.claude-fable-5-1",
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
});
|
||||
|
||||
const modelFlag = args.indexOf("--model");
|
||||
expect(modelFlag).toBeGreaterThanOrEqual(0);
|
||||
expect(args[modelFlag + 1]).toBe("us.anthropic.claude-fable-5-1");
|
||||
});
|
||||
|
||||
it("skips --model for a direct Anthropic ID under Bedrock auth", async () => {
|
||||
const { args } = await executeWithModel("paperclip-claude-model-bedrock-skip-", {
|
||||
model: "claude-fable-5-1",
|
||||
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
});
|
||||
|
||||
expect(args).not.toContain("--model");
|
||||
});
|
||||
|
||||
it("rejects Fable 5.1 before launch when the CLI is older than 2.1.251", async () => {
|
||||
runChildProcess.mockResolvedValueOnce({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "2.1.247 (Claude Code)\n",
|
||||
stderr: "",
|
||||
pid: 123,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const { args, result } = await executeWithModel("paperclip-claude-model-old-cli-", {
|
||||
model: "claude-fable-5-1",
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(result.errorCode).toBe("claude_cli_version_incompatible");
|
||||
expect(result.errorMessage).toContain("requires Claude Code 2.1.251 or newer");
|
||||
expect(result.resultJson).toMatchObject({
|
||||
requiredClaudeCodeVersion: "2.1.251",
|
||||
detectedClaudeCodeVersion: "2.1.247",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves Fable compatibility to explicitly configured custom CLI wrappers", async () => {
|
||||
const { args, result } = await executeWithModel("paperclip-claude-model-wrapper-", {
|
||||
command: "/opt/paperclip/claude-wrapper",
|
||||
model: "claude-fable-5-1",
|
||||
});
|
||||
|
||||
expect(args).toContain("--model");
|
||||
expect(args).toContain("claude-fable-5-1");
|
||||
expect(result.errorCode).not.toBe("claude_cli_version_incompatible");
|
||||
expect(runChildProcess.mock.calls.some((call) =>
|
||||
(call[2] as string[]).includes("--version"),
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,7 +81,13 @@ import {
|
|||
resolveSharedClaudeConfigDir,
|
||||
writePaperclipClaudeMcpConfig,
|
||||
} from "./claude-config.js";
|
||||
import { claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
|
||||
import {
|
||||
claudeCliVersionAtLeast,
|
||||
claudeCommandLooksLike,
|
||||
claudeCommandSupportsEffortFlag,
|
||||
minimumClaudeCliVersionForModel,
|
||||
readClaudeCommandVersion,
|
||||
} from "./cli-capabilities.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { prepareClaudePromptBundle } from "./prompt-cache.js";
|
||||
|
|
@ -863,6 +869,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
const passesConfiguredModel = Boolean(
|
||||
model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model)),
|
||||
);
|
||||
|
||||
const buildClaudeArgs = (
|
||||
resumeSessionId: string | null,
|
||||
|
|
@ -879,7 +888,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier
|
||||
// (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid
|
||||
// on Bedrock, so skip them and let the CLI use its own configured model.
|
||||
if (model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model))) {
|
||||
if (passesConfiguredModel) {
|
||||
args.push("--model", model);
|
||||
}
|
||||
if (effectiveEffort) args.push("--effort", effectiveEffort);
|
||||
|
|
@ -1250,6 +1259,49 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
};
|
||||
|
||||
try {
|
||||
const minimumCliVersion = passesConfiguredModel && claudeCommandLooksLike(command, "claude")
|
||||
? minimumClaudeCliVersionForModel(model)
|
||||
: null;
|
||||
if (minimumCliVersion) {
|
||||
const detectedCliVersion = await readClaudeCommandVersion({
|
||||
runId,
|
||||
command,
|
||||
target: runtimeExecutionTarget,
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
});
|
||||
if (
|
||||
!detectedCliVersion ||
|
||||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
|
||||
) {
|
||||
const detected = detectedCliVersion
|
||||
? `detected ${detectedCliVersion}`
|
||||
: "could not determine the installed version";
|
||||
const errorMessage =
|
||||
`Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane; ${detected}. ` +
|
||||
"Upgrade Claude Code or restore the default ACP lane before retrying.";
|
||||
await onLog("stderr", `[paperclip] ${errorMessage}\n`);
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage,
|
||||
errorCode: "claude_cli_version_incompatible",
|
||||
provider: "anthropic",
|
||||
biller: isBedrockAuth(effectiveEnv) ? "aws_bedrock" : "anthropic",
|
||||
model,
|
||||
billingType,
|
||||
resultJson: {
|
||||
stopReason: "claude_cli_version_incompatible",
|
||||
requiredClaudeCodeVersion: minimumCliVersion,
|
||||
detectedClaudeCodeVersion: detectedCliVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
const sessionErrorKind =
|
||||
sessionId &&
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const ANTHROPIC_API_VERSION = "2023-06-01";
|
|||
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
|
||||
const BEDROCK_MODELS: AdapterModel[] = [
|
||||
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
|
||||
// Fable 5.1's documented geo inference ID carries no -v1 suffix, unlike earlier entries.
|
||||
{ id: "us.anthropic.claude-fable-5-1", label: "Bedrock Fable 5.1" },
|
||||
{ id: "us.anthropic.claude-fable-5-v1", label: "Bedrock Fable 5" },
|
||||
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
|
||||
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },
|
||||
|
|
|
|||
|
|
@ -629,6 +629,39 @@ describe("claude CLI local hello probe hardening", () => {
|
|||
expect(JSON.stringify(spawnedEnv)).not.toContain("caller-proxy");
|
||||
});
|
||||
|
||||
it("warns without executing when runtime PATH selects a different local Claude executable", async () => {
|
||||
const runtimeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cli-runtime-path-"));
|
||||
const runtimeClaudePath = path.join(runtimeDir, "claude");
|
||||
await writeFile(runtimeClaudePath, "#!/bin/sh\nexit 0\n");
|
||||
await chmod(runtimeClaudePath, 0o755);
|
||||
|
||||
try {
|
||||
probeResult.value = { exitCode: 0, stdout: "2.1.251 (Claude Code)\n", stderr: "" };
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: "claude",
|
||||
model: "claude-fable-5-1",
|
||||
env: { PATH: runtimeDir },
|
||||
},
|
||||
executionTarget: null,
|
||||
environmentName: null,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks).toContainEqual(expect.objectContaining({
|
||||
code: "claude_cli_version_probe_mismatch",
|
||||
level: "warn",
|
||||
}));
|
||||
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("names the local host target on every result", async () => {
|
||||
probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" };
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
|
||||
import { testEnvironment } from "./test.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
import { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js";
|
||||
|
||||
const sandboxTarget: AdapterExecutionTarget = {
|
||||
kind: "remote",
|
||||
|
|
@ -94,6 +95,7 @@ const loginRequiredStdout = [
|
|||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetClaudeCliCapabilitiesCacheForTests();
|
||||
});
|
||||
|
||||
describe("claude sandbox auth-missing check", () => {
|
||||
|
|
@ -137,3 +139,40 @@ describe("claude sandbox auth-missing check", () => {
|
|||
expect(result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claude CLI model compatibility check", () => {
|
||||
it("fails before the hello probe when Fable 5.1 is configured with an older CLI", async () => {
|
||||
probeResult.value = {
|
||||
exitCode: 0,
|
||||
stdout: "2.1.247 (Claude Code)\n",
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
command: "claude",
|
||||
model: "claude-fable-5-1",
|
||||
},
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks).toContainEqual(expect.objectContaining({
|
||||
code: "claude_cli_version_incompatible",
|
||||
level: "error",
|
||||
detail: "Detected Claude Code 2.1.247.",
|
||||
}));
|
||||
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
|
||||
const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
|
||||
string,
|
||||
AdapterExecutionTarget,
|
||||
string,
|
||||
string[],
|
||||
];
|
||||
expect(versionCall[3]).toEqual(["--version"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
ensureAdapterExecutionTargetDirectory,
|
||||
runAdapterExecutionTargetProcess,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
detectClaudeLoginRequired,
|
||||
|
|
@ -23,7 +24,13 @@ import {
|
|||
isClaudeTransientUpstreamError,
|
||||
parseClaudeStreamJson,
|
||||
} from "./parse.js";
|
||||
import { claudeCommandLooksLike, claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
|
||||
import {
|
||||
claudeCliVersionAtLeast,
|
||||
claudeCommandLooksLike,
|
||||
claudeCommandSupportsEffortFlag,
|
||||
minimumClaudeCliVersionForModel,
|
||||
readClaudeCommandVersion,
|
||||
} from "./cli-capabilities.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { buildClaudeProbePermissionArgs } from "./permissions.js";
|
||||
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
|
||||
|
|
@ -47,6 +54,14 @@ function isNonEmpty(value: unknown): value is string {
|
|||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function localExecutablesMatch(
|
||||
trustedCommand: string | null,
|
||||
runtimeCommand: string | null,
|
||||
): boolean {
|
||||
if (!trustedCommand || !runtimeCommand) return false;
|
||||
return trustedCommand === runtimeCommand;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
|
|
@ -130,8 +145,17 @@ export async function testEnvironment(
|
|||
})),
|
||||
);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
let localRuntimeCommand: string | null = null;
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
if (!targetIsRemote) {
|
||||
localRuntimeCommand = await resolveAdapterExecutionTargetCommandForLogs(
|
||||
command,
|
||||
target,
|
||||
cwd,
|
||||
runtimeEnv,
|
||||
);
|
||||
}
|
||||
checks.push({
|
||||
code: "claude_command_resolvable",
|
||||
level: "info",
|
||||
|
|
@ -214,7 +238,62 @@ export async function testEnvironment(
|
|||
check.code !== "claude_command_unresolvable" &&
|
||||
check.code !== "claude_managed_config_dir_failed",
|
||||
);
|
||||
if (canRunProbe) {
|
||||
let configuredModelIsCompatible = true;
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
const minimumCliVersion =
|
||||
claudeCommandLooksLike(command, "claude") &&
|
||||
(!hasBedrock || isBedrockModelId(configuredModel))
|
||||
? minimumClaudeCliVersionForModel(configuredModel)
|
||||
: null;
|
||||
const versionProbeCommand = localProbe?.command ?? (targetIsRemote ? command : null);
|
||||
const versionProbeMatchesRuntime = targetIsRemote || localExecutablesMatch(
|
||||
localProbe?.command ?? null,
|
||||
localRuntimeCommand,
|
||||
);
|
||||
if (
|
||||
canRunProbe &&
|
||||
minimumCliVersion &&
|
||||
versionProbeCommand &&
|
||||
!versionProbeMatchesRuntime
|
||||
) {
|
||||
configuredModelIsCompatible = false;
|
||||
checks.push({
|
||||
code: "claude_cli_version_probe_mismatch",
|
||||
level: "warn",
|
||||
message:
|
||||
"Skipped Fable 5.1 readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.",
|
||||
hint:
|
||||
"Ensure the runtime-selected Claude Code is 2.1.251 or newer. Execution will verify that exact executable before launch.",
|
||||
});
|
||||
} else if (canRunProbe && minimumCliVersion && versionProbeCommand) {
|
||||
const versionProbeEnv = localProbe?.env ?? env;
|
||||
const detectedCliVersion = await readClaudeCommandVersion({
|
||||
runId,
|
||||
command: versionProbeCommand,
|
||||
target,
|
||||
cwd,
|
||||
env: versionProbeEnv,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
});
|
||||
if (
|
||||
!detectedCliVersion ||
|
||||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
|
||||
) {
|
||||
configuredModelIsCompatible = false;
|
||||
checks.push({
|
||||
code: "claude_cli_version_incompatible",
|
||||
level: "error",
|
||||
message: `Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`,
|
||||
detail: detectedCliVersion
|
||||
? `Detected Claude Code ${detectedCliVersion}.`
|
||||
: "Could not determine the installed Claude Code version.",
|
||||
hint: "Upgrade Claude Code or restore the default ACP lane, then retry the Test.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe && configuredModelIsCompatible) {
|
||||
if (!claudeCommandLooksLike(command, "claude")) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_skipped_custom_command",
|
||||
|
|
@ -233,7 +312,7 @@ export async function testEnvironment(
|
|||
hint: "Install the `claude` CLI on the Paperclip host, then retry the Test.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, "").trim();
|
||||
const model = configuredModel;
|
||||
const effort = asString(config.effort, "").trim();
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
--- a/dist/acp-agent.js
|
||||
+++ b/dist/acp-agent.js
|
||||
@@ -2917,10 +2917,20 @@
|
||||
cost: {
|
||||
amount: message.total_cost_usd,
|
||||
currency: "USD",
|
||||
},
|
||||
- ...(message.origin && {
|
||||
- _meta: { "_claude/origin": message.origin },
|
||||
- }),
|
||||
+ _meta: {
|
||||
+ ...(message.origin && { "_claude/origin": message.origin }),
|
||||
+ // ACP's core Usage shape exposes context occupancy and
|
||||
+ // cost, but not the billable input/output split. Keep the
|
||||
+ // SDK's bounded aggregate in extension metadata so ACPX can
|
||||
+ // normalize it without persisting a raw provider message.
|
||||
+ usage: {
|
||||
+ input_tokens: message.usage.input_tokens,
|
||||
+ output_tokens: message.usage.output_tokens,
|
||||
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
|
||||
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
|
||||
+ },
|
||||
+ },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5307,8 +5317,12 @@
|
||||
const options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(thinking !== undefined && { thinking }),
|
||||
...userProvidedOptions,
|
||||
+ // Paperclip Runner owns the complete session context. Its isolated
|
||||
+ // user root contains only assigned skills; project/local settings
|
||||
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
|
||||
...(settings && { settings }),
|
||||
env,
|
||||
// Override certain fields that must be controlled by ACP
|
||||
@@ -5317,7 +5331,9 @@
|
||||
includePartialMessages: true,
|
||||
forwardSubagentText,
|
||||
mcpServers: {
|
||||
- ...(userProvidedOptions?.mcpServers || {}),
|
||||
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
|
||||
+ ? {}
|
||||
+ : (userProvidedOptions?.mcpServers || {})),
|
||||
...mcpServers,
|
||||
...(fileChangeAuditSupport
|
||||
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }
|
||||
|
|
@ -20,4 +20,5 @@ patchedDependencies:
|
|||
acpx@0.12.0: patches/acpx@0.12.0.patch
|
||||
acpx@0.13.1: patches/acpx@0.13.1.patch
|
||||
'@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
|
||||
'@agentclientprotocol/claude-agent-acp@0.73.0': patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
|
||||
'@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ describe("adapter model listing", () => {
|
|||
// Newer flagship models are offered, but Opus 4.8 stays the default (first) option.
|
||||
expect(models[0]?.id).toBe("claude-opus-4-8");
|
||||
expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true);
|
||||
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
|
||||
|
|
@ -130,6 +131,36 @@ describe("adapter model listing", () => {
|
|||
expect(models).toEqual(claudeFallbackModels);
|
||||
});
|
||||
|
||||
it("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }],
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const models = await listAdapterModels("claude_local");
|
||||
|
||||
expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1);
|
||||
// Curated fallbacks discovery did not return are still merged in.
|
||||
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the Bedrock-native Fable 5.1 ID (never the direct ID) in Bedrock mode", async () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const models = await listAdapterModels("claude_local");
|
||||
|
||||
// The Bedrock default (first entry) is unchanged.
|
||||
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1");
|
||||
expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true);
|
||||
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads codex models dynamically and merges fallback options", async () => {
|
||||
process.env.OPENAI_API_KEY = "sk-test";
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
|
|
|
|||
Loading…
Reference in New Issue