Fix CLI text command hangs (#74220)

* fix(cli): keep agents list off plugin preload

* docs(changelog): note cli text hang fix

* test(cli): update preaction agents list expectations
This commit is contained in:
NianJiu
2026-04-30 14:36:24 +08:00
committed by GitHub
parent c4a4c189f1
commit 43ca7399e5
8 changed files with 73 additions and 20 deletions

View File

@@ -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.

View File

@@ -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" },
},
{

View File

@@ -77,6 +77,8 @@ describe("command-path-policy", () => {
});
for (const commandPath of [
["agents"],
["agents", "list"],
["agents", "bind"],
["agents", "bindings"],
["agents", "unbind"],

View File

@@ -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"],

View File

@@ -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);
});

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -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"),
);
});
});