import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearInternalHooks, registerInternalHook, type AgentBootstrapHookContext, } from "../hooks/internal-hooks.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { resetBootstrapWarningCacheForTest, FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, hasCompletedBootstrapTurn, makeBootstrapWarn, resolveBootstrapContextForRun, resolveBootstrapFilesForRun, resolveContextInjectionMode, } from "./bootstrap-files.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; function registerExtraBootstrapFileHook() { registerInternalHook("agent:bootstrap", (event) => { const context = event.context as AgentBootstrapHookContext; context.bootstrapFiles = [ ...context.bootstrapFiles, { name: "EXTRA.md", path: path.join(context.workspaceDir, "EXTRA.md"), content: "extra", missing: false, } as unknown as WorkspaceBootstrapFile, ]; }); } function registerMalformedBootstrapFileHook() { registerInternalHook("agent:bootstrap", (event) => { const context = event.context as AgentBootstrapHookContext; context.bootstrapFiles = [ ...context.bootstrapFiles, { name: "EXTRA.md", filePath: path.join(context.workspaceDir, "BROKEN.md"), content: "broken", missing: false, } as unknown as WorkspaceBootstrapFile, { name: "EXTRA.md", path: 123, content: "broken", missing: false, } as unknown as WorkspaceBootstrapFile, { name: "EXTRA.md", path: " ", content: "broken", missing: false, } as unknown as WorkspaceBootstrapFile, ]; }); } function registerDuplicateBootstrapFileHook() { registerInternalHook("agent:bootstrap", (event) => { const context = event.context as AgentBootstrapHookContext; context.bootstrapFiles = [ ...context.bootstrapFiles, { name: "AGENTS.md", path: "AGENTS.md", content: "duplicate relative hook content", missing: false, }, { name: "AGENTS.md", path: path.join(context.workspaceDir, ".", "AGENTS.md"), content: "duplicate absolute hook content", missing: false, }, ]; }); } function registerBootstrapFileHook(relativePath = "BOOTSTRAP.md") { registerInternalHook("agent:bootstrap", (event) => { const context = event.context as AgentBootstrapHookContext; context.bootstrapFiles = [ ...context.bootstrapFiles, { name: "BOOTSTRAP.md", path: path.join(context.workspaceDir, relativePath), content: "stale ritual", missing: false, }, ]; }); } async function createHeartbeatAgentsWorkspace() { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "repo rules", "utf8"); return workspaceDir; } function expectHeartbeatExcludedAndAgentsKept(files: WorkspaceBootstrapFile[]) { const fileNames = files.map((file) => file.name); expect(fileNames).not.toContain("HEARTBEAT.md"); expect(fileNames).toContain("AGENTS.md"); } describe("resolveBootstrapFilesForRun", () => { beforeEach(() => clearInternalHooks()); afterEach(() => clearInternalHooks()); it("applies bootstrap hook overrides", async () => { registerExtraBootstrapFileHook(); const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); const filePaths = files.map((file) => file.path); expect(filePaths).toContain(path.join(workspaceDir, "EXTRA.md")); }); it("drops malformed hook files with missing/invalid paths", async () => { registerMalformedBootstrapFileHook(); const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); const warnings: string[] = []; const files = await resolveBootstrapFilesForRun({ workspaceDir, warn: (message) => warnings.push(message), }); expect(files.map((file) => path.relative(workspaceDir, file.path))).toEqual([ "AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md", ]); expect(warnings).toHaveLength(3); expect(warnings[0]).toContain('missing or invalid "path" field'); }); it("dedupes hook-injected bootstrap paths relative to the workspace", async () => { registerDuplicateBootstrapFileHook(); const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); const agentsPath = path.join(workspaceDir, "AGENTS.md"); await fs.writeFile(agentsPath, "workspace rules", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); const agentsFiles = files.filter((file) => file.path === agentsPath); expect(agentsFiles).toHaveLength(1); expect(agentsFiles[0]?.content).toBe("workspace rules"); const context = await resolveBootstrapContextForRun({ workspaceDir }); const agentsContextFiles = context.contextFiles.filter((file) => file.path === agentsPath); expect(agentsContextFiles).toHaveLength(1); expect(agentsContextFiles[0]?.content).toBe("workspace rules"); }); it("ignores stale workspace BOOTSTRAP.md once setup is completed", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true }); await fs.writeFile( path.join(workspaceDir, ".openclaw", "workspace-state.json"), `${JSON.stringify({ version: 1, bootstrapSeededAt: "2026-05-16T00:00:00.000Z", setupCompletedAt: "2026-05-16T00:00:01.000Z", })}\n`, "utf8", ); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); expect(files.map((file) => file.name)).toContain("AGENTS.md"); expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md"); }); it("keeps BOOTSTRAP.md when setup state cannot be read", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.mkdir(path.join(workspaceDir, ".openclaw", "workspace-state.json"), { recursive: true, }); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); expect(files.map((file) => file.name)).toContain("BOOTSTRAP.md"); }); it("does not let hooks re-add stale root BOOTSTRAP.md after setup is completed", async () => { registerBootstrapFileHook(); const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true }); await fs.writeFile( path.join(workspaceDir, ".openclaw", "workspace-state.json"), `${JSON.stringify({ version: 1, bootstrapSeededAt: "2026-05-16T00:00:00.000Z", setupCompletedAt: "2026-05-16T00:00:01.000Z", })}\n`, "utf8", ); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir }); expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md"); }); it("ignores stale root BOOTSTRAP.md for home-relative workspace paths", async () => { registerBootstrapFileHook(); const parentDir = await makeTempWorkspace("openclaw-bootstrap-home-"); const workspaceDir = path.join(parentDir, "workspace"); await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true }); await fs.writeFile( path.join(workspaceDir, ".openclaw", "workspace-state.json"), `${JSON.stringify({ version: 1, bootstrapSeededAt: "2026-05-16T00:00:00.000Z", setupCompletedAt: "2026-05-16T00:00:01.000Z", })}\n`, "utf8", ); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8"); const previousOpenClawHome = process.env.OPENCLAW_HOME; process.env.OPENCLAW_HOME = parentDir; try { const files = await resolveBootstrapFilesForRun({ workspaceDir: "~/workspace" }); expect(files.map((file) => file.name)).toContain("AGENTS.md"); expect(files.map((file) => file.name)).not.toContain("BOOTSTRAP.md"); } finally { if (previousOpenClawHome === undefined) { delete process.env.OPENCLAW_HOME; } else { process.env.OPENCLAW_HOME = previousOpenClawHome; } } }); it("keeps hook-added nested BOOTSTRAP.md after setup is completed", async () => { registerBootstrapFileHook(path.join("packages", "core", "BOOTSTRAP.md")); const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true }); await fs.mkdir(path.join(workspaceDir, "packages", "core"), { recursive: true }); await fs.writeFile( path.join(workspaceDir, ".openclaw", "workspace-state.json"), `${JSON.stringify({ version: 1, bootstrapSeededAt: "2026-05-16T00:00:00.000Z", setupCompletedAt: "2026-05-16T00:00:01.000Z", })}\n`, "utf8", ); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "stale ritual", "utf8"); await fs.writeFile( path.join(workspaceDir, "packages", "core", "BOOTSTRAP.md"), "package ritual", "utf8", ); const files = await resolveBootstrapFilesForRun({ workspaceDir }); expect(files.map((file) => path.relative(workspaceDir, file.path))).toContain( path.join("packages", "core", "BOOTSTRAP.md"), ); expect(files.map((file) => file.path)).not.toContain(path.join(workspaceDir, "BOOTSTRAP.md")); }); }); describe("resolveBootstrapContextForRun", () => { beforeEach(() => clearInternalHooks()); afterEach(() => clearInternalHooks()); it("returns context files for hook-adjusted bootstrap files", async () => { registerExtraBootstrapFileHook(); const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); const result = await resolveBootstrapContextForRun({ workspaceDir }); const extra = result.contextFiles.find( (file) => file.path === path.join(workspaceDir, "EXTRA.md"), ); expect(extra?.content).toBe("extra"); }); it("keeps BOOTSTRAP.md available in shared injected context for non-attempt consumers", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8"); await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8"); const result = await resolveBootstrapContextForRun({ workspaceDir }); const bootstrapFileNames = result.bootstrapFiles.map((file) => file.name); expect(bootstrapFileNames).toContain("BOOTSTRAP.md"); const contextFileNames = new Set(result.contextFiles.map((file) => path.basename(file.path))); expect(contextFileNames.has("BOOTSTRAP.md")).toBe(true); expect(contextFileNames.has("AGENTS.md")).toBe(true); }); it("uses heartbeat-only bootstrap files in lightweight heartbeat mode", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "persona", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir, contextMode: "lightweight", runKind: "heartbeat", }); expect(files.map((file) => file.name)).toStrictEqual(["HEARTBEAT.md"]); expect(files[0]?.content).toBe("check inbox"); }); it("keeps bootstrap context empty in lightweight cron mode", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir, contextMode: "lightweight", runKind: "cron", }); expect(files).toStrictEqual([]); }); it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat prompt section is disabled", async () => { const workspaceDir = await createHeartbeatAgentsWorkspace(); const files = await resolveBootstrapFilesForRun({ workspaceDir, config: { agents: { defaults: { heartbeat: { includeSystemPromptSection: false, }, }, list: [{ id: "main" }], }, }, }); expectHeartbeatExcludedAndAgentsKept(files); }); it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat cadence is disabled", async () => { const workspaceDir = await createHeartbeatAgentsWorkspace(); const files = await resolveBootstrapFilesForRun({ workspaceDir, config: { agents: { defaults: { heartbeat: { every: "0m", }, }, list: [{ id: "main" }], }, }, }); expectHeartbeatExcludedAndAgentsKept(files); }); it("keeps HEARTBEAT.md for actual heartbeat runs even when the prompt section is disabled", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); const files = await resolveBootstrapFilesForRun({ workspaceDir, runKind: "heartbeat", config: { agents: { defaults: { heartbeat: { includeSystemPromptSection: false, }, }, list: [{ id: "main" }], }, }, }); const fileNames = files.map((file) => file.name); expect(fileNames).toContain("HEARTBEAT.md"); }); }); describe("hasCompletedBootstrapTurn", () => { let tmpDir: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(await fs.realpath("/tmp"), "openclaw-bootstrap-turn-")); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); it("returns false when session file does not exist", async () => { expect(await hasCompletedBootstrapTurn(path.join(tmpDir, "missing.jsonl"))).toBe(false); }); it("returns false for empty session files", async () => { const sessionFile = path.join(tmpDir, "empty.jsonl"); await fs.writeFile(sessionFile, "", "utf8"); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); }); it("returns false for header-only session files", async () => { const sessionFile = path.join(tmpDir, "header-only.jsonl"); await fs.writeFile(sessionFile, `${JSON.stringify({ type: "session", id: "s1" })}\n`, "utf8"); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); }); it("returns false when no assistant turn has been flushed yet", async () => { const sessionFile = path.join(tmpDir, "user-only.jsonl"); await fs.writeFile( sessionFile, [ JSON.stringify({ type: "session", id: "s1" }), JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); }); it("returns false for assistant turns without a recorded full bootstrap marker", async () => { const sessionFile = path.join(tmpDir, "assistant-no-marker.jsonl"); await fs.writeFile( sessionFile, [ JSON.stringify({ type: "session", id: "s1" }), JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }), JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); }); it("returns true when a full bootstrap completion marker exists", async () => { const sessionFile = path.join(tmpDir, "full-bootstrap.jsonl"); await fs.writeFile( sessionFile, [ JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }), JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 }, }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); }); it("returns false when compaction happened after the last assistant turn", async () => { const sessionFile = path.join(tmpDir, "post-compaction.jsonl"); await fs.writeFile( sessionFile, [ JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 }, }), JSON.stringify({ type: "compaction", summary: "trimmed" }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); }); it("returns true when a later full bootstrap marker happens after compaction", async () => { const sessionFile = path.join(tmpDir, "assistant-after-compaction.jsonl"); await fs.writeFile( sessionFile, [ JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 }, }), JSON.stringify({ type: "compaction", summary: "trimmed" }), JSON.stringify({ type: "message", message: { role: "user", content: "new ask" } }), JSON.stringify({ type: "message", message: { role: "assistant", content: "new reply" } }), JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 2 }, }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); }); it("ignores malformed JSON lines", async () => { const sessionFile = path.join(tmpDir, "malformed.jsonl"); await fs.writeFile( sessionFile, [ "{broken", JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 }, }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); }); it("finds a recent full bootstrap marker even when the scan starts mid-file", async () => { const sessionFile = path.join(tmpDir, "large-prefix.jsonl"); const hugePrefix = "x".repeat(300 * 1024); await fs.writeFile( sessionFile, [ JSON.stringify({ type: "message", message: { role: "user", content: hugePrefix } }), JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 }, }), ].join("\n") + "\n", "utf8", ); expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); }); it("returns false for symbolic links", async () => { const realFile = path.join(tmpDir, "real.jsonl"); const linkFile = path.join(tmpDir, "link.jsonl"); await fs.writeFile( realFile, `${JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 } })}\n`, "utf8", ); await fs.symlink(realFile, linkFile); expect(await hasCompletedBootstrapTurn(linkFile)).toBe(false); }); }); describe("makeBootstrapWarn", () => { afterEach(() => { resetBootstrapWarningCacheForTest(); }); it("deduplicates repeated warnings for the same session and message", () => { const warnings: string[] = []; const warn = makeBootstrapWarn({ sessionLabel: "agent:main:test-session", warn: (message) => warnings.push(message), }); warn?.("workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating"); warn?.("workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating"); expect(warnings).toEqual([ "workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating (sessionKey=agent:main:test-session)", ]); }); it("keeps warnings distinct across sessions", () => { const warnings: string[] = []; const first = makeBootstrapWarn({ sessionLabel: "agent:main:first-session", warn: (message) => warnings.push(message), }); const second = makeBootstrapWarn({ sessionLabel: "agent:main:second-session", warn: (message) => warnings.push(message), }); first?.("workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating"); second?.("workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating"); expect(warnings).toEqual([ "workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating (sessionKey=agent:main:first-session)", "workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating (sessionKey=agent:main:second-session)", ]); }); it("keeps warnings distinct across workspaces with the same session", () => { const warnings: string[] = []; const first = makeBootstrapWarn({ sessionLabel: "agent:main:shared-session", workspaceDir: "/tmp/workspace-a", warn: (message) => warnings.push(message), }); const second = makeBootstrapWarn({ sessionLabel: "agent:main:shared-session", workspaceDir: "/tmp/workspace-b", warn: (message) => warnings.push(message), }); first?.("workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating"); second?.("workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating"); expect(warnings).toEqual([ "workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating (sessionKey=agent:main:shared-session)", "workspace bootstrap file MEMORY.md is 36697 chars (limit 12000); truncating (sessionKey=agent:main:shared-session)", ]); }); }); describe("resolveContextInjectionMode", () => { it("defaults to always when config is missing", () => { expect(resolveContextInjectionMode(undefined)).toBe("always"); }); it("defaults to always when the setting is omitted", () => { expect(resolveContextInjectionMode({ agents: { defaults: {} } } as never)).toBe("always"); }); it("returns the configured continuation-skip mode", () => { expect( resolveContextInjectionMode({ agents: { defaults: { contextInjection: "continuation-skip" } }, } as never), ).toBe("continuation-skip"); }); it("uses per-agent contextInjection before defaults", () => { expect( resolveContextInjectionMode( { agents: { defaults: { contextInjection: "continuation-skip" }, list: [{ id: "strict", contextInjection: "always" }], }, } as never, "strict", ), ).toBe("always"); }); it("falls back to defaults when the agent has no contextInjection override", () => { expect( resolveContextInjectionMode( { agents: { defaults: { contextInjection: "never" }, list: [{ id: "worker" }], }, } as never, "worker", ), ).toBe("never"); }); });