planner.test.ts
2637 bytes
1import { describe, it } from "node:test";
2import assert from "node:assert/strict";
3import { DefaultSkillTogglePlanner } from "./planner.ts";
4import { SimpleFrontmatterCodec } from "../frontmatter/parser.ts";
5import { MinimalFrontmatterPatcher } from "../frontmatter/patcher.ts";
6import type { FileSystem } from "../ports/fs.ts";
7import type { SkillRecord } from "../types.ts";
8
9const codec = new SimpleFrontmatterCodec();
10const patcher = new MinimalFrontmatterPatcher();
11
12describe("DefaultSkillTogglePlanner", () => {
13 it("plans a normalization change for duplicated disable-model-invocation keys even if the mode is unchanged", async () => {
14 const filePath = "/skills/handoff/SKILL.md";
15 const raw = [
16 "---",
17 "name: handoff",
18 "description: Compact the conversation.",
19 "disable-model-invocation: true",
20 "argument-hint: What next?",
21 "disable-model-invocation: true",
22 "---",
23 "",
24 "# Handoff",
25 "",
26 ].join("\n");
27 const fs = new MemoryFileSystem(new Map([[filePath, raw]]));
28 const planner = new DefaultSkillTogglePlanner(fs, codec, patcher);
29 const record = skillRecord(filePath, "manual-only");
30
31 const changes = await planner.plan([record], [{ skill: record, desiredMode: "manual-only" }]);
32
33 assert.equal(changes.length, 1);
34 assert.equal(changes[0]?.from, "manual-only");
35 assert.equal(changes[0]?.to, "manual-only");
36 assert.equal((changes[0]?.patch.newText.match(/^disable-model-invocation\s*:/gm) ?? []).length, 1);
37 });
38});
39
40function skillRecord(filePath: string, mode: SkillRecord["mode"]): SkillRecord {
41 return {
42 id: filePath,
43 name: "handoff",
44 description: "Compact the conversation.",
45 filePath,
46 baseDir: "/skills/handoff",
47 source: { kind: "user", root: "/skills" },
48 editable: true,
49 mode,
50 diagnostics: [],
51 };
52}
53
54class MemoryFileSystem implements FileSystem {
55 constructor(private readonly files: Map<string, string>) {}
56
57 async readFile(path: string): Promise<string> {
58 const value = this.files.get(path);
59 if (value === undefined) throw new Error(`missing file: ${path}`);
60 return value;
61 }
62
63 async writeFileAtomic(path: string, content: string): Promise<void> {
64 this.files.set(path, content);
65 }
66
67 async access(path: string): Promise<boolean> {
68 return this.files.has(path);
69 }
70
71 async readdir(): Promise<Array<{ name: string; isDirectory: boolean; isFile: boolean; isSymbolicLink: boolean }>> {
72 return [];
73 }
74
75 async stat(): Promise<{ isDirectory: boolean; isFile: boolean; mode: number }> {
76 return { isDirectory: false, isFile: true, mode: 0o644 };
77 }
78}