import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS, DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE, DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, ensureSessionHeader, resolveBootstrapMaxChars, resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, } from "./pi-embedded-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; import { DEFAULT_AGENTS_FILENAME } from "./workspace.js"; const makeFile = (overrides: Partial): WorkspaceBootstrapFile => ({ name: DEFAULT_AGENTS_FILENAME, path: "/tmp/AGENTS.md", content: "", missing: false, ...overrides, }); const createLargeBootstrapFiles = (): WorkspaceBootstrapFile[] => [ makeFile({ name: "AGENTS.md", content: "a".repeat(10_000) }), makeFile({ name: "SOUL.md", path: "/tmp/SOUL.md", content: "b".repeat(10_000) }), makeFile({ name: "USER.md", path: "/tmp/USER.md", content: "c".repeat(10_000) }), ]; describe("ensureSessionHeader", () => { it("creates transcript files with restrictive permissions", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-header-")); try { const sessionFile = path.join(tempDir, "nested", "session.jsonl"); await ensureSessionHeader({ sessionFile, sessionId: "session-1", cwd: tempDir }); expect((await fs.stat(path.dirname(sessionFile))).mode & 0o777).toBe(0o700); expect((await fs.stat(sessionFile)).mode & 0o777).toBe(0o600); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); }); describe("buildBootstrapContextFiles", () => { it("keeps missing markers", () => { const files = [makeFile({ missing: true, content: undefined })]; expect(buildBootstrapContextFiles(files)).toEqual([ { path: "/tmp/AGENTS.md", content: "[MISSING] Expected at: /tmp/AGENTS.md", }, ]); }); it("skips empty or whitespace-only content", () => { const files = [makeFile({ content: " \n " })]; expect(buildBootstrapContextFiles(files)).toStrictEqual([]); }); it("truncates large bootstrap content", () => { const head = `HEAD-${"a".repeat(600)}`; const tail = `${"b".repeat(300)}-TAIL`; const long = `${head}${tail}`; const files = [makeFile({ name: "TOOLS.md", content: long })]; const warnings: string[] = []; const maxChars = 200; const [result] = buildBootstrapContextFiles(files, { maxChars, warn: (message) => warnings.push(message), }); const kept = result?.content.match(/kept (\d+)\+(\d+) chars/); if (!kept) { throw new Error("missing truncation kept-count marker"); } expect(kept[1].length).toBeGreaterThan(0); expect(kept[2].length).toBeGreaterThan(0); const headChars = Number(kept[1]); const tailChars = Number(kept[2]); expect(result?.content).toContain("[...truncated, read TOOLS.md for full content...]"); expect(result?.content.length).toBeLessThan(long.length); expect(result?.content.length).toBeLessThanOrEqual(maxChars); expect(result?.content.startsWith(long.slice(0, headChars))).toBe(true); if (tailChars > 0) { expect(result?.content.endsWith(long.slice(-tailChars))).toBe(true); } expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("TOOLS.md"); expect(warnings[0]).toContain("limit 200"); }); it("fits the rendered truncation marker inside the per-file budget", () => { const maxChars = DEFAULT_BOOTSTRAP_MAX_CHARS; const files = [ makeFile({ name: "HEARTBEAT.md", path: "/tmp/HEARTBEAT.md", content: "a".repeat(maxChars * 2), }), ]; const [result] = buildBootstrapContextFiles(files, { maxChars }); expect(result?.content).toContain("[...truncated, read HEARTBEAT.md for full content...]"); expect(result?.content.length).toBeLessThanOrEqual(maxChars); }); it("keeps bootstrap bytes in tiny per-file budgets when the marker is longer than the limit", () => { const maxChars = 64; const content = `HEAD-${"a".repeat(1_000)}-TAIL`; const files = [ makeFile({ name: "HEARTBEAT.md", path: "/tmp/HEARTBEAT.md", content, }), ]; const [result] = buildBootstrapContextFiles(files, { maxChars }); expect(result?.content.startsWith("HEAD-")).toBe(true); expect(result?.content.endsWith("-TAIL")).toBe(true); expect(result?.content).toContain("truncated"); expect(result?.content.length).toBeLessThanOrEqual(maxChars); }); it("keeps at least one bootstrap byte when only the compact marker fits", () => { const maxChars = 22; const content = `HEAD-${"a".repeat(1_000)}-TAIL`; const files = [ makeFile({ name: "HEARTBEAT.md", path: "/tmp/HEARTBEAT.md", content, }), ]; const [result] = buildBootstrapContextFiles(files, { maxChars }); expect(result?.content).toContain("truncated"); expect(result?.content.length).toBeLessThanOrEqual(maxChars); expect(result?.content).toContain("H"); }); it("keeps content under the default limit", () => { const long = "a".repeat(DEFAULT_BOOTSTRAP_MAX_CHARS - 10); const files = [makeFile({ content: long })]; const [result] = buildBootstrapContextFiles(files); expect(result?.content).toBe(long); expect(result?.content).not.toContain("[...truncated, read AGENTS.md for full content...]"); }); it("keeps total injected bootstrap characters under the new default total cap", () => { const files = createLargeBootstrapFiles(); const result = buildBootstrapContextFiles(files); const totalChars = result.reduce((sum, entry) => sum + entry.content.length, 0); expect(totalChars).toBeLessThanOrEqual(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS); expect(result).toHaveLength(3); expect(result[2]?.content).toBe("c".repeat(10_000)); }); it("caps total injected bootstrap characters when totalMaxChars is configured", () => { const files = createLargeBootstrapFiles(); const result = buildBootstrapContextFiles(files, { totalMaxChars: 24_000 }); const totalChars = result.reduce((sum, entry) => sum + entry.content.length, 0); expect(totalChars).toBeLessThanOrEqual(24_000); expect(result).toHaveLength(3); expect(result[2]?.content).toContain("[...truncated, read USER.md for full content...]"); }); it("enforces strict total cap even when truncation markers are present", () => { const files = [ makeFile({ name: "AGENTS.md", content: "a".repeat(1_000) }), makeFile({ name: "SOUL.md", path: "/tmp/SOUL.md", content: "b".repeat(1_000) }), ]; const result = buildBootstrapContextFiles(files, { maxChars: 100, totalMaxChars: 150, }); const totalChars = result.reduce((sum, entry) => sum + entry.content.length, 0); expect(totalChars).toBeLessThanOrEqual(150); }); it("skips bootstrap injection when remaining total budget is too small", () => { const files = [makeFile({ name: "AGENTS.md", content: "a".repeat(1_000) })]; const result = buildBootstrapContextFiles(files, { maxChars: 200, totalMaxChars: 40, }); expect(result).toStrictEqual([]); }); it("keeps missing markers under small total budgets", () => { const files = [makeFile({ missing: true, content: undefined })]; const result = buildBootstrapContextFiles(files, { totalMaxChars: 20, }); expect(result).toHaveLength(1); expect(result[0]?.content.length).toBeLessThanOrEqual(20); expect(result[0]?.content.startsWith("[MISSING]")).toBe(true); }); it("skips files with missing or invalid paths and emits warnings", () => { const malformedMissingPath = { name: "SKILL-SECURITY.md", missing: false, content: "secret", } as unknown as WorkspaceBootstrapFile; const malformedNonStringPath = { name: "SKILL-SECURITY.md", path: 123, missing: false, content: "secret", } as unknown as WorkspaceBootstrapFile; const malformedWhitespacePath = { name: "SKILL-SECURITY.md", path: " ", missing: false, content: "secret", } as unknown as WorkspaceBootstrapFile; const good = makeFile({ content: "hello" }); const warnings: string[] = []; const result = buildBootstrapContextFiles( [malformedMissingPath, malformedNonStringPath, malformedWhitespacePath, good], { warn: (msg) => warnings.push(msg), }, ); expect(result).toHaveLength(1); expect(result[0]?.path).toBe("/tmp/AGENTS.md"); expect(warnings).toHaveLength(3); expect( warnings.filter((warning) => !warning.includes('missing or invalid "path" field')), ).toStrictEqual([]); }); }); type BootstrapLimitResolverCase = { name: "bootstrapMaxChars" | "bootstrapTotalMaxChars"; resolve: (cfg?: OpenClawConfig) => number; defaultValue: number; }; const BOOTSTRAP_LIMIT_RESOLVERS: BootstrapLimitResolverCase[] = [ { name: "bootstrapMaxChars", resolve: resolveBootstrapMaxChars, defaultValue: DEFAULT_BOOTSTRAP_MAX_CHARS, }, { name: "bootstrapTotalMaxChars", resolve: resolveBootstrapTotalMaxChars, defaultValue: DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, }, ]; describe("bootstrap limit resolvers", () => { it("return defaults when unset", () => { for (const resolver of BOOTSTRAP_LIMIT_RESOLVERS) { expect(resolver.resolve()).toBe(resolver.defaultValue); } }); it("use configured values when valid", () => { for (const resolver of BOOTSTRAP_LIMIT_RESOLVERS) { const cfg = { agents: { defaults: { [resolver.name]: 12345 } }, } as OpenClawConfig; expect(resolver.resolve(cfg)).toBe(12345); } }); it("fall back when values are invalid", () => { for (const resolver of BOOTSTRAP_LIMIT_RESOLVERS) { const cfg = { agents: { defaults: { [resolver.name]: -1 } }, } as OpenClawConfig; expect(resolver.resolve(cfg)).toBe(resolver.defaultValue); } }); }); describe("resolveBootstrapPromptTruncationWarningMode", () => { it("defaults to once", () => { expect(resolveBootstrapPromptTruncationWarningMode()).toBe( DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE, ); }); it("accepts explicit valid modes", () => { expect( resolveBootstrapPromptTruncationWarningMode({ agents: { defaults: { bootstrapPromptTruncationWarning: "off" } }, } as OpenClawConfig), ).toBe("off"); expect( resolveBootstrapPromptTruncationWarningMode({ agents: { defaults: { bootstrapPromptTruncationWarning: "always" } }, } as OpenClawConfig), ).toBe("always"); }); it("falls back to default for invalid values", () => { expect( resolveBootstrapPromptTruncationWarningMode({ agents: { defaults: { bootstrapPromptTruncationWarning: "invalid" } }, } as unknown as OpenClawConfig), ).toBe(DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE); }); });