From cf43b92fc9015fb45b29152205322dfbb02fe4f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 13:20:40 +0100 Subject: [PATCH] fix(cli): keep status usage on fast path --- CHANGELOG.md | 1 + src/cli/run-main-policy.ts | 5 ++ src/cli/run-main.test.ts | 1 + ...re.gateway-auth.prompt-auth-config.test.ts | 28 ++++++++-- src/commands/status-all/channels.ts | 9 +++- src/commands/status.command.ts | 2 +- src/commands/status.scan-overview.test.ts | 22 ++++++++ src/commands/status.scan-overview.ts | 20 ++++--- src/commands/status.scan.test.ts | 54 +++++++++++++++++++ src/commands/status.scan.ts | 4 ++ src/commands/status.test.ts | 12 +++++ 11 files changed, 143 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ac907db3e..7df2f18e2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/models: restore provider-filtered `models list --all --provider ` rows for providers without manifest/static catalog coverage, including Anthropic and Amazon Bedrock, while keeping the compatibility fallback off expensive availability and resolver paths. Thanks @shakkernerd. +- CLI/status: keep default text `openclaw status --usage` on metadata-only channel scans unless `--deep` or `--all` is set, and send stray `openclaw tools --help` through the precomputed root-help fast path so latency-triage commands avoid plugin/runtime cold loads before printing. Refs #73477 and #74220. Thanks @oromeis and @NianJiuZst. - Plugins/runtime-deps: memoize packaged bundled runtime dist-mirror preparation after the first successful pass while keeping source-checkout mirrors refreshable, so constrained Docker/VPS installs avoid repeated root scans before chat turns. Refs #73428, #73421, #73532, and #73477. Thanks @Dimaoggg, @oromeis, @oadiazp, @jmfraga, @bstanbury, @antoniusfelix, and @jkobject. - Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie. - Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev. diff --git a/src/cli/run-main-policy.ts b/src/cli/run-main-policy.ts index 9e3ece97bb2..1414135442e 100644 --- a/src/cli/run-main-policy.ts +++ b/src/cli/run-main-policy.ts @@ -14,6 +14,8 @@ import { resolveCliNetworkProxyPolicy, } from "./command-path-policy.js"; +const ROOT_HELP_ALIASES = new Set(["tools"]); + export function rewriteUpdateFlagArgv(argv: string[]): string[] { const index = argv.indexOf("--update"); if (index === -1) { @@ -41,6 +43,9 @@ export function shouldUseRootHelpFastPath( return ( env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH !== "1" && (invocation.isRootHelpInvocation || + (invocation.commandPath.length === 1 && + ROOT_HELP_ALIASES.has(invocation.commandPath[0] ?? "") && + invocation.hasHelpOrVersion) || (invocation.commandPath.length === 1 && invocation.commandPath[0] === "help" && invocation.hasHelpOrVersion)) diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 5bf21fd5b62..851e58a8543 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -156,6 +156,7 @@ describe("shouldUseRootHelpFastPath", () => { expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true); expect(shouldUseRootHelpFastPath(["node", "openclaw", "--profile", "work", "-h"])).toBe(true); expect(shouldUseRootHelpFastPath(["node", "openclaw", "help", "--help"])).toBe(true); + expect(shouldUseRootHelpFastPath(["node", "openclaw", "tools", "--help"])).toBe(true); expect(shouldUseRootHelpFastPath(["node", "openclaw", "status", "--help"])).toBe(false); expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help", "status"])).toBe(false); expect(shouldUseRootHelpFastPath(["node", "openclaw", "help", "gateway"])).toBe(false); diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 8502ded08dc..be134a96964 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.js"; import type { NormalizedModelCatalogRow } from "../model-catalog/index.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -85,6 +86,18 @@ function createKilocodeProvider() { }; } +function createTestModel(id: string, name = id) { + return { + id, + name, + reasoning: false, + input: ["text"] as Array<"text" | "image" | "video" | "audio">, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + }; +} + function createApplyAuthChoiceConfig(includeMinimaxProvider = false) { return { config: { @@ -101,7 +114,7 @@ function createApplyAuthChoiceConfig(includeMinimaxProvider = false) { minimax: { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }], + models: [createTestModel("MiniMax-M2.7", "MiniMax M2.7")], }, } : {}), @@ -303,11 +316,11 @@ describe("promptAuthConfig", () => { ollama: { baseUrl: "https://ollama.com", api: "ollama", - models: [{ id: "deepseek-v4-pro", name: "deepseek-v4-pro" }], + models: [createTestModel("deepseek-v4-pro")], }, }, }, - }; + } satisfies OpenClawConfig; mocks.applyAuthChoice.mockResolvedValue({ config: existingConfig }); mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); mocks.resolveProviderPluginChoice.mockReturnValue(null); @@ -332,11 +345,11 @@ describe("promptAuthConfig", () => { ollama: { baseUrl: "https://ollama.com", api: "ollama", - models: [{ id: "deepseek-v4-pro", name: "deepseek-v4-pro" }], + models: [createTestModel("deepseek-v4-pro")], }, }, }, - }; + } satisfies OpenClawConfig; mocks.applyAuthChoice.mockResolvedValue({ config: { ...existingConfig, @@ -348,7 +361,12 @@ describe("promptAuthConfig", () => { ref: "github-copilot/claude-opus-4.7", provider: "github-copilot", id: "claude-opus-4.7", + mergeKey: "github-copilot/claude-opus-4.7", name: "Claude Opus 4.7", + source: "manifest", + input: ["text"], + reasoning: false, + status: "available", }, ]); mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index c1101ae837d..d8324ed5e27 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -188,7 +188,11 @@ function collectMissingPaths(accounts: ChannelAccountRow[]): string[] { // Keep this generic: channel-specific rules belong in the channel plugin. export async function buildChannelsTable( cfg: OpenClawConfig, - opts?: { showSecrets?: boolean; sourceConfig?: OpenClawConfig }, + opts?: { + showSecrets?: boolean; + sourceConfig?: OpenClawConfig; + includeSetupRuntimeFallback?: boolean; + }, ): Promise<{ rows: ChannelRow[]; details: Array<{ @@ -206,9 +210,10 @@ export async function buildChannelsTable( }> = []; const sourceConfig = opts?.sourceConfig ?? cfg; + const includeSetupRuntimeFallback = opts?.includeSetupRuntimeFallback ?? true; for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, - includeSetupRuntimeFallback: true, + includeSetupRuntimeFallback, })) { const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 8a057cc6b88..83507b932dd 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -130,7 +130,7 @@ export async function statusCommand( } const scan = await loadStatusScanModule().then(({ scanStatus }) => - scanStatus({ json: false, timeoutMs: opts.timeoutMs, all: opts.all }, runtime), + scanStatus({ json: false, timeoutMs: opts.timeoutMs, all: opts.all, deep: opts.deep }, runtime), ); const { diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts index 24f502adcbf..acdc7337c68 100644 --- a/src/commands/status.scan-overview.test.ts +++ b/src/commands/status.scan-overview.test.ts @@ -113,6 +113,7 @@ describe("collectStatusScanOverview", () => { expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ + includeSetupRuntimeFallback: true, showSecrets: false, sourceConfig: { session: {} }, }), @@ -120,6 +121,27 @@ describe("collectStatusScanOverview", () => { expect(result.channelIssues).toEqual([{ channel: "quietchat", message: "boom" }]); }); + it("can keep channel overview on metadata-only status paths", async () => { + const result = await collectStatusScanOverview({ + commandName: "status", + opts: { timeoutMs: 1234 }, + showSecrets: false, + includeLiveChannelStatus: false, + includeChannelSetupRuntimeFallback: false, + }); + + expect(mocks.callGateway).not.toHaveBeenCalled(); + expect(mocks.buildChannelsTable).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + includeSetupRuntimeFallback: false, + showSecrets: false, + sourceConfig: { session: {} }, + }), + ); + expect(result.channelIssues).toEqual([]); + }); + it("skips channels.status when the gateway is unreachable", async () => { mocks.createStatusScanCoreBootstrap.mockResolvedValueOnce({ tailscaleMode: "off", diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index ccf26d12251..51d6c8d3b8c 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -135,6 +135,8 @@ export async function collectStatusScanOverview(params: { allowMissingConfigFastPath?: boolean; resolveHasConfiguredChannels?: (cfg: OpenClawConfig, sourceConfig: OpenClawConfig) => boolean; includeChannelsData?: boolean; + includeLiveChannelStatus?: boolean; + includeChannelSetupRuntimeFallback?: boolean; useGatewayCallOverridesForChannelsStatus?: boolean; progress?: { setLabel(label: string): void; @@ -227,18 +229,21 @@ export async function collectStatusScanOverview(params: { const tailscaleHttpsUrl = await bootstrap.resolveTailscaleHttpsUrl(); const includeChannelsData = params.includeChannelsData !== false; + const includeLiveChannelStatus = params.includeLiveChannelStatus !== false; const { channelsStatus, channelIssues, channels } = includeChannelsData ? await (async () => { if (params.labels?.queryingChannelStatus) { params.progress?.setLabel(params.labels.queryingChannelStatus); } - const channelsStatus = await resolveStatusChannelsStatus({ - cfg, - gatewayReachable: gatewaySnapshot.gatewayReachable, - opts: params.opts, - gatewayCallOverrides: gatewaySnapshot.gatewayCallOverrides, - useGatewayCallOverrides: params.useGatewayCallOverridesForChannelsStatus, - }); + const channelsStatus = includeLiveChannelStatus + ? await resolveStatusChannelsStatus({ + cfg, + gatewayReachable: gatewaySnapshot.gatewayReachable, + opts: params.opts, + gatewayCallOverrides: gatewaySnapshot.gatewayCallOverrides, + useGatewayCallOverrides: params.useGatewayCallOverridesForChannelsStatus, + }) + : null; params.progress?.tick(); const { collectChannelStatusIssues, buildChannelsTable } = await loadStatusScanRuntimeModule().then(({ statusScanRuntime }) => statusScanRuntime); @@ -249,6 +254,7 @@ export async function collectStatusScanOverview(params: { const channels = await buildChannelsTable(cfg, { showSecrets: params.showSecrets, sourceConfig, + includeSetupRuntimeFallback: params.includeChannelSetupRuntimeFallback !== false, }); params.progress?.tick(); return { channelsStatus, channelIssues, channels }; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 1f7a92aa966..ff4c33b3161 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -90,11 +90,65 @@ describe("scanStatus", () => { expect(mocks.buildChannelsTable).toHaveBeenCalledWith( expect.objectContaining({ marker: "resolved" }), expect.objectContaining({ + includeSetupRuntimeFallback: false, sourceConfig: expect.objectContaining({ marker: "source" }), }), ); }); + it("keeps default text status off live channel status and setup runtime fallback", async () => { + configureScanStatus({ hasConfiguredChannels: true }); + mocks.probeGateway.mockResolvedValue({ + ok: true, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 12, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: false }, {} as never); + + expect(mocks.callGateway).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "channels.status" }), + ); + expect(mocks.buildChannelsTable).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ includeSetupRuntimeFallback: false }), + ); + }); + + it("uses live channel status and setup fallback for deep text status", async () => { + configureScanStatus({ hasConfiguredChannels: true }); + mocks.probeGateway.mockResolvedValue({ + ok: true, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 12, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: false, deep: true, timeoutMs: 5000 }, {} as never); + + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "channels.status", + timeoutMs: 2500, + }), + ); + expect(mocks.buildChannelsTable).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ includeSetupRuntimeFallback: true }), + ); + }); + it("skips channel plugin preload for status --json with no channel config", async () => { configureScanStatus({ sourceConfig: createStatusScanConfig({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 0257d148673..abf1bee2829 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -13,6 +13,7 @@ export async function scanStatus( json?: boolean; timeoutMs?: number; all?: boolean; + deep?: boolean; }, _runtime: RuntimeEnv, ): Promise { @@ -46,10 +47,13 @@ export async function scanStatus( enabled: true, }, async (progress) => { + const includeLiveChannelChecks = opts.all === true || opts.deep === true; const overview = await collectStatusScanOverview({ commandName: "status", opts, showSecrets: process.env.OPENCLAW_SHOW_SECRETS?.trim() !== "0", + includeLiveChannelStatus: includeLiveChannelChecks, + includeChannelSetupRuntimeFallback: includeLiveChannelChecks, progress, labels: { loadingConfig: "Loading config…", diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5d5780c363c..57674f67a47 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -1018,6 +1018,18 @@ describe("statusCommand", () => { expect(mocks.runSecurityAudit).not.toHaveBeenCalled(); }); + it("passes deep mode through to the text status scan", async () => { + const { scanStatus } = await import("./status.scan.js"); + vi.mocked(scanStatus).mockClear(); + + await statusCommand({ deep: true, timeoutMs: 5000 }, runtime as never); + + expect(scanStatus).toHaveBeenCalledWith( + { json: false, timeoutMs: 5000, all: undefined, deep: true }, + runtime, + ); + }); + it("surfaces unknown usage when totalTokens is missing", async () => { await withUnknownUsageStore(async () => { runtimeLogMock.mockClear();