fix(status): slim json startup path

This commit is contained in:
Vincent Koc 2026-03-19 16:54:16 -07:00
parent 41628770f5
commit d518260bb8
6 changed files with 559 additions and 32 deletions

View File

@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
scanStatusJsonFast: vi.fn(),
runSecurityAudit: vi.fn(),
loadProviderUsageSummary: vi.fn(),
callGateway: vi.fn(),
getDaemonStatusSummary: vi.fn(),
getNodeDaemonStatusSummary: vi.fn(),
normalizeUpdateChannel: vi.fn((value?: string | null) => value ?? null),
resolveUpdateChannelDisplay: vi.fn(() => ({
channel: "stable",
source: "config",
})),
}));
vi.mock("./status.scan.fast-json.js", () => ({
scanStatusJsonFast: mocks.scanStatusJsonFast,
}));
vi.mock("../security/audit.runtime.js", () => ({
runSecurityAudit: mocks.runSecurityAudit,
}));
vi.mock("../infra/provider-usage.js", () => ({
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
}));
vi.mock("./status.daemon.js", () => ({
getDaemonStatusSummary: mocks.getDaemonStatusSummary,
getNodeDaemonStatusSummary: mocks.getNodeDaemonStatusSummary,
}));
vi.mock("../infra/update-channels.js", () => ({
normalizeUpdateChannel: mocks.normalizeUpdateChannel,
resolveUpdateChannelDisplay: mocks.resolveUpdateChannelDisplay,
}));
const { statusJsonCommand } = await import("./status-json.js");
function createRuntimeCapture() {
const logs: string[] = [];
const runtime: RuntimeEnv = {
log: vi.fn((value: unknown) => {
logs.push(String(value));
}),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
};
return { runtime, logs };
}
function createScanResult() {
return {
cfg: { update: { channel: "stable" } },
sourceConfig: {},
summary: { ok: true, configuredChannels: [] },
osSummary: { platform: "linux" },
update: { installKind: "npm", git: { tag: null, branch: null } },
memory: null,
memoryPlugin: null,
gatewayMode: "local",
gatewayConnection: { url: "ws://127.0.0.1:18789", urlSource: "config" },
remoteUrlMissing: false,
gatewayReachable: false,
gatewayProbe: null,
gatewaySelf: null,
gatewayProbeAuthWarning: null,
agentStatus: [],
secretDiagnostics: [],
};
}
describe("statusJsonCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.scanStatusJsonFast.mockResolvedValue(createScanResult());
mocks.runSecurityAudit.mockResolvedValue({
summary: { critical: 1, warn: 0, info: 0 },
findings: [],
});
mocks.getDaemonStatusSummary.mockResolvedValue({ installed: false });
mocks.getNodeDaemonStatusSummary.mockResolvedValue({ installed: false });
mocks.loadProviderUsageSummary.mockResolvedValue({ providers: [] });
mocks.callGateway.mockResolvedValue({});
});
it("keeps plain status --json off the security audit fast path", async () => {
const { runtime, logs } = createRuntimeCapture();
await statusJsonCommand({}, runtime);
expect(mocks.runSecurityAudit).not.toHaveBeenCalled();
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0] ?? "{}")).not.toHaveProperty("securityAudit");
});
it("includes security audit details only when --all is requested", async () => {
const { runtime, logs } = createRuntimeCapture();
await statusJsonCommand({ all: true }, runtime);
expect(mocks.runSecurityAudit).toHaveBeenCalledWith({
config: expect.any(Object),
sourceConfig: expect.any(Object),
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
});
expect(logs).toHaveLength(1);
expect(JSON.parse(logs[0] ?? "{}")).toHaveProperty("securityAudit.summary.critical", 1);
});
});

View File

