openclaw/src/agents/cli-backends.test.ts

353 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
function createBackendEntry(params: {
pluginId: string;
id: string;
config: CliBackendConfig;
bundleMcp?: boolean;
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
}) {
return {
pluginId: params.pluginId,
source: "test",
backend: {
id: params.id,
config: params.config,
...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}),
...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}),
},
};
}
beforeEach(() => {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
createBackendEntry({
pluginId: "anthropic",
id: "claude-cli",
config: {
command: "claude",
args: ["stream-json", "--verbose", "--permission-mode", "bypassPermissions"],
resumeArgs: [
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "jsonl",
},
normalizeConfig: (config) => {
const normalizeArgs = (args: string[] | undefined) => {
if (!args) {
return args;
}
const next = args.filter((arg) => arg !== "--dangerously-skip-permissions");
const hasPermissionMode = next.some(
(arg, index) =>
arg === "--permission-mode" || next[index - 1]?.startsWith("--permission-mode="),
);
return hasPermissionMode ? next : [...next, "--permission-mode", "bypassPermissions"];
};
return {
...config,
args: normalizeArgs(config.args),
resumeArgs: normalizeArgs(config.resumeArgs),
};
},
}),
createBackendEntry({
pluginId: "openai",
id: "codex-cli",
config: {
command: "codex",
args: [
"exec",
"--json",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
],
resumeArgs: [
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
],
reliability: {
watchdog: {
fresh: {
noOutputTimeoutRatio: 0.8,
minMs: 60_000,
maxMs: 180_000,
},
resume: {
noOutputTimeoutRatio: 0.3,
minMs: 60_000,
maxMs: 180_000,
},
},
},
},
}),
createBackendEntry({
pluginId: "google",
id: "google-gemini-cli",
bundleMcp: false,
config: {
command: "gemini",
args: ["--prompt", "--output-format", "json"],
resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"],
modelArg: "--model",
sessionMode: "existing",
sessionIdFields: ["session_id", "sessionId"],
modelAliases: { pro: "gemini-3.1-pro-preview" },
},
}),
];
setActivePluginRegistry(registry);
});
describe("resolveCliBackendConfig reliability merge", () => {
it("defaults codex-cli to workspace-write for fresh and resume runs", () => {
const resolved = resolveCliBackendConfig("codex-cli");
expect(resolved).not.toBeNull();
expect(resolved?.config.args).toEqual([
"exec",
"--json",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
]);
expect(resolved?.config.resumeArgs).toEqual([
"exec",
"resume",
"{sessionId}",
"--color",
"never",
"--sandbox",
"workspace-write",
"--skip-git-repo-check",
]);
});
it("deep-merges reliability watchdog overrides for codex", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"codex-cli": {
command: "codex",
reliability: {
watchdog: {
resume: {
noOutputTimeoutMs: 42_000,
},
},
},
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("codex-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutMs).toBe(42_000);
// Ensure defaults are retained when only one field is overridden.
expect(resolved?.config.reliability?.watchdog?.resume?.noOutputTimeoutRatio).toBe(0.3);
expect(resolved?.config.reliability?.watchdog?.resume?.minMs).toBe(60_000);
expect(resolved?.config.reliability?.watchdog?.resume?.maxMs).toBe(180_000);
expect(resolved?.config.reliability?.watchdog?.fresh?.noOutputTimeoutRatio).toBe(0.8);
});
});
describe("resolveCliBackendConfig claude-cli defaults", () => {
it("uses non-interactive permission-mode defaults for fresh and resume args", () => {
const resolved = resolveCliBackendConfig("claude-cli");
expect(resolved).not.toBeNull();
expect(resolved?.config.output).toBe("jsonl");
expect(resolved?.config.args).toContain("stream-json");
expect(resolved?.config.args).toContain("--verbose");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.resumeArgs).toContain("stream-json");
expect(resolved?.config.resumeArgs).toContain("--verbose");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
});
it("retains default claude safety args when only command is overridden", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "/usr/local/bin/claude",
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.command).toBe("/usr/local/bin/claude");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
});
it("normalizes legacy skip-permissions overrides to permission-mode bypassPermissions", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--dangerously-skip-permissions", "--output-format", "json"],
resumeArgs: [
"-p",
"--dangerously-skip-permissions",
"--output-format",
"json",
"--resume",
"{sessionId}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.args).toContain("--permission-mode");
expect(resolved?.config.args).toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.resumeArgs).toContain("--permission-mode");
expect(resolved?.config.resumeArgs).toContain("bypassPermissions");
});
it("keeps explicit permission-mode overrides while removing legacy skip flag", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"claude-cli": {
command: "claude",
args: ["-p", "--dangerously-skip-permissions", "--permission-mode", "acceptEdits"],
resumeArgs: [
"-p",
"--dangerously-skip-permissions",
"--permission-mode=acceptEdits",
"--resume",
"{sessionId}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("claude-cli", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.args).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.args).toEqual(["-p", "--permission-mode", "acceptEdits"]);
expect(resolved?.config.resumeArgs).not.toContain("--dangerously-skip-permissions");
expect(resolved?.config.resumeArgs).toEqual([
"-p",
"--permission-mode=acceptEdits",
"--resume",
"{sessionId}",
]);
expect(resolved?.config.args).not.toContain("bypassPermissions");
expect(resolved?.config.resumeArgs).not.toContain("bypassPermissions");
});
});
describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
it("uses Gemini CLI json args and existing-session resume mode", () => {
const resolved = resolveCliBackendConfig("google-gemini-cli");
expect(resolved).not.toBeNull();
expect(resolved?.bundleMcp).toBe(false);
expect(resolved?.config.args).toEqual(["--prompt", "--output-format", "json"]);
expect(resolved?.config.resumeArgs).toEqual([
"--resume",
"{sessionId}",
"--prompt",
"--output-format",
"json",
]);
expect(resolved?.config.modelArg).toBe("--model");
expect(resolved?.config.sessionMode).toBe("existing");
expect(resolved?.config.sessionIdFields).toEqual(["session_id", "sessionId"]);
expect(resolved?.config.modelAliases?.pro).toBe("gemini-3.1-pro-preview");
});
});
describe("resolveCliBackendConfig alias precedence", () => {
it("prefers the canonical backend key over legacy aliases when both are configured", () => {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
createBackendEntry({
pluginId: "moonshot",
id: "kimi",
config: {
command: "kimi",
args: ["--default"],
},
}),
];
setActivePluginRegistry(registry);
const cfg = {
agents: {
defaults: {
cliBackends: {
"kimi-coding": {
command: "kimi-legacy",
args: ["--legacy"],
},
kimi: {
command: "kimi-canonical",
args: ["--canonical"],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = resolveCliBackendConfig("kimi", cfg);
expect(resolved).not.toBeNull();
expect(resolved?.config.command).toBe("kimi-canonical");
expect(resolved?.config.args).toEqual(["--canonical"]);
});
});