fix(cli): clarify terminal recovery errors

This commit is contained in:
Vincent Koc
2026-05-10 14:19:31 +08:00
parent acd882bde0
commit be2f333e6f
8 changed files with 89 additions and 9 deletions

View File

@@ -41,6 +41,9 @@ Docs: https://docs.openclaw.ai
- Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle.
- CLI/agent: let `openclaw agent --model` use the backend/admin Gateway scope without cached device-token scopes silently downscoping the request. (#78837) Thanks @VACInc.
- CLI/help: keep help and version invocations configless while improving shared port, channel, plugin, task, session, message, pairing, and auth recovery text.
- CLI/config: explain strict JSON parse failures with a valid example and the plain-string escape hatch.
- CLI/secrets: turn offline Gateway reload failures into actionable recovery text.
- CLI/channels: explain missing or ambiguous channel selections with next commands.
- Browser/Docker: detect Playwright-managed Chromium from `PLAYWRIGHT_BROWSERS_PATH` and the default Playwright cache on Linux, so Docker installs that persist `/home/node/.cache/ms-playwright` no longer need `browser.executablePath`.
- Ollama: keep DeepSeek V4 cloud models thinking-capable even when Ollama Cloud `/api/show` omits the `thinking` capability, so `/think high` no longer rejects `ollama/deepseek-v4-*:cloud`.
- ACPX/Claude ACP: keep foreground prompts waiting for their own result when autonomous task-notification results arrive during the same session, and retarget the patch for Claude Agent ACP `0.33.1`.

View File

@@ -734,6 +734,12 @@ describe("config cli", () => {
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining('Could not parse "{bad" as JSON for --strict-json.'),
);
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("For plain strings, omit --strict-json."),
);
});
it("keeps --json as a strict parsing alias", async () => {

View File

@@ -60,6 +60,7 @@ import {
type ConfigSetOptions,
} from "./config-set-input.js";
import { resolveConfigSetMode } from "./config-set-parser.js";
import { formatStrictJsonParseFailure } from "./error-format.js";
import { setCommandJsonMode } from "./program/json-mode.js";
type PathSegment = string;
@@ -266,7 +267,7 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
try {
return JSON.parse(trimmed);
} catch (err) {
throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err });
throw new Error(formatStrictJsonParseFailure({ value: raw, cause: err }), { cause: err });
}
}

View File