@ -33,15 +33,17 @@ export async function statusJsonCommand(
runtime: RuntimeEnv,
) {
const scan = await scanStatusJsonFast({ timeoutMs: opts.timeoutMs, all: opts.all }, runtime);
const securityAudit = await loadSecurityAuditModule().then(({ runSecurityAudit }) =>
runSecurityAudit({
config: scan.cfg,
sourceConfig: scan.sourceConfig,
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
}),
);
const securityAudit = opts.all
? await loadSecurityAuditModule().then(({ runSecurityAudit }) =>
runSecurityAudit({
config: scan.cfg,
sourceConfig: scan.sourceConfig,
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
}),
)
: undefined;
const usage = opts.usage
? await loadProviderUsage().then(({ loadProviderUsageSummary }) =>
@ -105,8 +107,8 @@ export async function statusJsonCommand(
gatewayService: daemon,
nodeService: nodeDaemon,
agents: scan.agentStatus,
securityAudit,
secretDiagnostics: scan.secretDiagnostics,
...(securityAudit ? { securityAudit } : {}),
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
},
null,

View File

@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
hasPotentialConfiguredChannels: vi.fn(),
readBestEffortConfig: vi.fn(),
resolveCommandSecretRefsViaGateway: vi.fn(),
getStatusCommandSecretTargetIds: vi.fn(() => []),
getUpdateCheckResult: vi.fn(),
getAgentLocalStatuses: vi.fn(),
getStatusSummary: vi.fn(),
resolveMemorySearchConfig: vi.fn(),
getMemorySearchManager: vi.fn(),
buildGatewayConnectionDetails: vi.fn(),
probeGateway: vi.fn(),
resolveGatewayProbeAuthResolution: vi.fn(),
ensurePluginRegistryLoaded: vi.fn(),
buildPluginCompatibilityNotices: vi.fn(() => []),
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.hasPotentialConfiguredChannels.mockReturnValue(false);
mocks.readBestEffortConfig.mockResolvedValue({
session: {},
gateway: {},
agents: {
defaults: {
memorySearch: {
provider: "local",
local: { modelPath: "/tmp/model.gguf" },
fallback: "none",
},
},
},
});
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: {
session: {},
gateway: {},
agents: {
defaults: {
memorySearch: {
provider: "local",
local: { modelPath: "/tmp/model.gguf" },
fallback: "none",
},
},
},
},
diagnostics: [],
});
mocks.getUpdateCheckResult.mockResolvedValue({
installKind: "git",
git: null,
registry: null,
});
mocks.getAgentLocalStatuses.mockResolvedValue({
defaultId: "main",
agents: [],
});
mocks.getStatusSummary.mockResolvedValue({
linkChannel: undefined,
sessions: { count: 0, paths: [], defaults: {}, recent: [], byAgent: [] },
});
mocks.buildGatewayConnectionDetails.mockReturnValue({
url: "ws://127.0.0.1:18789",
urlSource: "default",
});
mocks.resolveGatewayProbeAuthResolution.mockReturnValue({
auth: {},
warning: undefined,
});
mocks.probeGateway.mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
mocks.resolveMemorySearchConfig.mockReturnValue({
store: { path: "/tmp/main.sqlite" },
});
mocks.getMemorySearchManager.mockResolvedValue({
manager: {
probeVectorAvailability: vi.fn(async () => true),
status: vi.fn(() => ({ files: 0, chunks: 0, dirty: false })),
close: vi.fn(async () => {}),
},
});
});
vi.mock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
}));
vi.mock("../config/io.js", () => ({
readBestEffortConfig: mocks.readBestEffortConfig,
}));
vi.mock("../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
vi.mock("../cli/command-secret-targets.js", () => ({
getStatusCommandSecretTargetIds: mocks.getStatusCommandSecretTargetIds,
}));
vi.mock("./status.update.js", () => ({
getUpdateCheckResult: mocks.getUpdateCheckResult,
}));
vi.mock("./status.agent-local.js", () => ({
getAgentLocalStatuses: mocks.getAgentLocalStatuses,
}));
vi.mock("./status.summary.js", () => ({
getStatusSummary: mocks.getStatusSummary,
}));
vi.mock("../infra/os-summary.js", () => ({
resolveOsSummary: vi.fn(() => ({ label: "test-os" })),
}));
vi.mock("./status.scan.deps.runtime.js", () => ({
getTailnetHostname: vi.fn(),
getMemorySearchManager: mocks.getMemorySearchManager,
}));
vi.mock("../agents/memory-search.js", () => ({
resolveMemorySearchConfig: mocks.resolveMemorySearchConfig,
}));
vi.mock("../gateway/call.js", () => ({
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
}));
vi.mock("../gateway/probe.js", () => ({
probeGateway: mocks.probeGateway,
}));
vi.mock("./status.gateway-probe.js", () => ({
pickGatewaySelfPresence: vi.fn(() => null),
resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution,
}));
vi.mock("../process/exec.js", () => ({
runExec: vi.fn(),
}));
vi.mock("../cli/plugin-registry.js", () => ({
ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded,
}));
vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices,
}));
const { scanStatusJsonFast } = await import("./status.scan.fast-json.js");
describe("scanStatusJsonFast", () => {
it("skips memory inspection for the lean status --json fast path", async () => {
const result = await scanStatusJsonFast({}, {} as never);
expect(result.memory).toBeNull();
expect(mocks.resolveMemorySearchConfig).not.toHaveBeenCalled();
expect(mocks.getMemorySearchManager).not.toHaveBeenCalled();
});
it("restores memory inspection when --all is requested", async () => {
const result = await scanStatusJsonFast({ all: true }, {} as never);
expect(result.memory).toEqual(expect.objectContaining({ agentId: "main" }));
expect(mocks.resolveMemorySearchConfig).toHaveBeenCalled();
expect(mocks.getMemorySearchManager).toHaveBeenCalledWith({
cfg: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
memorySearch: expect.any(Object),
}),
}),
}),
agentId: "main",
purpose: "status",
});
});
});

