diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index a57e08f7771..dfb03b333f0 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -393,8 +393,8 @@ describe("registerPreActionHooks", () => { it("routes logs to stderr in --json mode so stdout stays clean", async () => { await runPreAction({ - parseArgv: ["agents", "list"], - processArgv: ["node", "openclaw", "agents", "list", "--json"], + parseArgv: ["message", "send"], + processArgv: ["node", "openclaw", "message", "send", "--json"], }); expect(routeLogsToStderrMock).toHaveBeenCalledOnce(); @@ -420,6 +420,16 @@ describe("registerPreActionHooks", () => { expect(routeLogsToStderrMock).not.toHaveBeenCalled(); }); + it("does not preload plugins for agents list JSON output", async () => { + await runPreAction({ + parseArgv: ["agents", "list"], + processArgv: ["node", "openclaw", "agents", "list", "--json"], + }); + + expect(routeLogsToStderrMock).toHaveBeenCalledOnce(); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("bypasses config guard for config validate", async () => { await runPreAction({ parseArgv: ["config", "validate"], diff --git a/src/commands/agents.commands.list.test.ts b/src/commands/agents.commands.list.test.ts new file mode 100644 index 00000000000..5ca1b330c06 --- /dev/null +++ b/src/commands/agents.commands.list.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { OutputRuntimeEnv } from "../runtime.js"; + +const { buildProviderStatusIndexMock, requireValidConfigMock } = vi.hoisted(() => ({ + buildProviderStatusIndexMock: vi.fn(), + requireValidConfigMock: vi.fn(), +})); + +vi.mock("./agents.command-shared.js", () => ({ + requireValidConfig: requireValidConfigMock, +})); + +vi.mock("./agents.providers.js", () => ({ + buildProviderStatusIndex: buildProviderStatusIndexMock, + listProvidersForAgent: () => ["Telegram default: configured"], + summarizeBindings: () => ["Telegram default"], +})); + +const { agentsListCommand } = await import("./agents.commands.list.js"); + +function createRuntime(): OutputRuntimeEnv & { json: unknown[] } { + const json: unknown[] = []; + return { + json, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + writeStdout: vi.fn(), + writeJson: vi.fn((value: unknown) => { + json.push(value); + }), + }; +} + +function createConfig(): OpenClawConfig { + return { + agents: { + list: [{ id: "main", default: true }], + }, + bindings: [{ agentId: "main", match: { channel: "telegram" } }], + }; +} + +describe("agentsListCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + requireValidConfigMock.mockResolvedValue(createConfig()); + buildProviderStatusIndexMock.mockResolvedValue(new Map()); + }); + + it("keeps plain JSON output on the config-only path", async () => { + const runtime = createRuntime(); + + await agentsListCommand({ json: true }, runtime); + + expect(buildProviderStatusIndexMock).not.toHaveBeenCalled(); + const summary = (runtime.json[0] as Array>)[0]; + expect(summary).toMatchObject({ id: "main" }); + expect(summary).not.toHaveProperty("routes"); + expect(summary).not.toHaveProperty("providers"); + }); + + it("keeps provider details available for JSON callers that request bindings", async () => { + const runtime = createRuntime(); + + await agentsListCommand({ json: true, bindings: true }, runtime); + + expect(buildProviderStatusIndexMock).toHaveBeenCalledOnce(); + expect(runtime.json[0]).toEqual([ + expect.objectContaining({ + id: "main", + routes: ["Telegram default"], + providers: ["Telegram default: configured"], + }), + ]); + }); +}); diff --git a/src/commands/agents.commands.list.ts b/src/commands/agents.commands.list.ts index 720def19074..41b8de474cb 100644 --- a/src/commands/agents.commands.list.ts +++ b/src/commands/agents.commands.list.ts @@ -101,23 +101,24 @@ export async function agentsListCommand( // `buildProviderStatusIndex` triggers on-demand plugin loads and is only // used for human text output (`summary.providers` is rendered in the text - // formatter). JSON callers (dashboards, monitors, IDE plugins) poll this - // command and don't need provider enrichment, so skip the plugin load when - // emitting JSON — combined with `loadPlugins: "text-only"` in the catalog - // entry, this drops `agents list --json` cold time from ~9s to sub-second. - // (#71739) - const providerStatus = opts.json ? null : await buildProviderStatusIndex(cfg); + // formatter). JSON callers (dashboards, monitors, IDE plugins) poll the + // config-derived fields, so skip the plugin load unless they explicitly ask + // for binding/provider enrichment with --bindings. Combined with + // `loadPlugins: "text-only"` in the catalog entry, this keeps + // `agents list --json` on the config-only path. (#71739) + const includeProviderDetails = !opts.json || opts.bindings === true; + const providerStatus = includeProviderDetails ? await buildProviderStatusIndex(cfg) : null; for (const summary of summaries) { const bindings = bindingMap.get(summary.id) ?? []; - const routes = summarizeBindings(cfg, bindings); - if (routes.length > 0) { - summary.routes = routes; - } else if (summary.isDefault) { - summary.routes = ["default (no explicit rules)"]; - } + if (includeProviderDetails && providerStatus) { + const routes = summarizeBindings(cfg, bindings); + if (routes.length > 0) { + summary.routes = routes; + } else if (summary.isDefault) { + summary.routes = ["default (no explicit rules)"]; + } - if (providerStatus) { const providerLines = listProvidersForAgent({ summaryIsDefault: summary.isDefault, cfg,