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,
];
});
}
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[]) {
expect(files.some((file) => file.name === "HEARTBEAT.md" )).toBe(false );
expect(files.some((file) => file.name === "AGENTS.md" )).toBe(true );
}
describe("resolveBootstrapFilesForRun" , () => {
beforeEach(() => clearInternalHooks());
afterEach(() => clearInternalHooks());
it("applies bootstrap hook overrides" , async () => {
registerExtraBootstrapFileHook();
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-" );
const files = await resolveBootstrapFilesForRun({ workspaceDir });
expect(files.some((file) => file.path === path.join(workspaceDir, "EXTRA.md" ))).toBe(true );
});
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.every((file) => typeof file.path === "string" && file.path.trim().length > 0 ),
).toBe(true );
expect(warnings).toHaveLength(3 );
expect(warnings[0 ]).toContain('missing or invalid "path" field' );
});
});
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 });
expect(result.bootstrapFiles.some((file) => file.name === "BOOTSTRAP.md" )).toBe(true );
expect(result.contextFiles.some((file) => file.path.endsWith("BOOTSTRAP.md" ))).toBe(true );
expect(result.contextFiles.some((file) => file.path.endsWith("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.length).toBeGreaterThan(0 );
expect(files.every((file) => file.name === "HEARTBEAT.md" )).toBe(true );
});
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).toEqual([]);
});
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" }],
},
},
});
expect(files.some((file) => file.name === "HEARTBEAT.md" )).toBe(true );
});
});
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" );
});
});
Messung V0.5 in Prozent C=100 H=97 G=98
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland