diff --git a/CHANGELOG.md b/CHANGELOG.md index d4beb5915c5..ce9d32ab809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/agents/status: keep `openclaw agents`, text `agents list`, and plain text `status` on read-only metadata paths so human output no longer preloads plugin runtimes or live channel scans before printing. Fixes #74195. Thanks @NianJiuZst. - Media: treat legacy Word/OLE attachments with `application/msword` or `application/x-cfb` MIME as binary so printable-looking `.doc` files are not embedded into prompts as text. Fixes #54176; carries forward #54380. Thanks @andyliu. - Config: accept documented `browser.tabCleanup` keys in strict root config validation, so configured tab cleanup no longer fails before runtime reads it. Fixes #74577. Thanks @lonexreb and @ezdlp. - Cron: validate disabled job schedule edits before persisting updates, so invalid cron changes no longer partially mutate stored jobs. Fixes #74459. Thanks @yfge. diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index a591478a7ed..793c8535dd3 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -60,6 +60,12 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ { commandPath: ["channels"], policy: { loadPlugins: "always" } }, { commandPath: ["directory"], policy: { loadPlugins: "always" } }, { commandPath: ["agents"], policy: { loadPlugins: "always", networkProxy: "bypass" } }, + { + commandPath: ["agents"], + exact: true, + policy: { loadPlugins: "never", networkProxy: "bypass" }, + route: { id: "agents-list" }, + }, { commandPath: ["agents", "bind"], exact: true, @@ -143,12 +149,9 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ }, { commandPath: ["agents", "list"], - // JSON callers (dashboards, monitoring scripts, IDE plugins) poll this - // command and don't need the plugin-derived `providers` enrichment that - // is only used in human text output. text-only skips the bundled-plugin - // import waterfall in `--json` mode, mirroring what `channels list` - // already does. Human (non-JSON) invocations still load plugins. (#71739) - policy: { loadPlugins: "text-only", networkProxy: "bypass" }, + // Text and JSON output are derived from config plus read-only channel + // metadata, so the route should not preload bundled plugin runtimes. + policy: { loadPlugins: "never", networkProxy: "bypass" }, route: { id: "agents-list" }, }, { diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 1d682b59d45..ae9b08dbb1d 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -77,6 +77,8 @@ describe("command-path-policy", () => { }); for (const commandPath of [ + ["agents"], + ["agents", "list"], ["agents", "bind"], ["agents", "bindings"], ["agents", "unbind"], diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 4479cfced43..4cc99384f78 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -113,15 +113,18 @@ describe("command-startup-policy", () => { jsonOutputMode: false, }), ).toBe(true); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["agents"], + jsonOutputMode: false, + }), + ).toBe(false); expect( shouldLoadPluginsForCommandPath({ commandPath: ["agents", "list"], jsonOutputMode: false, }), - ).toBe(true); - // text-only opts agents list out of plugin preload in --json mode so - // dashboards/scripts that poll this command don't pay the bundled-plugin - // import waterfall when they only consume config-derived fields. (#71739) + ).toBe(false); expect( shouldLoadPluginsForCommandPath({ commandPath: ["agents", "list"], diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 741309132fb..efb42b5e905 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -201,7 +201,7 @@ describe("registerPreActionHooks", () => { await preActionHook(program, actionCommand); } - it("handles debug mode and plugin-required command preaction", async () => { + it("handles debug mode and config-only command preaction", async () => { const processTitleSetSpy = vi.spyOn(process, "title", "set"); await runPreAction({ parseArgv: ["status"], @@ -229,7 +229,7 @@ describe("registerPreActionHooks", () => { runtime: runtimeMock, commandPath: ["agents", "list"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); processTitleSetSpy.mockRestore(); }); @@ -512,19 +512,13 @@ describe("registerPreActionHooks", () => { expect(loggingState.forceConsoleToStderr).toBe(false); }); - it("does not route logs to stderr during plugin loading without --json", async () => { - let stderrDuringPluginLoad = false; - ensurePluginRegistryLoadedMock.mockImplementation(() => { - stderrDuringPluginLoad = loggingState.forceConsoleToStderr; - }); - + it("does not preload plugins or route logs to stderr for agents list without --json", async () => { await runPreAction({ parseArgv: ["agents", "list"], processArgv: ["node", "openclaw", "agents", "list"], }); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); - expect(stderrDuringPluginLoad).toBe(false); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); expect(loggingState.forceConsoleToStderr).toBe(false); }); diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts index 544c2ab0d3f..e595d8f0001 100644 --- a/src/cli/program/route-args.test.ts +++ b/src/cli/program/route-args.test.ts @@ -111,6 +111,10 @@ describe("route-args", () => { json: true, bindings: true, }); + expect(parseAgentsListRouteArgs(["node", "openclaw", "agents"])).toEqual({ + json: false, + bindings: false, + }); }); it("parses config routes", () => { diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 51295dcf247..cd8f8acdf9b 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -11,6 +11,7 @@ const tasksListJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); const tasksAuditJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); const channelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const channelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const agentsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -49,6 +50,10 @@ vi.mock("../../commands/channels/status.js", () => ({ channelsStatusCommand: channelsStatusCommandMock, })); +vi.mock("../../commands/agents.js", () => ({ + agentsListCommand: agentsListCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -80,6 +85,34 @@ describe("program routes", () => { expect(expectRoute(["channels", "status"])?.loadPlugins).toBeUndefined(); }); + it("matches agents read-only routes without plugin preload", () => { + expect(expectRoute(["agents"])?.loadPlugins).toBeUndefined(); + expect(expectRoute(["agents", "list"])?.loadPlugins).toBeUndefined(); + }); + + it("passes parsed agents list flags through", async () => { + await expect(expectRoute(["agents"])?.run(["node", "openclaw", "agents"])).resolves.toBe(true); + expect(agentsListCommandMock).toHaveBeenCalledWith( + { json: false, bindings: false }, + expect.any(Object), + ); + + await expect( + expectRoute(["agents", "list"])?.run([ + "node", + "openclaw", + "agents", + "list", + "--json", + "--bindings", + ]), + ).resolves.toBe(true); + expect(agentsListCommandMock).toHaveBeenLastCalledWith( + { json: true, bindings: true }, + expect.any(Object), + ); + }); + it("passes parsed channel read-only route flags through", async () => { const listRoute = expectRoute(["channels", "list"]); await expect( diff --git a/src/commands/agents.commands.list.test.ts b/src/commands/agents.commands.list.test.ts index 49e82047432..3e7ae7e4243 100644 --- a/src/commands/agents.commands.list.test.ts +++ b/src/commands/agents.commands.list.test.ts @@ -112,4 +112,17 @@ describe("agentsListCommand", () => { }), ]); }); + + it("keeps human output enriched from read-only provider metadata", async () => { + const runtime = createRuntime(); + + await agentsListCommand({}, runtime); + + expect(buildProviderStatusIndexMock).toHaveBeenCalledOnce(); + expect(buildProviderSummaryMetadataIndexMock).toHaveBeenCalledOnce(); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Providers:")); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Telegram default: configured"), + ); + }); });