mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 18:01:46 +00:00
461 lines
13 KiB
TypeScript
461 lines
13 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
appendBootstrapPromptWarning,
|
|
analyzeBootstrapBudget,
|
|
buildBootstrapInjectionStats,
|
|
buildBootstrapPromptWarning,
|
|
buildBootstrapTruncationReportMeta,
|
|
buildBootstrapTruncationSignature,
|
|
formatBootstrapTruncationWarningLines,
|
|
resolveBootstrapWarningSignaturesSeen,
|
|
} from "./bootstrap-budget.js";
|
|
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
|
import type { WorkspaceBootstrapFile } from "./workspace.js";
|
|
|
|
describe("buildBootstrapInjectionStats", () => {
|
|
it("maps raw and injected sizes and marks truncation", () => {
|
|
const bootstrapFiles: WorkspaceBootstrapFile[] = [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/AGENTS.md",
|
|
content: "a".repeat(100),
|
|
missing: false,
|
|
},
|
|
{
|
|
name: "SOUL.md",
|
|
path: "/tmp/SOUL.md",
|
|
content: "b".repeat(50),
|
|
missing: false,
|
|
},
|
|
];
|
|
const injectedFiles = [
|
|
{ path: "/tmp/AGENTS.md", content: "a".repeat(100) },
|
|
{ path: "/tmp/SOUL.md", content: "b".repeat(20) },
|
|
];
|
|
const stats = buildBootstrapInjectionStats({
|
|
bootstrapFiles,
|
|
injectedFiles,
|
|
});
|
|
expect(stats).toHaveLength(2);
|
|
expect(stats[0]).toMatchObject({
|
|
name: "AGENTS.md",
|
|
rawChars: 100,
|
|
injectedChars: 100,
|
|
truncated: false,
|
|
});
|
|
expect(stats[1]).toMatchObject({
|
|
name: "SOUL.md",
|
|
rawChars: 50,
|
|
injectedChars: 20,
|
|
truncated: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("analyzeBootstrapBudget", () => {
|
|
it("reports per-file and total-limit causes", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 120,
|
|
truncated: true,
|
|
},
|
|
{
|
|
name: "SOUL.md",
|
|
path: "/tmp/SOUL.md",
|
|
missing: false,
|
|
rawChars: 90,
|
|
injectedChars: 80,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
expect(analysis.hasTruncation).toBe(true);
|
|
expect(analysis.totalNearLimit).toBe(true);
|
|
expect(analysis.truncatedFiles).toHaveLength(2);
|
|
const agents = analysis.truncatedFiles.find((file) => file.name === "AGENTS.md");
|
|
const soul = analysis.truncatedFiles.find((file) => file.name === "SOUL.md");
|
|
expect(agents?.causes).toContain("per-file-limit");
|
|
expect(agents?.causes).toContain("total-limit");
|
|
expect(soul?.causes).toContain("total-limit");
|
|
});
|
|
|
|
it("does not force a total-limit cause when totals are within limits", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 90,
|
|
injectedChars: 40,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
expect(analysis.truncatedFiles[0]?.causes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("bootstrap prompt warnings", () => {
|
|
it("appends warning details to the turn prompt instead of mutating the system prompt", () => {
|
|
const prompt = appendBootstrapPromptWarning("Please continue.", [
|
|
"AGENTS.md: 200 raw -> 0 injected",
|
|
]);
|
|
expect(prompt.startsWith("Please continue.")).toBe(true);
|
|
expect(prompt).toContain("[Bootstrap truncation warning]");
|
|
expect(prompt).toContain("Treat Project Context as partial");
|
|
expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected");
|
|
expect(prompt.endsWith("- AGENTS.md: 200 raw -> 0 injected")).toBe(true);
|
|
});
|
|
|
|
it("preserves raw prompt whitespace when appending warning details", () => {
|
|
const prompt = appendBootstrapPromptWarning(" indented\nkeep tail ", [
|
|
"AGENTS.md: 200 raw -> 0 injected",
|
|
]);
|
|
|
|
expect(prompt).toContain(" indented\nkeep tail ");
|
|
expect(prompt.indexOf(" indented\nkeep tail ")).toBe(0);
|
|
});
|
|
|
|
it("preserves exact heartbeat prompts without warning suffixes", () => {
|
|
const heartbeatPrompt = "Read HEARTBEAT.md. Reply HEARTBEAT_OK.";
|
|
|
|
expect(
|
|
appendBootstrapPromptWarning(heartbeatPrompt, ["AGENTS.md: 200 raw -> 0 injected"], {
|
|
preserveExactPrompt: heartbeatPrompt,
|
|
}),
|
|
).toBe(heartbeatPrompt);
|
|
});
|
|
|
|
it("resolves seen signatures from report history or legacy single signature", () => {
|
|
expect(
|
|
resolveBootstrapWarningSignaturesSeen({
|
|
bootstrapTruncation: {
|
|
warningSignaturesSeen: ["sig-a", " ", "sig-b", "sig-a"],
|
|
promptWarningSignature: "legacy-ignored",
|
|
},
|
|
}),
|
|
).toEqual(["sig-a", "sig-b"]);
|
|
|
|
expect(
|
|
resolveBootstrapWarningSignaturesSeen({
|
|
bootstrapTruncation: {
|
|
promptWarningSignature: "legacy-only",
|
|
},
|
|
}),
|
|
).toEqual(["legacy-only"]);
|
|
|
|
expect(resolveBootstrapWarningSignaturesSeen(undefined)).toEqual([]);
|
|
});
|
|
|
|
it("ignores single-signature fallback when warning mode is off", () => {
|
|
expect(
|
|
resolveBootstrapWarningSignaturesSeen({
|
|
bootstrapTruncation: {
|
|
warningMode: "off",
|
|
promptWarningSignature: "off-mode-signature",
|
|
},
|
|
}),
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
resolveBootstrapWarningSignaturesSeen({
|
|
bootstrapTruncation: {
|
|
warningMode: "off",
|
|
warningSignaturesSeen: ["prior-once-signature"],
|
|
promptWarningSignature: "off-mode-signature",
|
|
},
|
|
}),
|
|
).toEqual(["prior-once-signature"]);
|
|
});
|
|
|
|
it("dedupes warnings in once mode by signature", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
const first = buildBootstrapPromptWarning({
|
|
analysis,
|
|
mode: "once",
|
|
});
|
|
expect(first.warningShown).toBe(true);
|
|
expect(first.signature).toBeTruthy();
|
|
expect(first.lines.join("\n")).toContain("AGENTS.md");
|
|
|
|
const second = buildBootstrapPromptWarning({
|
|
analysis,
|
|
mode: "once",
|
|
seenSignatures: first.warningSignaturesSeen,
|
|
});
|
|
expect(second.warningShown).toBe(false);
|
|
expect(second.lines).toEqual([]);
|
|
});
|
|
|
|
it("dedupes once mode across non-consecutive repeated signatures", () => {
|
|
const analysisA = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "A.md",
|
|
path: "/tmp/A.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
const analysisB = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "B.md",
|
|
path: "/tmp/B.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
const firstA = buildBootstrapPromptWarning({
|
|
analysis: analysisA,
|
|
mode: "once",
|
|
});
|
|
expect(firstA.warningShown).toBe(true);
|
|
const firstB = buildBootstrapPromptWarning({
|
|
analysis: analysisB,
|
|
mode: "once",
|
|
seenSignatures: firstA.warningSignaturesSeen,
|
|
});
|
|
expect(firstB.warningShown).toBe(true);
|
|
const secondA = buildBootstrapPromptWarning({
|
|
analysis: analysisA,
|
|
mode: "once",
|
|
seenSignatures: firstB.warningSignaturesSeen,
|
|
});
|
|
expect(secondA.warningShown).toBe(false);
|
|
});
|
|
|
|
it("includes overflow line when more files are truncated than shown", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "A.md",
|
|
path: "/tmp/A.md",
|
|
missing: false,
|
|
rawChars: 10,
|
|
injectedChars: 1,
|
|
truncated: true,
|
|
},
|
|
{
|
|
name: "B.md",
|
|
path: "/tmp/B.md",
|
|
missing: false,
|
|
rawChars: 10,
|
|
injectedChars: 1,
|
|
truncated: true,
|
|
},
|
|
{
|
|
name: "C.md",
|
|
path: "/tmp/C.md",
|
|
missing: false,
|
|
rawChars: 10,
|
|
injectedChars: 1,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 20,
|
|
bootstrapTotalMaxChars: 10,
|
|
});
|
|
const lines = formatBootstrapTruncationWarningLines({
|
|
analysis,
|
|
maxFiles: 2,
|
|
});
|
|
expect(lines).toContain("+1 more truncated file(s).");
|
|
});
|
|
|
|
it("disambiguates duplicate file names in warning lines", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/a/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/b/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 140,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 300,
|
|
});
|
|
const lines = formatBootstrapTruncationWarningLines({
|
|
analysis,
|
|
});
|
|
expect(lines.join("\n")).toContain("AGENTS.md (/tmp/a/AGENTS.md)");
|
|
expect(lines.join("\n")).toContain("AGENTS.md (/tmp/b/AGENTS.md)");
|
|
});
|
|
|
|
it("respects off/always warning modes", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
const signature = buildBootstrapTruncationSignature(analysis);
|
|
const off = buildBootstrapPromptWarning({
|
|
analysis,
|
|
mode: "off",
|
|
seenSignatures: [signature ?? ""],
|
|
previousSignature: signature,
|
|
});
|
|
expect(off.warningShown).toBe(false);
|
|
expect(off.lines).toEqual([]);
|
|
|
|
const always = buildBootstrapPromptWarning({
|
|
analysis,
|
|
mode: "always",
|
|
seenSignatures: [signature ?? ""],
|
|
previousSignature: signature,
|
|
});
|
|
expect(always.warningShown).toBe(true);
|
|
expect(always.lines.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uses file path in signature to avoid collisions for duplicate names", () => {
|
|
const left = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/a/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
const right = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/b/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
expect(buildBootstrapTruncationSignature(left)).not.toBe(
|
|
buildBootstrapTruncationSignature(right),
|
|
);
|
|
});
|
|
|
|
it("builds truncation report metadata from analysis + warning decision", () => {
|
|
const analysis = analyzeBootstrapBudget({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/AGENTS.md",
|
|
missing: false,
|
|
rawChars: 150,
|
|
injectedChars: 100,
|
|
truncated: true,
|
|
},
|
|
],
|
|
bootstrapMaxChars: 120,
|
|
bootstrapTotalMaxChars: 200,
|
|
});
|
|
const warning = buildBootstrapPromptWarning({
|
|
analysis,
|
|
mode: "once",
|
|
});
|
|
const meta = buildBootstrapTruncationReportMeta({
|
|
analysis,
|
|
warningMode: "once",
|
|
warning,
|
|
});
|
|
expect(meta.warningMode).toBe("once");
|
|
expect(meta.warningShown).toBe(true);
|
|
expect(meta.truncatedFiles).toBe(1);
|
|
expect(meta.nearLimitFiles).toBeGreaterThanOrEqual(1);
|
|
expect(meta.promptWarningSignature).toBeTruthy();
|
|
expect(meta.warningSignaturesSeen?.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("improves cache-relevant system prompt stability versus legacy warning injection", () => {
|
|
const contextFiles = [{ path: "AGENTS.md", content: "Follow AGENTS guidance." }];
|
|
const warningLines = ["AGENTS.md: 200 raw -> 0 injected"];
|
|
const stableSystemPrompt = buildAgentSystemPrompt({
|
|
workspaceDir: "/tmp/openclaw",
|
|
contextFiles,
|
|
});
|
|
const optimizedTurns = [stableSystemPrompt, stableSystemPrompt, stableSystemPrompt];
|
|
const injectLegacyWarning = (prompt: string, lines: string[]) => {
|
|
const warningBlock = [
|
|
"⚠ Bootstrap truncation warning:",
|
|
...lines.map((line) => `- ${line}`),
|
|
"",
|
|
].join("\n");
|
|
return prompt.replace("## AGENTS.md", `${warningBlock}## AGENTS.md`);
|
|
};
|
|
const legacyTurns = [
|
|
injectLegacyWarning(optimizedTurns[0] ?? "", warningLines),
|
|
optimizedTurns[1] ?? "",
|
|
injectLegacyWarning(optimizedTurns[2] ?? "", warningLines),
|
|
];
|
|
const cacheHitRate = (turns: string[]) => {
|
|
const hits = turns.slice(1).filter((turn, index) => turn === turns[index]).length;
|
|
return hits / Math.max(1, turns.length - 1);
|
|
};
|
|
|
|
expect(cacheHitRate(legacyTurns)).toBe(0);
|
|
expect(cacheHitRate(optimizedTurns)).toBe(1);
|
|
expect(optimizedTurns[0]).not.toContain("⚠ Bootstrap truncation warning:");
|
|
});
|
|
});
|