mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(cli): keep status usage on fast path
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user