From 4d00c470724eac0eb4f7d10a9ab1e04b6b58d6bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 12:03:58 +0100 Subject: [PATCH] perf(crestodian): reduce test import overhead --- src/crestodian/crestodian.test-helpers.ts | 15 ++ src/crestodian/crestodian.test.ts | 58 +++-- src/crestodian/crestodian.ts | 11 +- src/crestodian/dialogue.ts | 11 +- src/crestodian/operations.test.ts | 270 ++++++++++++---------- src/crestodian/operations.ts | 73 ++++-- src/crestodian/overview.test.ts | 60 ++--- src/crestodian/overview.ts | 26 ++- src/crestodian/rescue-message.test.ts | 134 +++++++++-- src/crestodian/tui-backend.test.ts | 40 +++- 10 files changed, 468 insertions(+), 230 deletions(-) create mode 100644 src/crestodian/crestodian.test-helpers.ts diff --git a/src/crestodian/crestodian.test-helpers.ts b/src/crestodian/crestodian.test-helpers.ts new file mode 100644 index 00000000000..c12a7704ae1 --- /dev/null +++ b/src/crestodian/crestodian.test-helpers.ts @@ -0,0 +1,15 @@ +import type { RuntimeEnv } from "../runtime.js"; + +export function createCrestodianTestRuntime(): { runtime: RuntimeEnv; lines: string[] } { + const lines: string[] = []; + return { + lines, + runtime: { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`exit ${code}`); + }, + }, + }; +} diff --git a/src/crestodian/crestodian.test.ts b/src/crestodian/crestodian.test.ts index af836f0094f..8fe5a5f89f2 100644 --- a/src/crestodian/crestodian.test.ts +++ b/src/crestodian/crestodian.test.ts @@ -1,25 +1,49 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runCrestodian } from "./crestodian.js"; +import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; -function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { - const lines: string[] = []; - return { - lines, - runtime: { - log: (...args) => lines.push(args.join(" ")), - error: (...args) => lines.push(args.join(" ")), - exit: (code) => { - throw new Error(`exit ${code}`); - }, +vi.mock("./probes.js", () => ({ + probeLocalCommand: vi.fn(async (command: string) => ({ + command, + found: false, + error: "not found", + })), + probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), +})); + +vi.mock("./overview.js", () => ({ + formatCrestodianOverview: () => "Default model: openai/gpt-5.5", + loadCrestodianOverview: vi.fn(async () => ({ + defaultAgentId: "main", + defaultModel: "openai/gpt-5.5", + agents: [{ id: "main", isDefault: true, model: "openai/gpt-5.5" }], + config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, + tools: { + codex: { command: "codex", found: false, error: "not found" }, + claude: { command: "claude", found: false, error: "not found" }, + apiKeys: { openai: true, anthropic: false }, }, - }; -} + gateway: { + url: "ws://127.0.0.1:18789", + source: "local loopback", + reachable: false, + error: "offline", + }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, + })), +})); describe("runCrestodian", () => { + beforeEach(() => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + }); + afterEach(() => { vi.unstubAllEnvs(); }); @@ -28,7 +52,7 @@ describe("runCrestodian", () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runGatewayRestart = vi.fn(async () => {}); await runCrestodian( @@ -54,7 +78,7 @@ describe("runCrestodian", () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-deterministic-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const planner = vi.fn(async () => ({ command: "restart gateway" })); await runCrestodian( @@ -73,7 +97,7 @@ describe("runCrestodian", () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-tui-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runInteractiveTui = vi.fn(async () => {}); await runCrestodian( diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index a07bd274163..9562ebd17e3 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -8,7 +8,11 @@ import { type CrestodianCommandDeps, } from "./operations.js"; import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js"; -import { runCrestodianTui } from "./tui-backend.js"; + +type CrestodianInteractiveRunner = ( + opts: RunCrestodianOptions, + runtime: RuntimeEnv, +) => Promise; export type RunCrestodianOptions = { message?: string; @@ -19,7 +23,7 @@ export type RunCrestodianOptions = { planWithAssistant?: CrestodianAssistantPlanner; input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; - runInteractiveTui?: typeof runCrestodianTui; + runInteractiveTui?: CrestodianInteractiveRunner; }; async function runOneShot( @@ -62,6 +66,7 @@ export async function runCrestodian( return; } - const runInteractiveTui = opts.runInteractiveTui ?? runCrestodianTui; + const runInteractiveTui = + opts.runInteractiveTui ?? (await import("./tui-backend.js")).runCrestodianTui; await runInteractiveTui(opts, runtime); } diff --git a/src/crestodian/dialogue.ts b/src/crestodian/dialogue.ts index 4c607946667..b0c74c6c35a 100644 --- a/src/crestodian/dialogue.ts +++ b/src/crestodian/dialogue.ts @@ -1,15 +1,11 @@ import type { RuntimeEnv } from "../runtime.js"; -import { - planCrestodianCommand, - type CrestodianAssistantPlan, - type CrestodianAssistantPlanner, -} from "./assistant.js"; +import type { CrestodianAssistantPlan, CrestodianAssistantPlanner } from "./assistant.js"; import { describeCrestodianPersistentOperation, parseCrestodianOperation, type CrestodianOperation, } from "./operations.js"; -import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js"; +import type { CrestodianOverview } from "./overview.js"; export type CrestodianDialogueOptions = { planWithAssistant?: CrestodianAssistantPlanner; @@ -32,8 +28,9 @@ export async function resolveCrestodianOperation( if (!shouldAskAssistant(input, operation)) { return operation; } + const { loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); - const planner = opts.planWithAssistant ?? planCrestodianCommand; + const planner = opts.planWithAssistant ?? (await import("./assistant.js")).planCrestodianCommand; const plan = await planner({ input, overview }); if (!plan) { return operation; diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 41175aff67a..e58a80b0d79 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -1,29 +1,159 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; import { executeCrestodianOperation, parseCrestodianOperation, type CrestodianOperationResult, } from "./operations.js"; -function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { - const lines: string[] = []; +type TestConfig = Record; + +const mockConfig = vi.hoisted(() => { + const initial = {}; + const state = { + path: "/tmp/openclaw.json", + exists: true, + config: initial as TestConfig, + hash: "mock-hash-0" as string | undefined, + }; + const cloneConfig = () => structuredClone(state.config); + const snapshot = () => { + const config = cloneConfig(); + return { + path: state.path, + exists: state.exists, + raw: state.exists ? `${JSON.stringify(config)}\n` : null, + parsed: state.exists ? config : undefined, + sourceConfig: config, + resolved: config, + valid: state.exists, + runtimeConfig: config, + config, + hash: state.hash, + issues: state.exists ? [] : [{ path: "", message: "missing config" }], + warnings: [], + legacyIssues: [], + }; + }; return { - lines, - runtime: { - log: (...args) => lines.push(args.join(" ")), - error: (...args) => lines.push(args.join(" ")), - exit: (code) => { - throw new Error(`exit ${code}`); + reset() { + state.path = "/tmp/openclaw.json"; + state.exists = true; + state.config = {}; + state.hash = "mock-hash-0"; + }, + missing(path: string) { + state.path = path; + state.exists = false; + state.config = {}; + state.hash = undefined; + }, + currentConfig() { + return cloneConfig(); + }, + readConfigFileSnapshot: vi.fn(async () => snapshot()), + mutateConfigFile: vi.fn( + async (params: { + mutate: ( + draft: TestConfig, + context: { snapshot: ReturnType }, + ) => Promise | void; + }) => { + const before = snapshot(); + const draft = cloneConfig(); + await params.mutate(draft, { snapshot: before }); + state.exists = true; + state.config = draft; + state.hash = "mock-hash-1"; + return { + path: state.path, + previousHash: before.hash ?? null, + snapshot: before, + nextConfig: cloneConfig(), + result: undefined, + }; + }, + ), + }; +}); + +vi.mock("./probes.js", () => ({ + probeLocalCommand: vi.fn(async (command: string) => ({ + command, + found: false, + error: "not found", + })), + probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), +})); + +vi.mock("./overview.js", () => ({ + formatCrestodianOverview: () => "Default model: openai/gpt-5.5", + loadCrestodianOverview: vi.fn(async () => ({ + defaultAgentId: "main", + defaultModel: undefined, + agents: [ + { id: "main", isDefault: true }, + { id: "work", isDefault: false, model: "openai/gpt-5.2" }, + ], + config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, + tools: { + codex: { command: "codex", found: false, error: "not found" }, + claude: { command: "claude", found: false, error: "not found" }, + apiKeys: { openai: true, anthropic: false }, + }, + gateway: { + url: "ws://127.0.0.1:18789", + source: "local loopback", + reachable: false, + error: "offline", + }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, + })), +})); + +vi.mock("../config/config.js", () => ({ + mutateConfigFile: mockConfig.mutateConfigFile, + readConfigFileSnapshot: mockConfig.readConfigFileSnapshot, +})); + +vi.mock("../commands/models/shared.js", () => ({ + applyDefaultModelPrimaryUpdate: ({ + cfg, + modelRaw, + field, + }: { + cfg: TestConfig; + modelRaw: string; + field: "model" | "imageModel"; + }) => ({ + ...cfg, + agents: { + ...(cfg.agents as TestConfig | undefined), + defaults: { + ...(cfg.agents as { defaults?: TestConfig } | undefined)?.defaults, + [field]: { primary: modelRaw }, }, }, - }; -} + }), +})); + +vi.mock("../config/model-input.js", () => ({ + resolveAgentModelPrimaryValue: (model?: string | { primary?: string }) => + typeof model === "string" ? model : model?.primary, +})); describe("parseCrestodianOperation", () => { + beforeEach(() => { + mockConfig.reset(); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + }); + afterEach(() => { vi.unstubAllEnvs(); }); @@ -100,7 +230,7 @@ describe("parseCrestodianOperation", () => { }); it("requires approval before restarting gateway", async () => { - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runGatewayRestart = vi.fn(async () => {}); const result = await executeCrestodianOperation({ kind: "gateway-restart" }, runtime, { @@ -115,84 +245,9 @@ describe("parseCrestodianOperation", () => { expect(runGatewayRestart).not.toHaveBeenCalled(); }); - it("restarts gateway through typed deps and writes an audit entry", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-gateway-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - const { runtime, lines } = createRuntime(); - const runGatewayRestart = vi.fn(async () => {}); - - await expect( - executeCrestodianOperation({ kind: "gateway-restart" }, runtime, { - approved: true, - deps: { runGatewayRestart }, - auditDetails: { rescue: true, channel: "whatsapp" }, - }), - ).resolves.toMatchObject({ applied: true }); - - expect(runGatewayRestart).toHaveBeenCalledTimes(1); - expect(lines.join("\n")).toContain("[crestodian] done: gateway.restart"); - const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); - const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); - expect(audit).toMatchObject({ - operation: "gateway.restart", - summary: "Restarted Gateway", - details: { rescue: true, channel: "whatsapp" }, - }); - }); - - it("creates agents through typed deps and writes an audit entry", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-agent-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - const { runtime, lines } = createRuntime(); - const runAgentsAdd = vi.fn(async () => {}); - - await expect( - executeCrestodianOperation( - { - kind: "create-agent", - agentId: "work", - workspace: "/tmp/work", - model: "openai/gpt-5.2", - }, - runtime, - { - approved: true, - deps: { runAgentsAdd }, - auditDetails: { rescue: true, channel: "whatsapp" }, - }, - ), - ).resolves.toMatchObject({ applied: true }); - - expect(runAgentsAdd).toHaveBeenCalledWith( - { - name: "work", - workspace: "/tmp/work", - model: "openai/gpt-5.2", - nonInteractive: true, - }, - runtime, - { hasFlags: true }, - ); - expect(lines.join("\n")).toContain("[crestodian] done: agents.create"); - const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); - const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); - expect(audit).toMatchObject({ - operation: "agents.create", - summary: "Created agent work", - details: { - rescue: true, - channel: "whatsapp", - agentId: "work", - workspace: "/tmp/work", - model: "openai/gpt-5.2", - }, - }); - }); - it("validates missing config without exiting the process", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-validate-")); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + mockConfig.missing("/tmp/openclaw.json"); + const { runtime, lines } = createCrestodianTestRuntime(); await expect( executeCrestodianOperation({ kind: "config-validate" }, runtime), @@ -204,8 +259,7 @@ describe("parseCrestodianOperation", () => { it("applies config set through typed deps and writes an audit entry", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-config-set-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runConfigSet = vi.fn(async () => {}); await expect( @@ -242,8 +296,7 @@ describe("parseCrestodianOperation", () => { it("applies SecretRef config set through typed deps and writes an audit entry", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-config-ref-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runConfigSet = vi.fn(async () => {}); await expect( @@ -290,9 +343,8 @@ describe("parseCrestodianOperation", () => { it("runs setup bootstrap only after approval and audits it", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-setup-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); vi.stubEnv("OPENAI_API_KEY", "test-key"); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const plan = await executeCrestodianOperation( { kind: "setup", workspace: "/tmp/work" }, @@ -311,10 +363,7 @@ describe("parseCrestodianOperation", () => { ).resolves.toMatchObject({ applied: true }); expect(lines.join("\n")).toContain("[crestodian] done: crestodian.setup"); - const config = JSON.parse( - await fs.readFile(path.join(tempDir, "openclaw.json"), "utf8"), - ) as Record; - expect(config).toMatchObject({ + expect(mockConfig.currentConfig()).toMatchObject({ agents: { defaults: { workspace: "/tmp/work", @@ -339,8 +388,7 @@ describe("parseCrestodianOperation", () => { it("runs doctor repairs only after approval and audits them", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-doctor-fix-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runDoctor = vi.fn(async () => {}); const plan = await executeCrestodianOperation({ kind: "doctor-fix" }, runtime, { @@ -376,23 +424,7 @@ describe("parseCrestodianOperation", () => { }); it("returns from the agent TUI back to Crestodian", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-tui-return-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); - await fs.writeFile( - path.join(tempDir, "openclaw.json"), - JSON.stringify( - { - agents: { - defaults: { model: { primary: "openai/gpt-5.2" } }, - list: [{ id: "main", default: true }, { id: "work" }], - }, - }, - null, - 2, - ), - ); - const { runtime, lines } = createRuntime(); + const { runtime, lines } = createCrestodianTestRuntime(); const runTui = vi.fn(async () => ({ exitReason: "return-to-crestodian" as const, crestodianMessage: "restart gateway", diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index a495c298513..588d53ac61b 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -1,18 +1,15 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import type { ConfigSetOptions } from "../cli/config-set-input.js"; -import { doctorCommand } from "../commands/doctor.js"; import type { DoctorOptions } from "../commands/doctor.types.js"; -import { healthCommand } from "../commands/health.js"; -import { applyDefaultModelPrimaryUpdate } from "../commands/models/shared.js"; -import { statusCommand } from "../commands/status.command.js"; -import { mutateConfigFile, readConfigFileSnapshot } from "../config/config.js"; -import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import type { TuiResult } from "../tui/tui-types.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { appendCrestodianAuditEntry, resolveCrestodianAuditPath } from "./audit.js"; -import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js"; +import type { CrestodianOverview } from "./overview.js"; + +type ConfigModule = typeof import("../config/config.js"); +type ConfigFileSnapshot = Awaited>; export type CrestodianOperation = | { kind: "none"; message: string } @@ -294,9 +291,12 @@ function formatSetupPlanDescription( } function chooseSetupModel( - overview: Awaited>, + overview: CrestodianOverview, requestedModel: string | undefined, -): { model?: string; source: string } { +): { + model?: string; + source: string; +} { if (requestedModel?.trim()) { return { model: requestedModel.trim(), source: "requested" }; } @@ -323,9 +323,7 @@ function logQueued(runtime: RuntimeEnv, operation: string): void { runtime.log(`[crestodian] running: ${operation}`); } -function formatGatewayStatusLine( - overview: Awaited>, -): string { +function formatGatewayStatusLine(overview: CrestodianOverview): string { return [ `Gateway: ${overview.gateway.reachable ? "reachable" : "not reachable"}`, `URL: ${overview.gateway.url}`, @@ -349,9 +347,20 @@ async function runGatewayLifecycle(operation: "start" | "stop" | "restart"): Pro await lifecycle.runDaemonRestart(); } -function formatConfigValidationLine( - snapshot: Awaited>, -): string { +async function readConfigFileSnapshotLazy(): Promise { + const { readConfigFileSnapshot } = await import("../config/config.js"); + return await readConfigFileSnapshot(); +} + +async function loadConfigFileMutationHelpers(): Promise<{ + mutateConfigFile: ConfigModule["mutateConfigFile"]; + readConfigFileSnapshot: ConfigModule["readConfigFileSnapshot"]; +}> { + const { mutateConfigFile, readConfigFileSnapshot } = await import("../config/config.js"); + return { mutateConfigFile, readConfigFileSnapshot }; +} + +function formatConfigValidationLine(snapshot: ConfigFileSnapshot): string { if (!snapshot.exists) { return `Config missing: ${shortenHomePath(snapshot.path)}`; } @@ -380,6 +389,7 @@ async function resolveTuiAgentId(params: { requestedAgentId: string | undefined; requestedWorkspace?: string; }): Promise { + const { loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); const workspace = params.requestedWorkspace ? resolveUserPath(params.requestedWorkspace) @@ -419,11 +429,13 @@ export async function executeCrestodianOperation( return { applied: false, exitsInteractive: operation.message.includes("Bye.") }; } if (operation.kind === "overview") { + const { formatCrestodianOverview, loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); runtime.log(formatCrestodianOverview(overview)); return { applied: false }; } if (operation.kind === "agents") { + const { loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); runtime.log( [ @@ -444,6 +456,7 @@ export async function executeCrestodianOperation( return { applied: false }; } if (operation.kind === "models") { + const { loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); runtime.log( [ @@ -462,11 +475,12 @@ export async function executeCrestodianOperation( return { applied: false }; } if (operation.kind === "config-validate") { - const snapshot = await readConfigFileSnapshot(); + const snapshot = await readConfigFileSnapshotLazy(); runtime.log(formatConfigValidationLine(snapshot)); return { applied: false }; } if (operation.kind === "setup") { + const { loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); const setupModel = chooseSetupModel(overview, operation.model); if (!opts.approved) { @@ -482,13 +496,17 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "crestodian.setup"); + const { mutateConfigFile, readConfigFileSnapshot } = await loadConfigFileMutationHelpers(); const before = await readConfigFileSnapshot(); const workspace = resolveUserPath(operation.workspace ?? process.cwd()); + const applyDefaultModelPrimaryUpdate = setupModel.model + ? (await import("../commands/models/shared.js")).applyDefaultModelPrimaryUpdate + : undefined; const result = await mutateConfigFile({ base: "source", mutate: (cfg) => { let next = cfg; - if (setupModel.model) { + if (setupModel.model && applyDefaultModelPrimaryUpdate) { next = applyDefaultModelPrimaryUpdate({ cfg: next, modelRaw: setupModel.model, @@ -543,6 +561,7 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "config.set"); + const { readConfigFileSnapshot } = await import("../config/config.js"); const before = await readConfigFileSnapshot(); const runConfigSet = opts.deps?.runConfigSet ?? @@ -580,6 +599,7 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "config.setRef"); + const { readConfigFileSnapshot } = await import("../config/config.js"); const before = await readConfigFileSnapshot(); const runConfigSet = opts.deps?.runConfigSet ?? @@ -622,6 +642,7 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "agents.create"); + const { readConfigFileSnapshot } = await import("../config/config.js"); const before = await readConfigFileSnapshot(); const workspace = resolveUserPath(operation.workspace ?? process.cwd()); const runAgentsAdd = @@ -656,7 +677,7 @@ export async function executeCrestodianOperation( } if (operation.kind === "doctor") { logQueued(runtime, "doctor"); - const runDoctor = opts.deps?.runDoctor ?? doctorCommand; + const runDoctor = opts.deps?.runDoctor ?? (await import("../commands/doctor.js")).doctorCommand; await runDoctor(runtime, { nonInteractive: true }); runtime.log("[crestodian] done: doctor"); return { applied: false }; @@ -668,8 +689,9 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "doctor.fix"); + const { readConfigFileSnapshot } = await import("../config/config.js"); const before = await readConfigFileSnapshot(); - const runDoctor = opts.deps?.runDoctor ?? doctorCommand; + const runDoctor = opts.deps?.runDoctor ?? (await import("../commands/doctor.js")).doctorCommand; await runDoctor(runtime, { nonInteractive: true, repair: true, yes: true }); const after = await readConfigFileSnapshot(); await appendCrestodianAuditEntry({ @@ -685,17 +707,20 @@ export async function executeCrestodianOperation( } if (operation.kind === "status") { logQueued(runtime, "status.check"); + const { statusCommand } = await import("../commands/status.command.js"); await statusCommand({ timeoutMs: 10_000 }, runtime); runtime.log("[crestodian] done: status.check"); return { applied: false }; } if (operation.kind === "health") { logQueued(runtime, "health.check"); + const { healthCommand } = await import("../commands/health.js"); await healthCommand({ timeoutMs: 10_000 }, runtime); runtime.log("[crestodian] done: health.check"); return { applied: false }; } if (operation.kind === "gateway-status") { + const { loadCrestodianOverview } = await import("./overview.js"); const overview = await loadCrestodianOverview(); runtime.log(formatGatewayStatusLine(overview)); return { applied: false }; @@ -781,7 +806,9 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "config.setDefaultModel"); + const { mutateConfigFile, readConfigFileSnapshot } = await loadConfigFileMutationHelpers(); const before = await readConfigFileSnapshot(); + const { applyDefaultModelPrimaryUpdate } = await import("../commands/models/shared.js"); const result = await mutateConfigFile({ base: "source", mutate: (cfg) => { @@ -794,6 +821,8 @@ export async function executeCrestodianOperation( }, }); const after = await readConfigFileSnapshot(); + const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js"); + const effectiveModel = resolveAgentModelPrimaryValue(result.nextConfig.agents?.defaults?.model); await appendCrestodianAuditEntry({ operation: "config.setDefaultModel", summary: `Set default model to ${operation.model}`, @@ -803,13 +832,11 @@ export async function executeCrestodianOperation( details: { ...opts.auditDetails, requestedModel: operation.model, - effectiveModel: resolveAgentModelPrimaryValue(result.nextConfig.agents?.defaults?.model), + effectiveModel, }, }); runtime.log(`Updated ${result.path}`); - runtime.log( - `Default model: ${resolveAgentModelPrimaryValue(result.nextConfig.agents?.defaults?.model) ?? operation.model}`, - ); + runtime.log(`Default model: ${effectiveModel ?? operation.model}`); runtime.log("[crestodian] done: config.setDefaultModel"); return { applied: true }; } diff --git a/src/crestodian/overview.test.ts b/src/crestodian/overview.test.ts index e5ff2f99d37..06a685d8f29 100644 --- a/src/crestodian/overview.test.ts +++ b/src/crestodian/overview.test.ts @@ -1,8 +1,4 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetConfigRuntimeState } from "../config/config.js"; vi.mock("./probes.js", () => ({ probeLocalCommand: vi.fn(async (command: string) => ({ @@ -13,17 +9,40 @@ vi.mock("./probes.js", () => ({ probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), })); +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: vi.fn(async () => ({ + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + hash: "test-hash", + runtimeConfig: { + agents: { + defaults: { model: { primary: "openai/gpt-5.2" } }, + list: [ + { id: "main", default: true }, + { id: "work", name: "Work" }, + ], + }, + gateway: { port: 19001 }, + }, + sourceConfig: undefined, + })), + resolveConfigPath: vi.fn(() => "/tmp/openclaw.json"), + resolveGatewayPort: vi.fn((cfg: { gateway?: { port?: number } }) => cfg.gateway?.port ?? 8765), +})); + +vi.mock("../gateway/call.js", () => ({ + buildGatewayConnectionDetails: vi.fn((input: { config: { gateway?: { port?: number } } }) => ({ + url: `ws://127.0.0.1:${input.config.gateway?.port ?? 8765}`, + urlSource: "local loopback", + })), +})); + describe("loadCrestodianOverview", () => { - const previousStateDir = process.env.OPENCLAW_STATE_DIR; const previousTestFast = process.env.OPENCLAW_TEST_FAST; afterEach(() => { - resetConfigRuntimeState(); - if (previousStateDir === undefined) { - delete process.env.OPENCLAW_STATE_DIR; - } else { - process.env.OPENCLAW_STATE_DIR = previousStateDir; - } if (previousTestFast === undefined) { delete process.env.OPENCLAW_TEST_FAST; } else { @@ -32,26 +51,7 @@ describe("loadCrestodianOverview", () => { }); it("summarizes config, agents, model, tools, and gateway", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-overview-")); - vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - await fs.writeFile( - path.join(tempDir, "openclaw.json"), - `${JSON.stringify( - { - agents: { - defaults: { model: { primary: "openai/gpt-5.2" } }, - list: [ - { id: "main", default: true }, - { id: "work", name: "Work" }, - ], - }, - gateway: { port: 19001 }, - }, - null, - 2, - )}\n`, - ); const { formatCrestodianOverview, formatCrestodianStartupMessage, loadCrestodianOverview } = await import("./overview.js"); diff --git a/src/crestodian/overview.ts b/src/crestodian/overview.ts index 02218b24814..a5f17f2ba09 100644 --- a/src/crestodian/overview.ts +++ b/src/crestodian/overview.ts @@ -16,7 +16,6 @@ import { type OpenClawConfig, } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; -import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { probeGatewayUrl, probeLocalCommand, type LocalCommandProbe } from "./probes.js"; @@ -61,6 +60,8 @@ export type CrestodianOverview = { }; }; +type OpenClawReferencePaths = Awaited>; + function issueMessages(snapshot: ConfigFileSnapshot): string[] { return snapshot.issues.map((issue) => { const path = issue.path ? `${issue.path}: ` : ""; @@ -107,6 +108,17 @@ function buildAgentSummaries(cfg: OpenClawConfig): CrestodianAgentSummary[] { return summaries; } +function resolveFastTestReferences(env: NodeJS.ProcessEnv): OpenClawReferencePaths | undefined { + if (env.OPENCLAW_TEST_FAST !== "1") { + return undefined; + } + const sourcePath = process.cwd(); + return { + sourcePath, + docsPath: `${sourcePath}/docs`, + }; +} + export async function loadCrestodianOverview( opts: { env?: NodeJS.ProcessEnv } = {}, ): Promise { @@ -122,6 +134,7 @@ export async function loadCrestodianOverview( let gatewaySource = "local loopback"; let gatewayError: string | undefined; try { + const { buildGatewayConnectionDetails } = await import("../gateway/call.js"); const details = buildGatewayConnectionDetails({ config: cfg, configPath }); gatewayUrl = details.url; gatewaySource = details.urlSource; @@ -133,11 +146,12 @@ export async function loadCrestodianOverview( probeLocalCommand("codex"), probeLocalCommand("claude"), probeGatewayUrl(gatewayUrl), - resolveOpenClawReferencePaths({ - argv1: process.argv[1], - cwd: process.cwd(), - moduleUrl: import.meta.url, - }), + resolveFastTestReferences(env) ?? + resolveOpenClawReferencePaths({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url, + }), ]); return { config: { diff --git a/src/crestodian/rescue-message.test.ts b/src/crestodian/rescue-message.test.ts index df748cea547..4545040f508 100644 --- a/src/crestodian/rescue-message.test.ts +++ b/src/crestodian/rescue-message.test.ts @@ -1,14 +1,105 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CommandContext } from "../auto-reply/reply/commands-types.js"; -import { clearConfigCache } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { extractCrestodianRescueMessage, runCrestodianRescueMessage } from "./rescue-message.js"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; -const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; + +type TestConfig = Record; + +const mockConfig = vi.hoisted(() => { + const state = { + path: "/tmp/openclaw.json", + config: {} as TestConfig, + hash: "mock-hash-0" as string | undefined, + }; + const cloneConfig = () => structuredClone(state.config); + const snapshot = () => { + const config = cloneConfig(); + return { + path: state.path, + exists: true, + raw: `${JSON.stringify(config)}\n`, + parsed: config, + sourceConfig: config, + resolved: config, + valid: true, + runtimeConfig: config, + config, + hash: state.hash, + issues: [], + warnings: [], + legacyIssues: [], + }; + }; + return { + reset() { + state.path = "/tmp/openclaw.json"; + state.config = {}; + state.hash = "mock-hash-0"; + }, + currentConfig() { + return cloneConfig(); + }, + readConfigFileSnapshot: vi.fn(async () => snapshot()), + mutateConfigFile: vi.fn( + async (params: { + mutate: ( + draft: TestConfig, + context: { snapshot: ReturnType }, + ) => Promise | void; + }) => { + const before = snapshot(); + const draft = cloneConfig(); + await params.mutate(draft, { snapshot: before }); + state.config = draft; + state.hash = "mock-hash-1"; + return { + path: state.path, + previousHash: before.hash ?? null, + snapshot: before, + nextConfig: cloneConfig(), + result: undefined, + }; + }, + ), + }; +}); + +vi.mock("../config/config.js", () => ({ + clearConfigCache: vi.fn(), + mutateConfigFile: mockConfig.mutateConfigFile, + readConfigFileSnapshot: mockConfig.readConfigFileSnapshot, +})); + +vi.mock("../commands/models/shared.js", () => ({ + applyDefaultModelPrimaryUpdate: ({ + cfg, + modelRaw, + field, + }: { + cfg: TestConfig; + modelRaw: string; + field: "model" | "imageModel"; + }) => ({ + ...cfg, + agents: { + ...(cfg.agents as TestConfig | undefined), + defaults: { + ...(cfg.agents as { defaults?: TestConfig } | undefined)?.defaults, + [field]: { primary: modelRaw }, + }, + }, + }), +})); + +vi.mock("../config/model-input.js", () => ({ + resolveAgentModelPrimaryValue: (model?: string | { primary?: string }) => + typeof model === "string" ? model : model?.primary, +})); function commandContext(overrides: Partial = {}): CommandContext { return { @@ -43,18 +134,16 @@ async function runRescue( } describe("Crestodian rescue message", () => { + beforeEach(() => { + mockConfig.reset(); + }); + afterEach(() => { - clearConfigCache(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = originalStateDir; } - if (originalConfigPath === undefined) { - delete process.env.OPENCLAW_CONFIG_PATH; - } else { - process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; - } }); it("recognizes the Crestodian rescue command", () => { @@ -91,20 +180,7 @@ describe("Crestodian rescue message", () => { it("queues and applies persistent writes through conversational approval", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-rescue-")); - const configPath = path.join(tempDir, "openclaw.json"); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); - vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); - await fs.writeFile( - configPath, - JSON.stringify( - { - meta: { lastTouchedVersion: "test", lastTouchedAt: new Date(0).toISOString() }, - agents: { defaults: {} }, - }, - null, - 2, - ), - ); const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; await expect(runRescue("/crestodian set default model openai/gpt-5.2", cfg)).resolves.toContain( @@ -114,8 +190,9 @@ describe("Crestodian rescue message", () => { "Default model: openai/gpt-5.2", ); - const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; - expect(config.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.2" }); + expect(mockConfig.currentConfig()).toMatchObject({ + agents: { defaults: { model: { primary: "openai/gpt-5.2" } } }, + }); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); expect(audit.details).toMatchObject({ @@ -167,6 +244,15 @@ describe("Crestodian rescue message", () => { ); expect(deps.runAgentsAdd).toHaveBeenCalledTimes(1); + expect(deps.runAgentsAdd).toHaveBeenCalledWith( + { + name: "work", + workspace: "/tmp/work", + nonInteractive: true, + }, + expect.any(Object), + { hasFlags: true }, + ); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); expect(audit).toMatchObject({ diff --git a/src/crestodian/tui-backend.test.ts b/src/crestodian/tui-backend.test.ts index b3676464e03..e28f15d6060 100644 --- a/src/crestodian/tui-backend.test.ts +++ b/src/crestodian/tui-backend.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; const mocks = vi.hoisted(() => ({ @@ -12,6 +12,40 @@ vi.mock("../tui/tui.js", () => ({ runTui: mocks.runTui, })); +vi.mock("./probes.js", () => ({ + probeLocalCommand: vi.fn(async (command: string) => ({ + command, + found: false, + error: "not found", + })), + probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), +})); + +vi.mock("./overview.js", () => ({ + formatCrestodianOverview: () => "Default model: openai/gpt-5.5", + loadCrestodianOverview: vi.fn(async () => ({ + defaultAgentId: "main", + defaultModel: "openai/gpt-5.5", + agents: [{ id: "main", isDefault: true, model: "openai/gpt-5.5" }], + config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, + tools: { + codex: { command: "codex", found: false, error: "not found" }, + claude: { command: "claude", found: false, error: "not found" }, + apiKeys: { openai: true, anthropic: false }, + }, + gateway: { + url: "ws://127.0.0.1:18789", + source: "local loopback", + reachable: false, + error: "offline", + }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, + })), +})); + import { runCrestodianTui } from "./tui-backend.js"; function createRuntime(): RuntimeEnv { @@ -25,6 +59,10 @@ function createRuntime(): RuntimeEnv { } describe("runCrestodianTui", () => { + beforeEach(() => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + }); + afterEach(() => { vi.unstubAllEnvs(); mocks.runTui.mockClear();