@@ -48,6 +48,39 @@ export function formatUnsupportedChannelActionMessage(params: {
)} to inspect supported actions.`;
}
export function formatStrictJsonParseFailure(params: { value: string; cause: unknown }): string {
const rawCause = params.cause instanceof Error ? params.cause.message : String(params.cause);
const cause = rawCause.trim().replace(/[.]+$/u, "");
const preview =
params.value.length > 48 ? `${params.value.slice(0, 45).trimEnd()}...` : params.value;
return [
`Could not parse ${JSON.stringify(preview)} as JSON for --strict-json.`,
`${cause}.`,
`Use valid JSON, for example ${formatInlineCliCommand(
"openclaw config set gateway.port 18789 --strict-json",
)}.`,
"For plain strings, omit --strict-json.",
].join(" ");
}
export function formatGatewayCommandFailure(params: {
action: string;
error: unknown;
inspectCommand?: string;
}): string {
const raw = params.error instanceof Error ? params.error.message : String(params.error);
const message = raw
.replace(/\s*Run [`"]?openclaw doctor[`"]? for diagnostics\.?/gi, "")
.replace(/\s+/g, " ")
.trim()
.replace(/[.]+$/u, "");
const inspectCommand = params.inspectCommand ?? "openclaw gateway status --deep";
const detail = message ? `: ${message}` : "";
return `Could not ${params.action} because the Gateway did not respond${detail}. Run ${formatInlineCliCommand(
inspectCommand,
)} to inspect the active Gateway.`;
}
export function formatLookupMiss(params: {
noun: string;
value: string;

View File

@@ -175,6 +175,22 @@ describe("secrets CLI", () => {
expect(runtimeLogs.at(-1)).toContain('"ok": true');
});
it("explains Gateway reload failures without duplicate doctor noise", async () => {
callGatewayFromCli.mockRejectedValue(
new Error("gateway closed (1006 abnormal closure). Run `openclaw doctor` for diagnostics."),
);
await expect(
createProgram().parseAsync(["secrets", "reload"], { from: "user" }),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain(
"Could not reload secrets because the Gateway did not respond: gateway closed (1006 abnormal closure).",
);
expect(runtimeErrors.at(-1)).toContain("openclaw gateway status --deep");
expect(runtimeErrors.at(-1)).not.toContain("diagnostics..");
});
it("runs secrets audit and exits via check code", async () => {
runSecretsAudit.mockResolvedValue({
version: 1,

View File

@@ -11,6 +11,7 @@ import { isSecretsApplyPlan, type SecretsApplyPlan } from "../secrets/plan.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
import { formatGatewayCommandFailure } from "./error-format.js";
import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js";
type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean };
@@ -82,7 +83,11 @@ export function registerSecretsCli(program: Command) {
} catch (err) {
defaultRuntime.error(
danger(
`Secrets reload failed: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep")} to inspect the active gateway.`,
formatGatewayCommandFailure({
action: "reload secrets",
error: err,
inspectCommand: "openclaw gateway status --deep",
}),
),
);
defaultRuntime.exit(1);

View File

@@ -271,7 +271,8 @@ describe("resolveMessageChannelSelection", () => {
},
{
params: { cfg: {} as never },
expectedMessage: "Channel is required (no configured channels detected).",
expectedMessage:
"Channel is required (no configured channels detected). Run openclaw channels add to configure one",
},
{
setup: () => {
@@ -292,7 +293,8 @@ describe("resolveMessageChannelSelection", () => {
]);
},
params: { cfg: { channels: { whatsapp: { enabled: true } } } as never },
expectedMessage: "Channel is required (no configured channels detected).",
expectedMessage:
"Channel is required (no configured channels detected). Run openclaw channels add to configure one",
},
{
setup: () => {
@@ -302,7 +304,8 @@ describe("resolveMessageChannelSelection", () => {
]);
},
params: { cfg: {} as never },
expectedMessage: "Channel is required when multiple channels are configured: beta, gamma",
expectedMessage:
"Channel is required when multiple channels are configured: beta, gamma. Pass --channel <channel> to choose one.",
},
])("rejects invalid channel selection for %j", async ({ setup, params, expectedMessage }) => {
setup?.();

View File

@@ -102,6 +102,21 @@ function formatMissingOfficialExternalChannelsMessage(
return `Configured official external channels ${labels} are missing their plugins. Run: openclaw doctor --fix, or install individually: ${installCommands}.`;
}
function formatNoConfiguredChannelsMessage(): string {
return [
"Channel is required (no configured channels detected).",
"Run openclaw channels add to configure one, or pass --channel <channel> after enabling a channel.",
"Use openclaw channels list --all to see available channel ids.",
].join(" ");
}
function formatMultipleConfiguredChannelsMessage(configured: readonly string[]): string {
return [
`Channel is required when multiple channels are configured: ${configured.join(", ")}.`,
"Pass --channel <channel> to choose one.",
].join(" ");
}
function isAccountEnabled(account: unknown): boolean {
if (!account || typeof account !== "object") {
return true;
@@ -263,11 +278,9 @@ export async function resolveMessageChannelSelection(params: {
`Channel is required (no available channels detected). ${formatMissingOfficialExternalChannelsMessage(repairHints)}`,
);
}
throw new Error("Channel is required (no configured channels detected).");
throw new Error(formatNoConfiguredChannelsMessage());
}
throw new Error(
`Channel is required when multiple channels are configured: ${configured.join(", ")}`,
);
throw new Error(formatMultipleConfiguredChannelsMessage(configured));
}
export const __testing = {