Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import os from "node:os";
import { formatSkillsForPrompt as upstreamFormatSkillsForPrompt } from "@mariozechner/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createCanonicalFixtureSkill } from "../skills.test-helpers.js";
import {
restoreMockSkillsHomeEnv,
setMockSkillsHomeEnv,
type SkillsHomeEnvSnapshot,
} from "./home-env.test-support.js";
import { formatSkillsForPrompt, type Skill } from "./skill-contract.js";
import type { SkillEntry } from "./types.js";
import {
formatSkillsCompact,
buildWorkspaceSkillsPrompt,
buildWorkspaceSkillSnapshot,
} from "./workspace.js";
function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/SKILL.md`): Skill {
return createCanonicalFixtureSkill({
name,
description: desc,
filePath,
baseDir: `/skills/${name}`,
source: "workspace",
});
}
function makeEntry(skill: Skill): SkillEntry {
return {
skill,
frontmatter: {},
exposure: {
includeInRuntimeRegistry: true,
includeInAvailableSkillsPrompt: true,
userInvocable: true,
},
};
}
function buildPrompt(
skills: Skill[],
limits: { maxChars?: number; maxCount?: number } = {},
): string {
return buildWorkspaceSkillsPrompt("/fake", {
entries: skills.map(makeEntry),
config: {
skills: {
limits: {
...(limits.maxChars !== undefined && { maxSkillsPromptChars: limits.maxChars }),
...(limits.maxCount !== undefined && { maxSkillsInPrompt: limits.maxCount }),
},
},
} satisfies OpenClawConfig,
});
}
describe("formatSkillsCompact", () => {
it("keeps the full-format XML output aligned with the upstream formatter for visible skills", () => {
const skills = [
makeSkill("weather", "Get weather <data> & forecasts"),
makeSkill("notes", "Summarize notes", "/tmp/notes/SKILL.md"),
];
expect(formatSkillsForPrompt(skills)).toBe(upstreamFormatSkillsForPrompt(skills));
});
it("renders all passed skills in the full formatter without reapplying visibility policy", () => {
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
const out = formatSkillsForPrompt([makeSkill("visible"), hidden]);
expect(out).toContain("visible");
expect(out).toContain("hidden");
});
it("returns empty string for no skills", () => {
expect(formatSkillsCompact([])).toBe("");
});
it("omits description, keeps name and location", () => {
const out = formatSkillsCompact([makeSkill("weather", "Get weather data")]);
expect(out).toContain("<name>weather</name>");
expect(out).toContain("<location>/skills/weather/SKILL.md</location>");
expect(out).not.toContain("Get weather data");
expect(out).not.toContain("<description>");
});
it("renders all passed skills without reapplying visibility policy", () => {
const hidden: Skill = { ...makeSkill("hidden"), disableModelInvocation: true };
const out = formatSkillsCompact([makeSkill("visible"), hidden]);
expect(out).toContain("visible");
expect(out).toContain("hidden");
});
it("escapes XML special characters", () => {
const out = formatSkillsCompact([makeSkill("a<b&c")]);
expect(out).toContain("a<b&c");
});
it("is significantly smaller than full format", () => {
const skills = Array.from({ length: 50 }, (_, i) =>
makeSkill(`skill-${i}`, "A moderately long description that takes up space in the prompt"),
);
const compact = formatSkillsCompact(skills);
expect(compact.length).toBeLessThan(6000);
});
});
describe("applySkillsPromptLimits (via buildWorkspaceSkillsPrompt)", () => {
let envSnapshot: SkillsHomeEnvSnapshot;
beforeEach(() => {
envSnapshot = setMockSkillsHomeEnv("/Users/openclaw-test-user");
});
afterEach(() => restoreMockSkillsHomeEnv(envSnapshot));
it("respects explicit exposure metadata before compact formatting", () => {
const hidden = makeEntry({ ...makeSkill("hidden"), disableModelInvocation: true });
hidden.exposure = {
includeInRuntimeRegistry: true,
includeInAvailableSkillsPrompt: false,
userInvocable: true,
};
const prompt = buildWorkspaceSkillsPrompt("/fake", {
entries: [makeEntry(makeSkill("visible")), hidden],
config: {
skills: {
limits: {
maxSkillsPromptChars: 4_000,
},
},
} satisfies OpenClawConfig,
});
expect(prompt).toContain("visible");
expect(prompt).not.toContain("hidden");
});
it("tier 1: uses full format when under budget", () => {
const skills = [makeSkill("weather", "Get weather data")];
const prompt = buildPrompt(skills, { maxChars: 50_000 });
expect(prompt).toContain("<description>");
expect(prompt).toContain("Get weather data");
expect(prompt).not.toContain("⚠️");
});
it("tier 2: compact when full exceeds budget but compact fits", () => {
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
const fullLen = formatSkillsForPrompt(skills).length;
const compactLen = formatSkillsCompact(skills).length;
const budget = Math.floor((fullLen + compactLen) / 2);
// Verify preconditions: full exceeds budget, compact fits within overhead-adjusted budget
expect(fullLen).toBeGreaterThan(budget);
expect(compactLen + 150).toBeLessThan(budget);
const prompt = buildPrompt(skills, { maxChars: budget });
expect(prompt).not.toContain("<description>");
// All skills preserved — distinct message, no "included X of Y"
expect(prompt).toContain("compact format (descriptions omitted)");
expect(prompt).not.toContain("included");
expect(prompt).toContain("skill-0");
expect(prompt).toContain("skill-19");
});
it("tier 3: compact + binary search when compact also exceeds budget", () => {
const skills = Array.from({ length: 100 }, (_, i) => makeSkill(`skill-${i}`, "description"));
const prompt = buildPrompt(skills, { maxChars: 2000 });
expect(prompt).toContain("compact format, descriptions omitted");
expect(prompt).not.toContain("<description>");
expect(prompt).toContain("skill-0");
const match = prompt.match(/included (\d+) of (\d+)/);
expect(match).toBeTruthy();
expect(Number(match![1])).toBeLessThan(Number(match![2]));
expect(Number(match![1])).toBeGreaterThan(0);
});
it("compact preserves all skills where full format would drop some", () => {
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
const compactLen = formatSkillsCompact(skills).length;
const budget = compactLen + 250;
// Verify precondition: full format must not fit so tier 2 is actually exercised
expect(formatSkillsForPrompt(skills).length).toBeGreaterThan(budget);
const prompt = buildPrompt(skills, { maxChars: budget });
// All 50 fit in compact — no truncation, just compact notice
expect(prompt).toContain("compact format");
expect(prompt).not.toContain("included");
expect(prompt).toContain("skill-0");
expect(prompt).toContain("skill-49");
});
it("count truncation + compact: shows included X of Y with compact note", () => {
// 30 skills but maxCount=10, and full format of 10 exceeds budget
const skills = Array.from({ length: 30 }, (_, i) => makeSkill(`skill-${i}`, "A".repeat(200)));
const tenSkills = skills.slice(0, 10);
const fullLen = formatSkillsForPrompt(tenSkills).length;
const compactLen = formatSkillsCompact(tenSkills).length;
const budget = compactLen + 200;
// Verify precondition: full format of 10 skills exceeds budget
expect(fullLen).toBeGreaterThan(budget);
const prompt = buildPrompt(skills, { maxChars: budget, maxCount: 10 });
// Count-truncated (30→10) AND compact (full format of 10 exceeds budget)
expect(prompt).toContain("included 10 of 30");
expect(prompt).toContain("compact format, descriptions omitted");
expect(prompt).not.toContain("<description>");
});
it("extreme budget: even a single compact skill overflows", () => {
const skills = [makeSkill("only-one", "desc")];
// Budget so small that even one compact skill can't fit
const prompt = buildPrompt(skills, { maxChars: 10 });
expect(prompt).not.toContain("only-one");
const match = prompt.match(/included (\d+) of (\d+)/);
expect(match).toBeTruthy();
expect(Number(match![1])).toBe(0);
});
it("count truncation only: shows included X of Y without compact note", () => {
const skills = Array.from({ length: 20 }, (_, i) => makeSkill(`skill-${i}`, "short"));
const prompt = buildPrompt(skills, { maxChars: 50_000, maxCount: 5 });
expect(prompt).toContain("included 5 of 20");
expect(prompt).not.toContain("compact");
expect(prompt).toContain("<description>");
});
it("compact budget reserves space for the warning line", () => {
// Build skills whose compact output exactly equals the char budget.
// Without overhead reservation the compact block would fit, but the
// warning line prepended by the caller would push the total over budget.
const skills = Array.from({ length: 50 }, (_, i) => makeSkill(`s-${i}`, "A".repeat(200)));
const compactLen = formatSkillsCompact(skills).length;
// Set budget = compactLen + 50 — less than the 150-char overhead reserve.
// The function should NOT choose compact-only because the warning wouldn't fit.
const prompt = buildPrompt(skills, { maxChars: compactLen + 50 });
// Should fall through to compact + binary search (some skills dropped)
expect(prompt).toContain("included");
expect(prompt).not.toContain("<description>");
});
it("budget check uses compacted home-dir paths, not canonical paths", () => {
// Skills with home-dir prefix get compacted (e.g. /home/user/... → ~/...).
// Budget check must use the compacted length, not the longer canonical path.
// If it used canonical paths, it would overestimate and potentially drop
// skills that actually fit after compaction.
const home = os.homedir();
const skills = Array.from({ length: 30 }, (_, i) =>
makeSkill(
`skill-${i}`,
"A".repeat(200),
`${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`,
),
);
// Compute compacted lengths (what the prompt will actually contain)
const compactedSkills = skills.map((s) => ({
...s,
filePath: s.filePath.replace(home, "~"),
}));
const compactedCompactLen = formatSkillsCompact(compactedSkills).length;
const canonicalCompactLen = formatSkillsCompact(skills).length;
// Sanity: canonical paths are longer than compacted paths
expect(canonicalCompactLen).toBeGreaterThan(compactedCompactLen);
// Set budget between compacted and canonical lengths — only fits if
// budget check uses compacted paths (correct) not canonical (wrong).
const budget = Math.floor((compactedCompactLen + canonicalCompactLen) / 2) + 150;
const prompt = buildPrompt(skills, { maxChars: budget });
// All 30 skills should be preserved in compact form (tier 2, no dropping)
expect(prompt).toContain("skill-0");
expect(prompt).toContain("skill-29");
expect(prompt).not.toContain("included");
expect(prompt).toContain("compact format");
// Verify paths in output are compacted
expect(prompt).toContain("~/");
expect(prompt).not.toContain(home);
});
it("skills are sorted alphabetically regardless of entry insertion order", () => {
// Entries provided in reverse alphabetical order should still produce
// an alphabetically sorted prompt (fixes #64167).
const entries = ["zoo", "apple", "mango", "banana"].map((n) =>
makeEntry(makeSkill(n, `${n} skill`)),
);
const prompt = buildWorkspaceSkillsPrompt("/fake", {
entries,
config: { skills: { limits: { maxSkillsPromptChars: 50_000 } } } satisfies OpenClawConfig,
});
const nameMatches = [...prompt.matchAll(/<name>(\w+)<\/name>/g)].map((m) => m[1]);
expect(nameMatches).toEqual(["apple", "banana", "mango", "zoo"]);
});
it("resolvedSkills in snapshot keeps canonical paths, not compacted", () => {
const home = os.homedir();
const skills = Array.from({ length: 5 }, (_, i) =>
makeSkill(`skill-${i}`, "A skill", `${home}/.openclaw/workspace/skills/skill-${i}/SKILL.md`),
);
const snapshot = buildWorkspaceSkillSnapshot("/fake", {
entries: skills.map(makeEntry),
});
// Prompt should use compacted paths
expect(snapshot.prompt).toContain("~/");
// resolvedSkills should preserve canonical (absolute) paths
expect(snapshot.resolvedSkills).toBeDefined();
for (const skill of snapshot.resolvedSkills!) {
expect(skill.filePath).toContain(home);
expect(skill.filePath).not.toMatch(/^~\//);
}
});
});
¤ Dauer der Verarbeitung: 0.22 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|