View File

@ -197,7 +197,11 @@ export async function scanStatusJsonFast(
? pickGatewaySelfPresence(gatewayProbe.presence)
: null;
const memoryPlugin = resolveMemoryPluginStatus(cfg);
const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin });
// Keep the lean `status --json` route off the memory manager/runtime graph.
// Deep memory inspection is still available on the explicit `--all` path.
const memory = opts.all
? await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin })
: null;
const pluginCompatibility = shouldCollectPluginCompatibility(cfg)
? await loadPluginStatusModule().then(({ buildPluginCompatibilityNotices }) =>
// Keep plugin status loading off the empty-config `status --json` fast path.

View File

@ -1,5 +1,226 @@
import { resolveContextTokensForModel } from "../agents/context.js";
import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.js";
function parseStatusModelRef(
raw: string,
defaultProvider: string,
): { provider: string; model: string } | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const slash = trimmed.indexOf("/");
if (slash === -1) {
return { provider: defaultProvider, model: trimmed };
}
const provider = trimmed.slice(0, slash).trim();
const model = trimmed.slice(slash + 1).trim();
if (!provider || !model) {
return null;
}
return { provider, model };
}
function resolveStatusModelRefFromRaw(params: {
cfg: OpenClawConfig;
rawModel: string;
defaultProvider: string;
}): { provider: string; model: string } | null {
const trimmed = params.rawModel.trim();
if (!trimmed) {
return null;
}
const configuredModels = params.cfg.agents?.defaults?.models ?? {};
if (!trimmed.includes("/")) {
const aliasKey = trimmed.toLowerCase();
for (const [modelKey, entry] of Object.entries(configuredModels)) {
const aliasValue = (entry as { alias?: unknown } | undefined)?.alias;
const alias = typeof aliasValue === "string" ? aliasValue.trim() : "";
if (!alias || alias.toLowerCase() !== aliasKey) {
continue;
}
const parsed = parseStatusModelRef(modelKey, params.defaultProvider);
if (parsed) {
return parsed;
}
}
return { provider: "anthropic", model: trimmed };
}
return parseStatusModelRef(trimmed, params.defaultProvider);
}
function resolveConfiguredStatusModelRef(params: {
cfg: OpenClawConfig;
defaultProvider: string;
defaultModel: string;
agentId?: string;
}): { provider: string; model: string } {
const agentRawModel = params.agentId
? resolveAgentModelPrimaryValue(
params.cfg.agents?.list?.find((entry) => entry?.id === params.agentId)?.model,
)
: undefined;
if (agentRawModel) {
const parsed = resolveStatusModelRefFromRaw({
cfg: params.cfg,
rawModel: agentRawModel,
defaultProvider: params.defaultProvider,
});
if (parsed) {
return parsed;
}
}
const defaultsRawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model);
if (defaultsRawModel) {
const parsed = resolveStatusModelRefFromRaw({
cfg: params.cfg,
rawModel: defaultsRawModel,
defaultProvider: params.defaultProvider,
});
if (parsed) {
return parsed;
}
}
const configuredProviders = params.cfg.models?.providers;
if (configuredProviders && typeof configuredProviders === "object") {
const hasDefaultProvider = Boolean(configuredProviders[params.defaultProvider]);
if (!hasDefaultProvider) {
const availableProvider = Object.entries(configuredProviders).find(
([, providerCfg]) =>
providerCfg &&
Array.isArray(providerCfg.models) &&
providerCfg.models.length > 0 &&
providerCfg.models[0]?.id,
);
if (availableProvider) {
const [providerName, providerCfg] = availableProvider;
return { provider: providerName, model: providerCfg.models[0].id };
}
}
}
return { provider: params.defaultProvider, model: params.defaultModel };
}
function resolveConfiguredProviderContextWindow(
cfg: OpenClawConfig | undefined,
provider: string,
model: string,
): number | undefined {
const providers = cfg?.models?.providers;
if (!providers || typeof providers !== "object") {
return undefined;
}
const providerKey = provider.trim().toLowerCase();
for (const [id, providerConfig] of Object.entries(providers)) {
if (id.trim().toLowerCase() !== providerKey || !Array.isArray(providerConfig?.models)) {
continue;
}
for (const entry of providerConfig.models) {
if (
typeof entry?.id === "string" &&
entry.id === model &&
typeof entry.contextWindow === "number" &&
entry.contextWindow > 0
) {
return entry.contextWindow;
}
}
}
return undefined;
}
function classifySessionKey(key: string, entry?: SessionEntry) {
if (key === "global") {
return "global";
}
if (key === "unknown") {
return "unknown";
}
if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group";
}
if (key.includes(":group:") || key.includes(":channel:")) {
return "group";
}
return "direct";
}
function resolveSessionModelRef(
cfg: OpenClawConfig,
entry?:
| SessionEntry
| Pick<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
agentId?: string,
): { provider: string; model: string } {
const resolved = resolveConfiguredStatusModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
agentId,
});
let provider = resolved.provider;
let model = resolved.model;
const runtimeModel = entry?.model?.trim();
const runtimeProvider = entry?.modelProvider?.trim();
if (runtimeModel) {
if (runtimeProvider) {
return { provider: runtimeProvider, model: runtimeModel };
}
const parsedRuntime = parseStatusModelRef(runtimeModel, provider || DEFAULT_PROVIDER);
if (parsedRuntime) {
provider = parsedRuntime.provider;
model = parsedRuntime.model;
} else {
model = runtimeModel;
}
return { provider, model };
}
const storedModelOverride = entry?.modelOverride?.trim();
if (storedModelOverride) {
const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER;
const parsedOverride = parseStatusModelRef(storedModelOverride, overrideProvider);
if (parsedOverride) {
provider = parsedOverride.provider;
model = parsedOverride.model;
} else {
provider = overrideProvider;
model = storedModelOverride;
}
}
return { provider, model };
}
function resolveContextTokensForModel(params: {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
contextTokensOverride?: number;
fallbackContextTokens?: number;
allowAsyncLoad?: boolean;
}): number | undefined {
void params.allowAsyncLoad;
if (typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0) {
return params.contextTokensOverride;
}
if (params.provider && params.model) {
const configuredWindow = resolveConfiguredProviderContextWindow(
params.cfg,
params.provider,
params.model,
);
if (configuredWindow !== undefined) {
return configuredWindow;
}
}
return params.fallbackContextTokens ?? DEFAULT_CONTEXT_TOKENS;
}
export const statusSummaryRuntime = {
resolveContextTokensForModel,

View File

@ -4,8 +4,15 @@ vi.mock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: vi.fn(() => true),
}));
vi.mock("../agents/context.js", () => ({
resolveContextTokensForModel: vi.fn(() => 200_000),
vi.mock("./status.summary.runtime.js", () => ({
statusSummaryRuntime: {
classifySessionKey: vi.fn(() => "direct"),
resolveSessionModelRef: vi.fn(() => ({
provider: "openai",
model: "gpt-5.2",
})),
resolveContextTokensForModel: vi.fn(() => 200_000),
},
}));
vi.mock("../agents/defaults.js", () => ({
@ -14,13 +21,6 @@ vi.mock("../agents/defaults.js", () => ({
DEFAULT_PROVIDER: "openai",
}));
vi.mock("../agents/model-selection.js", () => ({
resolveConfiguredModelRef: vi.fn(() => ({
provider: "openai",
model: "gpt-5.2",
})),
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({})),
}));
@ -39,14 +39,6 @@ vi.mock("../gateway/agent-list.js", () => ({
})),
}));
vi.mock("../gateway/session-utils.js", () => ({
classifySessionKey: vi.fn(() => "direct"),
resolveSessionModelRef: vi.fn(() => ({
provider: "openai",
model: "gpt-5.2",
})),
}));
vi.mock("../infra/channel-summary.js", () => ({
buildChannelSummary: vi.fn(async () => ["ok"]),
}));
@ -78,9 +70,9 @@ vi.mock("./status.link-channel.js", () => ({
}));
const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js");
const { resolveContextTokensForModel } = await import("../agents/context.js");
const { buildChannelSummary } = await import("../infra/channel-summary.js");
const { resolveLinkChannelContext } = await import("./status.link-channel.js");
const { statusSummaryRuntime } = await import("./status.summary.runtime.js");
const { getStatusSummary } = await import("./status.summary.js");
describe("getStatusSummary", () => {
@ -110,7 +102,7 @@ describe("getStatusSummary", () => {
it("does not trigger async context warmup while building status summaries", async () => {
await getStatusSummary();
expect(vi.mocked(resolveContextTokensForModel)).toHaveBeenCalledWith(
expect(vi.mocked(statusSummaryRuntime.resolveContextTokensForModel)).toHaveBeenCalledWith(
expect.objectContaining({ allowAsyncLoad: false }),
);
});