diff --git a/src/agents/skills/bundled-context.ts b/src/agents/skills/bundled-context.ts index 091f62caba..bc9f830954 100644 --- a/src/agents/skills/bundled-context.ts +++ b/src/agents/skills/bundled-context.ts @@ -4,6 +4,7 @@ import { resolveBundledSkillsDir, type BundledSkillsResolveOptions } from "./bun const skillsLogger = createSubsystemLogger("skills"); let hasWarnedMissingBundledDir = false; +let cachedBundledContext: { dir: string; names: Set } | null = null; export type BundledSkillsContext = { dir?: string; @@ -24,11 +25,16 @@ export function resolveBundledSkillsContext( } return { dir, names }; } + + if (cachedBundledContext?.dir === dir) { + return { dir, names: new Set(cachedBundledContext.names) }; + } const result = loadSkillsFromDir({ dir, source: "openclaw-bundled" }); for (const skill of result.skills) { if (skill.name.trim()) { names.add(skill.name); } } + cachedBundledContext = { dir, names: new Set(names) }; return { dir, names }; } diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index fa0729ae57..c105d0f4d4 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { buildWorkspaceSkillStatus, type SkillStatusEntry, @@ -214,6 +215,18 @@ describe("skills-cli", () => { }); describe("integration: loads real skills from bundled directory", () => { + let tempWorkspaceDir = ""; + + beforeAll(() => { + tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skills-test-")); + }); + + afterAll(() => { + if (tempWorkspaceDir) { + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + } + }); + function resolveBundledSkillsDir(): string | undefined { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(moduleDir, "..", ".."); @@ -231,7 +244,7 @@ describe("skills-cli", () => { return; } - const report = buildWorkspaceSkillStatus("/tmp", { + const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { managedSkillsDir: "/nonexistent", }); @@ -257,7 +270,7 @@ describe("skills-cli", () => { return; } - const report = buildWorkspaceSkillStatus("/tmp", { + const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { managedSkillsDir: "/nonexistent", }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index aa77174127..2ca137835b 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -9,6 +9,10 @@ const select = vi.fn(); const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })); const isCancel = (value: unknown) => value === "cancel"; +const readPackageName = vi.fn(); +const readPackageVersion = vi.fn(); +const resolveGlobalManager = vi.fn(); + vi.mock("@clack/prompts", () => ({ confirm, select, @@ -61,6 +65,16 @@ vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: vi.fn(), })); +vi.mock("./update-cli/shared.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readPackageName, + readPackageVersion, + resolveGlobalManager, + }; +}); + // Mock doctor (heavy module; should not run in unit tests) vi.mock("../commands/doctor.js", () => ({ doctorCommand: vi.fn(), @@ -129,7 +143,23 @@ describe("update-cli", () => { }; beforeEach(() => { - vi.clearAllMocks(); + confirm.mockReset(); + select.mockReset(); + vi.mocked(runGatewayUpdate).mockReset(); + vi.mocked(resolveOpenClawPackageRoot).mockReset(); + vi.mocked(readConfigFileSnapshot).mockReset(); + vi.mocked(writeConfigFile).mockReset(); + vi.mocked(checkUpdateStatus).mockReset(); + vi.mocked(fetchNpmTagVersion).mockReset(); + vi.mocked(resolveNpmChannelTag).mockReset(); + vi.mocked(runCommandWithTimeout).mockReset(); + vi.mocked(runDaemonRestart).mockReset(); + vi.mocked(defaultRuntime.log).mockReset(); + vi.mocked(defaultRuntime.error).mockReset(); + vi.mocked(defaultRuntime.exit).mockReset(); + readPackageName.mockReset(); + readPackageVersion.mockReset(); + resolveGlobalManager.mockReset(); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(fetchNpmTagVersion).mockResolvedValue({ @@ -172,6 +202,9 @@ describe("update-cli", () => { signal: null, killed: false, }); + readPackageName.mockResolvedValue("openclaw"); + readPackageVersion.mockResolvedValue("1.0.0"); + resolveGlobalManager.mockResolvedValue("npm"); setTty(false); setStdoutTty(false); }); @@ -241,11 +274,6 @@ describe("update-cli", () => { it("defaults to stable channel for package installs when unset", async () => { const tempDir = await createCaseDir("openclaw-update"); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0" }), - "utf-8", - ); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(checkUpdateStatus).mockResolvedValue({ @@ -293,11 +321,6 @@ describe("update-cli", () => { it("falls back to latest when beta tag is older than release", async () => { const tempDir = await createCaseDir("openclaw-update"); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0" }), - "utf-8", - ); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(readConfigFileSnapshot).mockResolvedValue({ @@ -335,11 +358,6 @@ describe("update-cli", () => { it("honors --tag override", async () => { const tempDir = await createCaseDir("openclaw-update"); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "1.0.0" }), - "utf-8", - ); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(runGatewayUpdate).mockResolvedValue({ @@ -478,11 +496,7 @@ describe("update-cli", () => { it("requires confirmation on downgrade when non-interactive", async () => { const tempDir = await createCaseDir("openclaw-update"); setTty(false); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); + readPackageVersion.mockResolvedValue("2.0.0"); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(checkUpdateStatus).mockResolvedValue({ @@ -520,11 +534,7 @@ describe("update-cli", () => { it("allows downgrade with --yes in non-interactive mode", async () => { const tempDir = await createCaseDir("openclaw-update"); setTty(false); - await fs.writeFile( - path.join(tempDir, "package.json"), - JSON.stringify({ name: "openclaw", version: "2.0.0" }), - "utf-8", - ); + readPackageVersion.mockResolvedValue("2.0.0"); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(checkUpdateStatus).mockResolvedValue({ diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 5f94d68af6..b49086e5bf 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -75,9 +75,9 @@ describe("memory index", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], @@ -114,7 +114,7 @@ describe("memory index", () => { provider: "openai", store: { path: indexPath }, sync: { watch: false, onSessionStart: false, onSearch: true }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], @@ -182,7 +182,7 @@ describe("memory index", () => { model: "mock-embed", store: { path: indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, cache: { enabled: true }, }, }, @@ -276,8 +276,9 @@ describe("memory index", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: true }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], @@ -304,8 +305,9 @@ describe("memory index", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: true }, + query: { minScore: 0, hybrid: { enabled: false } }, extraPaths: [extraDir], }, }, diff --git a/src/memory/manager.embedding-batches.test.ts b/src/memory/manager.embedding-batches.test.ts index d4e2d04ed6..4b530602d7 100644 --- a/src/memory/manager.embedding-batches.test.ts +++ b/src/memory/manager.embedding-batches.test.ts @@ -67,10 +67,10 @@ describe("memory embedding batches", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, chunking: { tokens: 1250, overlap: 0 }, sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], @@ -114,10 +114,10 @@ describe("memory embedding batches", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, chunking: { tokens: 200, overlap: 0 }, sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], @@ -174,10 +174,10 @@ describe("memory embedding batches", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, chunking: { tokens: 200, overlap: 0 }, sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], @@ -209,9 +209,9 @@ describe("memory embedding batches", () => { memorySearch: { provider: "openai", model: "mock-embed", - store: { path: indexPath }, + store: { path: indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0 }, + query: { minScore: 0, hybrid: { enabled: false } }, }, }, list: [{ id: "main", default: true }], diff --git a/src/shared/config-eval.ts b/src/shared/config-eval.ts index 60b48c3019..c1e4f6926a 100644 --- a/src/shared/config-eval.ts +++ b/src/shared/config-eval.ts @@ -52,8 +52,22 @@ function windowsPathExtensions(): string[] { return ["", ...list.filter(Boolean)]; } +let cachedHasBinaryPath: string | undefined; +let cachedHasBinaryPathExt: string | undefined; +const hasBinaryCache = new Map(); + export function hasBinary(bin: string): boolean { const pathEnv = process.env.PATH ?? ""; + const pathExt = process.platform === "win32" ? (process.env.PATHEXT ?? "") : ""; + if (cachedHasBinaryPath !== pathEnv || cachedHasBinaryPathExt !== pathExt) { + cachedHasBinaryPath = pathEnv; + cachedHasBinaryPathExt = pathExt; + hasBinaryCache.clear(); + } + if (hasBinaryCache.has(bin)) { + return hasBinaryCache.get(bin)!; + } + const parts = pathEnv.split(path.delimiter).filter(Boolean); const extensions = process.platform === "win32" ? windowsPathExtensions() : [""]; for (const part of parts) { @@ -61,11 +75,13 @@ export function hasBinary(bin: string): boolean { const candidate = path.join(part, bin + ext); try { fs.accessSync(candidate, fs.constants.X_OK); + hasBinaryCache.set(bin, true); return true; } catch { // keep scanning } } } + hasBinaryCache.set(bin, false); return false; }