fix(cli): keep status usage on fast path

This commit is contained in:
Peter Steinberger
2026-04-29 13:20:40 +01:00
parent 4e4f9204d7
commit cf43b92fc9
11 changed files with 143 additions and 15 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/models: restore provider-filtered `models list --all --provider <id>` 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ export async function scanStatus(
json?: boolean;
timeoutMs?: number;
all?: boolean;
deep?: boolean;
},
_runtime: RuntimeEnv,
): Promise<StatusScanResult> {
@@ -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…",

View File

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