diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index b2744b6e7cc..d16bfc3868b 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -331,7 +331,7 @@ Then verify backend health: ### acpx command and version configuration -By default, `@openclaw/acpx` uses the plugin-local pinned binary: +By default, the acpx plugin (published as `@openclaw/acpx`) uses the plugin-local pinned binary: 1. Command defaults to `extensions/acpx/node_modules/.bin/acpx`. 2. Expected version defaults to the extension pin. diff --git a/src/auto-reply/reply/commands-acp/install-hints.test.ts b/src/auto-reply/reply/commands-acp/install-hints.test.ts new file mode 100644 index 00000000000..bc06c88ba25 --- /dev/null +++ b/src/auto-reply/reply/commands-acp/install-hints.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../config/config.js"; +import { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./install-hints.js"; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +function withAcpConfig(acp: OpenClawConfig["acp"]): OpenClawConfig { + return { acp } as OpenClawConfig; +} + +afterEach(() => { + process.chdir(originalCwd); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ACP install hints", () => { + it("prefers explicit runtime install command", () => { + const cfg = withAcpConfig({ + runtime: { installCommand: "pnpm openclaw plugins install acpx" }, + }); + expect(resolveAcpInstallCommandHint(cfg)).toBe("pnpm openclaw plugins install acpx"); + }); + + it("uses local acpx extension path when present", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acp-install-hint-")); + tempDirs.push(tempRoot); + fs.mkdirSync(path.join(tempRoot, "extensions", "acpx"), { recursive: true }); + process.chdir(tempRoot); + + const cfg = withAcpConfig({ backend: "acpx" }); + const hint = resolveAcpInstallCommandHint(cfg); + expect(hint).toContain("openclaw plugins install "); + expect(hint).toContain(path.join("extensions", "acpx")); + }); + + it("falls back to npm install hint for acpx when local extension is absent", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acp-install-hint-")); + tempDirs.push(tempRoot); + process.chdir(tempRoot); + + const cfg = withAcpConfig({ backend: "acpx" }); + expect(resolveAcpInstallCommandHint(cfg)).toBe("openclaw plugins install acpx"); + }); + + it("returns generic plugin hint for non-acpx backend", () => { + const cfg = withAcpConfig({ backend: "custom-backend" }); + expect(resolveConfiguredAcpBackendId(cfg)).toBe("custom-backend"); + expect(resolveAcpInstallCommandHint(cfg)).toContain('ACP backend "custom-backend"'); + }); +}); diff --git a/src/auto-reply/reply/commands-acp/install-hints.ts b/src/auto-reply/reply/commands-acp/install-hints.ts new file mode 100644 index 00000000000..58b4b387c74 --- /dev/null +++ b/src/auto-reply/reply/commands-acp/install-hints.ts @@ -0,0 +1,23 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../../../config/config.js"; + +export function resolveConfiguredAcpBackendId(cfg: OpenClawConfig): string { + return cfg.acp?.backend?.trim() || "acpx"; +} + +export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string { + const configured = cfg.acp?.runtime?.installCommand?.trim(); + if (configured) { + return configured; + } + const backendId = resolveConfiguredAcpBackendId(cfg).toLowerCase(); + if (backendId === "acpx") { + const localPath = path.resolve(process.cwd(), "extensions/acpx"); + if (existsSync(localPath)) { + return `openclaw plugins install ${localPath}`; + } + return "openclaw plugins install acpx"; + } + return `Install and enable the plugin that provides ACP backend "${backendId}".`; +} diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index 1a084382330..dfc88c4b9ec 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -1,15 +1,13 @@ import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import path from "node:path"; import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js"; import type { AcpRuntimeError } from "../../../acp/runtime/errors.js"; import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js"; import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; -import type { OpenClawConfig } from "../../../config/config.js"; import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; import { resolveAcpCommandChannel, resolveAcpCommandThreadId } from "./context.js"; +export { resolveAcpInstallCommandHint, resolveConfiguredAcpBackendId } from "./install-hints.js"; export const COMMAND = "/acp"; export const ACP_SPAWN_USAGE = @@ -404,26 +402,6 @@ export function resolveAcpHelpText(): string { ].join("\n"); } -export function resolveConfiguredAcpBackendId(cfg: OpenClawConfig): string { - return cfg.acp?.backend?.trim() || "acpx"; -} - -export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string { - const configured = cfg.acp?.runtime?.installCommand?.trim(); - if (configured) { - return configured; - } - const backendId = resolveConfiguredAcpBackendId(cfg).toLowerCase(); - if (backendId === "acpx") { - const localPath = path.resolve(process.cwd(), "extensions/acpx"); - if (existsSync(localPath)) { - return `openclaw plugins install ${localPath}`; - } - return "openclaw plugins install acpx"; - } - return `Install and enable the plugin that provides ACP backend "${backendId}".`; -} - export function formatRuntimeOptionsText(options: AcpSessionRuntimeOptions): string { const extras = options.backendExtras ? Object.entries(options.backendExtras)