diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 4021a5e260d..9dde0408b32 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { loggingState } from "../../logging/state.js"; import { setCommandJsonMode } from "./json-mode.js"; const setVerboseMock = vi.fn(); @@ -58,6 +59,7 @@ let originalProcessArgv: string[]; let originalProcessTitle: string; let originalNodeNoWarnings: string | undefined; let originalHideBanner: string | undefined; +let originalForceStderr: boolean; beforeAll(async () => { ({ registerPreActionHooks } = await import("./preaction.js")); @@ -76,6 +78,8 @@ beforeEach(() => { originalProcessTitle = process.title; originalNodeNoWarnings = process.env.NODE_NO_WARNINGS; originalHideBanner = process.env.OPENCLAW_HIDE_BANNER; + originalForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = false; delete process.env.NODE_NO_WARNINGS; delete process.env.OPENCLAW_HIDE_BANNER; }); @@ -83,6 +87,7 @@ beforeEach(() => { afterEach(() => { process.argv = originalProcessArgv; process.title = originalProcessTitle; + loggingState.forceConsoleToStderr = originalForceStderr; if (originalNodeNoWarnings === undefined) { delete process.env.NODE_NO_WARNINGS; } else { @@ -340,6 +345,39 @@ describe("registerPreActionHooks", () => { expect(ensureConfigReadyMock).not.toHaveBeenCalled(); }); + it("routes logs to stderr during plugin loading in --json mode and restores after", async () => { + let stderrDuringPluginLoad = false; + ensurePluginRegistryLoadedMock.mockImplementation(() => { + stderrDuringPluginLoad = loggingState.forceConsoleToStderr; + }); + + await runPreAction({ + parseArgv: ["agents"], + processArgv: ["node", "openclaw", "agents", "--json"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); + expect(stderrDuringPluginLoad).toBe(true); + // Flag must be restored after plugin loading completes + 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; + }); + + await runPreAction({ + parseArgv: ["agents"], + processArgv: ["node", "openclaw", "agents"], + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); + expect(stderrDuringPluginLoad).toBe(false); + expect(loggingState.forceConsoleToStderr).toBe(false); + }); + beforeAll(() => { program = buildProgram(); const hooks = ( diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index b37e211be89..9d5e4fa910a 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -3,6 +3,7 @@ import { setVerbose } from "../../globals.js"; import { isTruthyEnvValue } from "../../infra/env.js"; import { routeLogsToStderr } from "../../logging/console.js"; import type { LogLevel } from "../../logging/levels.js"; +import { loggingState } from "../../logging/state.js"; import { defaultRuntime } from "../../runtime.js"; import { getCommandPathWithRootOptions, getVerboseFlag, hasHelpOrVersion } from "../argv.js"; import { emitCliBanner } from "../banner.js"; @@ -138,10 +139,21 @@ export function registerPreActionHooks(program: Command, programVersion: string) commandPath, ...(jsonOutputMode ? { suppressDoctorStdout: true } : {}), }); - // Load plugins for commands that need channel access +<<<<<<< HEAD + // Load plugins for commands that need channel access. + // When --json output is active, temporarily route logs to stderr so plugin + // registration messages don't corrupt the JSON payload on stdout. if (shouldLoadPluginsForCommand(commandPath, jsonOutputMode)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); + const prev = loggingState.forceConsoleToStderr; + if (jsonOutputMode) { + loggingState.forceConsoleToStderr = true; + } + try { + ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); + } finally { + loggingState.forceConsoleToStderr = prev; + } } }); } diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index 4a65bcfc24f..c68cb7cd93b 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -34,7 +34,11 @@ vi.mock("../runtime.js", () => ({ describe("tryRouteCli", () => { let tryRouteCli: typeof import("./route.js").tryRouteCli; + // After vi.resetModules(), reimported modules get fresh loggingState. + // Capture the same reference that route.js uses. + let loggingState: typeof import("../logging/state.js").loggingState; let originalDisableRouteFirst: string | undefined; + let originalForceStderr: boolean; beforeEach(async () => { vi.clearAllMocks(); @@ -42,6 +46,9 @@ describe("tryRouteCli", () => { delete process.env.OPENCLAW_DISABLE_ROUTE_FIRST; vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); + ({ loggingState } = await import("../logging/state.js")); + originalForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = false; findRoutedCommandMock.mockReturnValue({ loadPlugins: (argv: string[]) => !argv.includes("--json"), run: runRouteMock, @@ -49,6 +56,9 @@ describe("tryRouteCli", () => { }); afterEach(() => { + if (loggingState) { + loggingState.forceConsoleToStderr = originalForceStderr; + } if (originalDisableRouteFirst === undefined) { delete process.env.OPENCLAW_DISABLE_ROUTE_FIRST; } else { @@ -78,6 +88,44 @@ describe("tryRouteCli", () => { expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); + it("routes logs to stderr during plugin loading in --json mode and restores after", async () => { + findRoutedCommandMock.mockReturnValue({ + loadPlugins: true, + run: runRouteMock, + }); + + // Capture the value inside the mock callback using the same loggingState + // reference that route.js sees (both imported after vi.resetModules()). + const captured: boolean[] = []; + ensurePluginRegistryLoadedMock.mockImplementation(() => { + captured.push(loggingState.forceConsoleToStderr); + }); + + await tryRouteCli(["node", "openclaw", "agents", "--json"]); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); + expect(captured[0]).toBe(true); + expect(loggingState.forceConsoleToStderr).toBe(false); + }); + + it("does not route logs to stderr during plugin loading without --json", async () => { + findRoutedCommandMock.mockReturnValue({ + loadPlugins: true, + run: runRouteMock, + }); + + const captured: boolean[] = []; + ensurePluginRegistryLoadedMock.mockImplementation(() => { + captured.push(loggingState.forceConsoleToStderr); + }); + + await tryRouteCli(["node", "openclaw", "agents"]); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled(); + expect(captured[0]).toBe(false); + expect(loggingState.forceConsoleToStderr).toBe(false); + }); + it("routes status when root options precede the command", async () => { await expect(tryRouteCli(["node", "openclaw", "--log-level", "debug", "status"])).resolves.toBe( true, diff --git a/src/cli/route.ts b/src/cli/route.ts index abd347be0a0..aa56caf7d6b 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -1,4 +1,5 @@ import { isTruthyEnvValue } from "../infra/env.js"; +import { loggingState } from "../logging/state.js"; import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { getCommandPathWithRootOptions, hasFlag, hasHelpOrVersion } from "./argv.js"; @@ -22,12 +23,20 @@ async function prepareRoutedCommand(params: { typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins; if (shouldLoadPlugins) { const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); - ensurePluginRegistryLoaded({ - scope: - params.commandPath[0] === "status" || params.commandPath[0] === "health" - ? "channels" - : "all", - }); + const prev = loggingState.forceConsoleToStderr; + if (suppressDoctorStdout) { + loggingState.forceConsoleToStderr = true; + } + try { + ensurePluginRegistryLoaded({ + scope: + params.commandPath[0] === "status" || params.commandPath[0] === "health" + ? "channels" + : "all", + }); + } finally { + loggingState.forceConsoleToStderr = prev; + } } }