Files
openclaw/src/agents/workspace.test.ts
Josh Avant 4159a11ea3 Fix stale bootstrap file breaking channel agent turns (#82463)
* fix agents stale bootstrap context

* docs changelog stale bootstrap fix
2026-05-16 00:55:18 -05:00

457 lines
19 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
filterBootstrapFilesForSession,
isWorkspaceBootstrapPending,
loadWorkspaceBootstrapFiles,
reconcileWorkspaceBootstrapCompletion,
resolveWorkspaceBootstrapStatus,
resolveDefaultAgentWorkspaceDir,
type WorkspaceBootstrapFile,
} from "./workspace.js";
describe("resolveDefaultAgentWorkspaceDir", () => {
it("uses OPENCLAW_HOME for default workspace resolution", () => {
const dir = resolveDefaultAgentWorkspaceDir({
OPENCLAW_HOME: "/srv/openclaw-home",
HOME: "/home/other",
} as NodeJS.ProcessEnv);
expect(dir).toBe(path.join(path.resolve("/srv/openclaw-home"), ".openclaw", "workspace"));
});
it("prefers OPENCLAW_WORKSPACE_DIR for default workspace resolution", () => {
const dir = resolveDefaultAgentWorkspaceDir({
OPENCLAW_WORKSPACE_DIR: "/srv/openclaw-workspace",
OPENCLAW_HOME: "/srv/openclaw-home",
HOME: "/home/other",
} as NodeJS.ProcessEnv);
expect(dir).toBe(path.resolve("/srv/openclaw-workspace"));
});
});
const WORKSPACE_STATE_PATH_SEGMENTS = [".openclaw", "workspace-state.json"] as const;
async function readWorkspaceState(dir: string): Promise<{
version: number;
bootstrapSeededAt?: string;
setupCompletedAt?: string;
}> {
const raw = await fs.readFile(path.join(dir, ...WORKSPACE_STATE_PATH_SEGMENTS), "utf-8");
return JSON.parse(raw) as {
version: number;
bootstrapSeededAt?: string;
setupCompletedAt?: string;
};
}
async function expectBootstrapSeeded(dir: string) {
await expect(fs.access(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME))).resolves.toBeUndefined();
const state = await readWorkspaceState(dir);
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
}
async function expectPathMissing(filePath: string): Promise<void> {
await expect(fs.access(filePath)).rejects.toHaveProperty("code", "ENOENT");
}
async function expectCompletedWithoutBootstrap(dir: string) {
await expect(fs.access(path.join(dir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
await expectPathMissing(path.join(dir, DEFAULT_BOOTSTRAP_FILENAME));
const state = await readWorkspaceState(dir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
}
function expectSubagentAllowedBootstrapNames(files: WorkspaceBootstrapFile[]) {
const names = files.map((file) => file.name);
expect(names).toStrictEqual(["AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md"]);
}
describe("ensureAgentWorkspace", () => {
it("creates BOOTSTRAP.md and records a seeded marker for brand new workspaces", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("recovers partial initialization by creating BOOTSTRAP.md when marker is missing", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_AGENTS_FILENAME, content: "existing" });
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectBootstrapSeeded(tempDir);
});
it("does not recreate BOOTSTRAP.md after completion, even when a core file is recreated", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_TOOLS_FILENAME));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("does not re-seed BOOTSTRAP.md for legacy completed workspaces without state marker", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
const state = await readWorkspaceState(tempDir);
expect(state.bootstrapSeededAt).toBeUndefined();
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("treats memory-backed workspaces as existing even when template files are missing", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
await fs.writeFile(path.join(tempDir, "memory", "2026-02-25.md"), "# Daily log\nSome notes");
await fs.writeFile(path.join(tempDir, "MEMORY.md"), "# Long-term memory\nImportant stuff");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
const memoryContent = await fs.readFile(path.join(tempDir, "MEMORY.md"), "utf-8");
expect(memoryContent).toBe("# Long-term memory\nImportant stuff");
});
it("treats git-backed workspaces as existing even when template files are missing", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
await fs.writeFile(path.join(tempDir, ".git", "HEAD"), "ref: refs/heads/main\n");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expectCompletedWithoutBootstrap(tempDir);
});
it("skips configured optional bootstrap files without skipping required files", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({
dir: tempDir,
ensureBootstrapFiles: true,
skipOptionalBootstrapFiles: [
DEFAULT_SOUL_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
],
});
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
await expect(fs.access(path.join(tempDir, DEFAULT_TOOLS_FILENAME))).resolves.toBeUndefined();
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
for (const fileName of [
DEFAULT_SOUL_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
]) {
await expectPathMissing(path.join(tempDir, fileName));
}
});
it("preserves legacy setup detection when skipped profile files already exist", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_IDENTITY_FILENAME, content: "custom" });
await writeWorkspaceFile({ dir: tempDir, name: DEFAULT_USER_FILENAME, content: "custom" });
await ensureAgentWorkspace({
dir: tempDir,
ensureBootstrapFiles: true,
skipOptionalBootstrapFiles: [DEFAULT_IDENTITY_FILENAME, DEFAULT_USER_FILENAME],
});
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("migrates legacy onboardingCompletedAt markers to setupCompletedAt", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await fs.mkdir(path.join(tempDir, ".openclaw"), { recursive: true });
await fs.writeFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
JSON.stringify({
version: 1,
onboardingCompletedAt: "2026-03-15T02:30:00.000Z",
}),
);
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toBe("2026-03-15T02:30:00.000Z");
const persisted = await fs.readFile(
path.join(tempDir, ...WORKSPACE_STATE_PATH_SEGMENTS),
"utf-8",
);
expect(persisted).toContain('"setupCompletedAt": "2026-03-15T02:30:00.000Z"');
});
it("reports bootstrap pending while BOOTSTRAP.md exists and setup is incomplete", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(true);
});
it("keeps bootstrap status read-only when stale completion evidence exists", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_IDENTITY_FILENAME,
content: "# IDENTITY.md\n\n- **Name:** Example\n",
});
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("repairs stale BOOTSTRAP.md when profile files show onboarding completed", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_IDENTITY_FILENAME,
content: "# IDENTITY.md\n\n- **Name:** Example\n",
});
const result = await reconcileWorkspaceBootstrapCompletion(tempDir);
expect(result.repaired).toBe(true);
expect(result.bootstrapExists).toBe(false);
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
const state = await readWorkspaceState(tempDir);
expect(state.bootstrapSeededAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
});
it("records stale bootstrap completion when BOOTSTRAP.md cleanup fails", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_IDENTITY_FILENAME,
content: "# IDENTITY.md\n\n- **Name:** Example\n",
});
const bootstrapPath = path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME);
const rmSpy = vi
.spyOn(fs, "rm")
.mockRejectedValueOnce(Object.assign(new Error("not a directory"), { code: "ENOTDIR" }));
try {
const result = await reconcileWorkspaceBootstrapCompletion(tempDir);
expect(result.repaired).toBe(true);
expect(result.bootstrapExists).toBe(true);
expect(rmSpy).toHaveBeenCalledWith(bootstrapPath, { force: true });
await expect(fs.access(bootstrapPath)).resolves.toBeUndefined();
const state = await readWorkspaceState(tempDir);
expect(state.setupCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
} finally {
rmSpy.mockRestore();
}
});
it("uses SOUL.md customization as stale bootstrap completion evidence", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_SOUL_FILENAME,
content: "# SOUL.md\n\nUse a concise, practical voice.\n",
});
const result = await reconcileWorkspaceBootstrapCompletion(tempDir);
expect(result.repaired).toBe(true);
expect(result.bootstrapExists).toBe(false);
await expectPathMissing(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
});
it("does not treat git alone as stale bootstrap completion evidence", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.mkdir(path.join(tempDir, ".git"), { recursive: true });
await fs.writeFile(path.join(tempDir, ".git", "HEAD"), "ref: refs/heads/main\n");
const result = await reconcileWorkspaceBootstrapCompletion(tempDir);
expect(result.repaired).toBe(false);
expect(result.bootstrapExists).toBe(true);
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
expect((await readWorkspaceState(tempDir)).setupCompletedAt).toBeUndefined();
});
it("reports bootstrap complete once BOOTSTRAP.md is deleted and completion is recorded", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
});
it("writes the current fenced HEARTBEAT template body into new workspaces", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
const heartbeat = await fs.readFile(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME), "utf-8");
expect(heartbeat).toContain("```markdown");
expect(heartbeat).toContain(
"# Keep this file empty (or with only comments) to skip heartbeat API calls.",
);
expect(heartbeat).toContain(
"# Add tasks below when you want the agent to check something periodically.",
);
});
});
describe("loadWorkspaceBootstrapFiles", () => {
const getMemoryEntries = (files: Awaited<ReturnType<typeof loadWorkspaceBootstrapFiles>>) =>
files.filter((file) => file.name === DEFAULT_MEMORY_FILENAME);
const expectSingleMemoryEntry = (
files: Awaited<ReturnType<typeof loadWorkspaceBootstrapFiles>>,
content: string,
) => {
const memoryEntries = getMemoryEntries(files);
expect(memoryEntries).toHaveLength(1);
expect(memoryEntries[0]?.missing).toBe(false);
expect(memoryEntries[0]?.content).toBe(content);
};
it("includes MEMORY.md when present", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: "MEMORY.md", content: "memory" });
const files = await loadWorkspaceBootstrapFiles(tempDir);
expectSingleMemoryEntry(files, "memory");
});
it("ignores lowercase memory.md when MEMORY.md is absent", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await writeWorkspaceFile({ dir: tempDir, name: "memory.md", content: "alt" });
const files = await loadWorkspaceBootstrapFiles(tempDir);
expect(getMemoryEntries(files)).toHaveLength(0);
});
it("omits memory entries when no memory files exist", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
const files = await loadWorkspaceBootstrapFiles(tempDir);
expect(getMemoryEntries(files)).toHaveLength(0);
});
it("treats hardlinked bootstrap aliases as missing", async () => {
if (process.platform === "win32") {
return;
}
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-hardlink-"));
try {
const workspaceDir = path.join(rootDir, "workspace");
const outsideDir = path.join(rootDir, "outside");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
const outsideFile = path.join(outsideDir, DEFAULT_AGENTS_FILENAME);
const linkPath = path.join(workspaceDir, DEFAULT_AGENTS_FILENAME);
await fs.writeFile(outsideFile, "outside", "utf-8");
try {
await fs.link(outsideFile, linkPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
const files = await loadWorkspaceBootstrapFiles(workspaceDir);
const agents = files.find((file) => file.name === DEFAULT_AGENTS_FILENAME);
expect(agents?.missing).toBe(true);
expect(agents?.content).toBeUndefined();
} finally {
await fs.rm(rootDir, { recursive: true, force: true });
}
});
});
describe("filterBootstrapFilesForSession", () => {
const mockFiles: WorkspaceBootstrapFile[] = [
{ name: "AGENTS.md", path: "/w/AGENTS.md", content: "", missing: false },
{ name: "SOUL.md", path: "/w/SOUL.md", content: "", missing: false },
{ name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false },
{ name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false },
{ name: "USER.md", path: "/w/USER.md", content: "", missing: false },
{ name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false },
{ name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false },
{ name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false },
];
it("returns all files for main session (no sessionKey)", () => {
const result = filterBootstrapFilesForSession(mockFiles);
expect(result).toStrictEqual(mockFiles);
});
it("returns all files for normal (non-subagent, non-cron) session key", () => {
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:chat:main");
expect(result).toStrictEqual(mockFiles);
});
it("filters to allowlist for subagent sessions", () => {
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:subagent:task-1");
expectSubagentAllowedBootstrapNames(result);
});
it("filters to allowlist for cron sessions", () => {
const result = filterBootstrapFilesForSession(mockFiles, "agent:default:cron:daily-check");
expectSubagentAllowedBootstrapNames(result);
});
});