mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:20:44 +00:00
fix: keep agent json stdout clean
This commit is contained in:
@@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319.
|
||||
- Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana.
|
||||
- Providers/DeepSeek: add missing `reasoning_content` placeholders for replayed assistant tool-call turns when DeepSeek V4 thinking is enabled, so switching an existing session to `deepseek-v4-flash` or `deepseek-v4-pro` no longer trips the provider's 400 replay check. Fixes #71372. Thanks @yangyang1719.
|
||||
- Exec approvals: allow bare command-name allowlist patterns to match PATH-resolved executable basenames without trusting `./tool` or absolute path-selected binaries. Fixes #71315. Thanks @chen-zhang-cs-code and @dengluozhang.
|
||||
|
||||
@@ -53,6 +53,7 @@ openclaw agent --agent ops --message "Run locally" --local
|
||||
- Gateway mode falls back to the embedded agent when the Gateway request fails. Use `--local` to force embedded execution up front.
|
||||
- `--local` still preloads the plugin registry first, so plugin-provided providers, tools, and channels stay available during embedded runs.
|
||||
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing.
|
||||
- `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly.
|
||||
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
|
||||
- Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values.
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { agentCliCommand } from "./agent-via-gateway.js";
|
||||
import type { agentCommand as AgentCommand } from "./agent.js";
|
||||
@@ -17,6 +18,14 @@ const runtime: RuntimeEnv = {
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const jsonRuntime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
writeStdout: vi.fn(),
|
||||
writeJson: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
function mockConfig(storePath: string, overrides?: Partial<OpenClawConfig>) {
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
@@ -76,8 +85,16 @@ vi.mock("../gateway/call.js", () => ({
|
||||
}));
|
||||
vi.mock("./agent.js", () => ({ agentCommand }));
|
||||
|
||||
let originalForceConsoleToStderr = false;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalForceConsoleToStderr = loggingState.forceConsoleToStderr;
|
||||
loggingState.forceConsoleToStderr = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggingState.forceConsoleToStderr = originalForceConsoleToStderr;
|
||||
});
|
||||
|
||||
describe("agentCliCommand", () => {
|
||||
@@ -105,6 +122,28 @@ describe("agentCliCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes diagnostics to stderr before JSON gateway execution", async () => {
|
||||
await withTempStore(async () => {
|
||||
const response = {
|
||||
runId: "idem-1",
|
||||
status: "ok",
|
||||
result: {
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: { stub: true },
|
||||
},
|
||||
};
|
||||
callGateway.mockImplementationOnce(async () => {
|
||||
expect(loggingState.forceConsoleToStderr).toBe(true);
|
||||
return response;
|
||||
});
|
||||
|
||||
await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
|
||||
|
||||
expect(jsonRuntime.writeJson).toHaveBeenCalledWith(response, 2);
|
||||
expect(jsonRuntime.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to embedded agent when gateway fails", async () => {
|
||||
await withTempStore(async () => {
|
||||
callGateway.mockRejectedValue(new Error("gateway not connected"));
|
||||
@@ -118,6 +157,25 @@ describe("agentCliCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps diagnostics on stderr before JSON embedded fallback", async () => {
|
||||
await withTempStore(async () => {
|
||||
callGateway.mockRejectedValue(new Error("gateway not connected"));
|
||||
agentCommand.mockImplementationOnce(async (_opts, rt) => {
|
||||
expect(loggingState.forceConsoleToStderr).toBe(true);
|
||||
rt?.log?.("local");
|
||||
return {
|
||||
payloads: [{ text: "local" }],
|
||||
meta: { durationMs: 1, agentMeta: { sessionId: "s", provider: "p", model: "m" } },
|
||||
} as unknown as Awaited<ReturnType<typeof AgentCommand>>;
|
||||
});
|
||||
|
||||
await agentCliCommand({ message: "hi", to: "+1555", json: true }, jsonRuntime);
|
||||
|
||||
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(loggingState.forceConsoleToStderr).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips gateway when --local is set", async () => {
|
||||
await withTempStore(async () => {
|
||||
mockLocalAgentReply();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js";
|
||||
import { routeLogsToStderr } from "../logging/console.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
@@ -53,6 +54,12 @@ export type AgentCliOpts = {
|
||||
local?: boolean;
|
||||
};
|
||||
|
||||
function protectJsonStdout(opts: Pick<AgentCliOpts, "json">): void {
|
||||
if (opts.json === true) {
|
||||
routeLogsToStderr();
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) {
|
||||
const raw =
|
||||
opts.timeout !== undefined
|
||||
@@ -85,6 +92,7 @@ function formatPayloadForLog(payload: {
|
||||
}
|
||||
|
||||
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
|
||||
protectJsonStdout(opts);
|
||||
const body = (opts.message ?? "").trim();
|
||||
if (!body) {
|
||||
throw new Error("Message (--message) is required");
|
||||
@@ -178,6 +186,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
|
||||
}
|
||||
|
||||
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
|
||||
protectJsonStdout(opts);
|
||||
const localOpts = {
|
||||
...opts,
|
||||
agentId: opts.agent,
|
||||
|
||||
Reference in New Issue
Block a user