Skip to content
458 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { readFileSync, readdirSync } from "node:fs";
3import { DatabaseSync } from "node:sqlite";
4import { test } from "node:test";
5
6import { zip as deflateZip } from "../../../apps/web/app/lib/zip.ts";
7import { type CheckedSkill, checkSkillFolder } from "../../../packages/contracts/src/skill-format.ts";
8import { draftedSkill, transcriptText } from "./skill-draft.ts";
9import { Library, type LibraryPorts, onPush } from "./skill-library.ts";
10import { bytesBase64, readUpload, unzip } from "./skill-zip.ts";
11import { zip as storedZip } from "./ooxml.ts";
12import { attachedRows, shelfFrom } from "./skills.ts";
13
14/** D1 over node's SQLite with the service's migrations: prepare, bind, first, run, all and batch. */
15function fakeD1(): D1Database {
16 const db = new DatabaseSync(":memory:");
17 const dir = new URL("../migrations/", import.meta.url);
18 for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
19 const statement = (sql: string, params: unknown[] = []): any => ({
20 sql,
21 params,
22 bind: (...values: unknown[]) => statement(sql, values),
23 first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
24 run: async () => ({ meta: { changes: Number(db.prepare(sql).run(...(params as never[])).changes) } }),
25 all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }),
26 });
27 return {
28 prepare: (sql: string) => statement(sql),
29 batch: async (statements: any[]) => {
30 db.exec("BEGIN");
31 try {
32 const out = statements.map((s) => ({ meta: { changes: Number(db.prepare(s.sql).run(...(s.params as never[])).changes) } }));
33 db.exec("COMMIT");
34 return out;
35 } catch (error) {
36 db.exec("ROLLBACK");
37 throw error;
38 }
39 },
40 } as unknown as D1Database;
41}
42
43const WS = "wsp_acme";
44const at = new Date("2026-10-10T12:00:00Z");
45
46/** The teams each agent is on, by id: memberships, as identity holds them. */
47const onTeams = new Map<string, string[]>();
48
49async function addAgent(db: D1Database, id: string, handle: string, team: string | null = null): Promise<void> {
50 await db
51 .prepare(
52 `INSERT INTO agents (id, workspace_id, handle, display_name, role, instructions, routing, budget, autonomy, created_by, created_at, updated_at)
53 VALUES (?, ?, ?, ?, 'r', 'i', '{}', '{}', '{}', 'ana', ?, ?)`,
54 )
55 .bind(id, WS, handle, handle[0]!.toUpperCase() + handle.slice(1), at.toISOString(), at.toISOString())
56 .run();
57 onTeams.set(id, team ? [team] : []);
58}
59
60type Repo = { id: string; full: string; default_branch: string; commits: Record<string, Record<string, string>> };
61
62function ports(over: Partial<LibraryPorts> & { repos?: Repo[]; teamList?: { slug: string; name: string; can_manage: boolean }[]; log?: string[] } = {}): LibraryPorts {
63 const repos = over.repos ?? [];
64 const blobs = new Map<string, string>();
65 return {
66 teams: async () => over.teamList ?? [{ slug: "qa", name: "QA", can_manage: false }],
67 repo: async (full) => repos.find((r) => r.full === full) ?? null,
68 listFiles: async (repoId, ref) => {
69 const repo = repos.find((r) => r.id === repoId)!;
70 const commit = ref ?? repo.default_branch;
71 const files = repo.commits[commit];
72 if (!files) return { commit: null, files: [], truncated: false };
73 return {
74 commit: `${commit}-sha`,
75 files: Object.entries(files).map(([path, content]) => {
76 const hash = `h:${content.length}:${path}:${content}`;
77 blobs.set(hash, Buffer.from(content).toString("base64"));
78 return { path, hash };
79 }),
80 truncated: false,
81 };
82 },
83 blobs: async (_repoId, hashes) => hashes.map((hash) => ({ hash, data: blobs.get(hash) ?? null })),
84 agentTeams: async (agentId) => (onTeams.get(agentId) ?? []).map((slug) => ({ slug, name: slug.toUpperCase() })),
85 teamAgentIndex: async () => {
86 const bySlug = new Map<string, string[]>();
87 for (const [id, slugs] of onTeams) for (const slug of slugs) bySlug.set(slug, [...(bySlug.get(slug) ?? []), id]);
88 return [...bySlug].map(([slug, agent_ids]) => ({ slug, agent_ids }));
89 },
90 audit: (action, name) => over.log?.push(`${action} ${name}`),
91 ...over,
92 };
93}
94
95function library(db: D1Database, who: { username: string; owner: boolean }, p: LibraryPorts = ports()): Library {
96 return new Library({ db, workspaceId: WS, slug: "acme", viewer: { id: `usr_${who.username}`, username: who.username }, owner: who.owner, ports: p, now: at });
97}
98
99const owner = { username: "chase", owner: true };
100const maintainer = { username: "mia", owner: false };
101const member = { username: "bo", owner: false };
102const maintainerPorts = () => ports({ teamList: [{ slug: "qa", name: "QA", can_manage: true }, { slug: "web", name: "Web", can_manage: false }] });
103
104const notes = {
105 name: "release-notes",
106 description: "Use when someone asks for release notes.",
107 instructions: "# Release notes\n\nGroup changes by area.",
108 tools: ["recent_activity", "create_artifact"],
109};
110
111test("an owner writes a skill; every save is a version, and attachments it may change follow", async () => {
112 const db = fakeD1();
113 await addAgent(db, "agt_margo", "margo", "qa");
114 const log: string[] = [];
115 const lib = library(db, owner, ports({ log }));
116 const made = await lib.save(null, notes);
117 assert.ok(made.ok, !made.ok ? made.error.message : "");
118 assert.equal(made.value.skill.version, 1);
119 assert.equal(made.value.skill.status, "published");
120 assert.deepEqual(made.value.tools, ["recent_activity", "create_artifact"]);
121 assert.match(made.value.skill_md, /^---\nname: release-notes\ndescription: Use when someone asks for release notes\.\ntools: \[recent_activity, create_artifact\]\n---\n\n# Release notes/);
122 assert.equal(made.value.instructions, "# Release notes\n\nGroup changes by area.");
123 assert.deepEqual(made.value.skill.origin, { kind: "written" });
124
125 const attached = await lib.attach("release-notes", "agent", "@margo");
126 assert.ok(attached.ok);
127 assert.deepEqual(
128 attached.value.skill.attachments.map((a) => [a.scope, a.target, a.label, a.version]),
129 [["agent", "margo", "@margo", 1]],
130 );
131 assert.equal((await lib.attach("release-notes", "agent", "margo")).ok, false, "attached there already");
132 assert.ok((await lib.attach("release-notes", "workspace", null)).ok);
133
134 // Saving the same thing again writes nothing.
135 const same = await lib.save("release-notes", notes);
136 assert.ok(same.ok && same.value.skill.version === 1);
137
138 // A change: version 2, and both attachments move with it.
139 const changed = await lib.save("release-notes", { ...notes, instructions: `${notes.instructions}\n\nLink every pull request.`, note: " Links " });
140 assert.ok(changed.ok);
141 assert.equal(changed.value.skill.version, 2);
142 assert.deepEqual(changed.value.skill.attachments.map((a) => a.version), [2, 2]);
143 assert.deepEqual(changed.value.versions.map((v) => [v.version, v.note]), [[2, "Links"], [1, null]]);
144 // Without moving them: version 3, attachments stay on 2, so an update is available.
145 const kept = await lib.save("release-notes", { ...notes, instructions: "# Release notes\n\nShort.", update_attachments: false });
146 assert.ok(kept.ok);
147 assert.deepEqual(kept.value.skill.attachments.map((a) => a.version), [2, 2]);
148 const tab = await lib.agentSkills("margo");
149 assert.ok(tab.ok);
150 const line = tab.value.skills.find((s) => s.name === "release-notes")!;
151 assert.equal(line.via, "agent", "attached to the agent itself, which wins over the workspace");
152 assert.equal(line.version, "2");
153 assert.equal(line.update, 3);
154 assert.equal(tab.value.skills.filter((s) => s.foundational).length, 6);
155 // The pin moves when someone asks; an old version can be pinned too.
156 const pinned = await lib.pin("release-notes", line.attachment_id, null);
157 assert.ok(pinned.ok);
158 assert.equal(pinned.value.skill.attachments.find((a) => a.scope === "agent")!.version, 3);
159 assert.ok((await lib.pin("release-notes", line.attachment_id, 1)).ok);
160 assert.equal((await lib.pin("release-notes", line.attachment_id, 9)).ok, false);
161 // An older version reads as it was.
162 const v1 = await lib.detail("release-notes", 1);
163 assert.ok(v1.ok && v1.value.shown === 1 && v1.value.instructions === notes.instructions);
164 assert.deepEqual(log, ["create_skill release-notes", "attach_skill release-notes", "attach_skill release-notes", "update_skill release-notes", "update_skill release-notes", "pin_skill release-notes", "pin_skill release-notes"]);
165});
166
167test("the editor's checks: names, the foundational names, tools agents have, and renames", async () => {
168 const db = fakeD1();
169 const lib = library(db, owner);
170 const bad = async (input: Partial<typeof notes>, pattern: RegExp) => {
171 const result = await lib.save(null, { ...notes, ...input });
172 assert.equal(result.ok, false, JSON.stringify(input));
173 assert.match(!result.ok ? result.error.message : "", pattern);
174 };
175 await bad({ name: "Release Notes" }, /lowercase letters, digits and single hyphens/);
176 await bad({ name: "documents" }, /one of g1t's foundational skills/);
177 await bad({ description: "" }, /needs a description/);
178 await bad({ instructions: "" }, /needs instructions/);
179 await bad({ tools: ["teleport"] }, /teleport, which isn't a tool agents have/);
180 assert.ok((await lib.save(null, notes)).ok);
181 const twice = await lib.save(null, notes);
182 assert.equal(!twice.ok && twice.error.code, "conflict");
183 // Files: added by path, removed by path, the rest kept.
184 const withFiles = await lib.save("release-notes", { ...notes, add_files: [{ path: "resources/a.md", content: "A" }, { path: "scripts/run.sh", content: "echo" }] });
185 assert.ok(withFiles.ok);
186 assert.deepEqual(withFiles.value.files.map((f) => f.path), ["resources/a.md", "scripts/run.sh"]);
187 assert.equal(withFiles.value.requires_computer, true, "a script needs a computer");
188 const fewer = await lib.save("release-notes", { ...notes, remove_files: ["scripts/run.sh"], add_files: [{ path: "resources/a.md", content: "A2" }] });
189 assert.ok(fewer.ok);
190 assert.deepEqual(fewer.value.files.map((f) => [f.path, f.content]), [["resources/a.md", "A2"]]);
191 assert.equal(fewer.value.requires_computer, false);
192 // Renaming keeps the history.
193 const renamed = await lib.save("release-notes", { ...notes, name: "changelog" });
194 assert.ok(renamed.ok);
195 assert.equal(renamed.value.skill.name, "changelog");
196 assert.equal(renamed.value.skill.version, 4);
197});
198
199test("who may do what: owners anything, maintainers their own skills and teams, members only look", async () => {
200 const db = fakeD1();
201 await addAgent(db, "agt_margo", "margo", "qa");
202 const asOwner = library(db, owner);
203 const asMaintainer = library(db, maintainer, maintainerPorts());
204 const asMember = library(db, member);
205
206 const view = await asMember.library();
207 assert.ok(view.ok);
208 assert.equal(view.value.can_write, false);
209 assert.deepEqual(view.value.teams, []);
210 assert.equal((await asMember.save(null, notes)).ok, false);
211
212 const mine = await asMaintainer.save(null, { ...notes, name: "qa-triage" });
213 assert.ok(mine.ok);
214 assert.equal(mine.value.skill.can_edit, true);
215 const lib = await asMaintainer.library();
216 assert.ok(lib.ok);
217 assert.equal(lib.value.can_write, true);
218 assert.equal(lib.value.can_manage, false);
219 assert.deepEqual(lib.value.teams, [{ slug: "qa", name: "QA" }], "only the teams they maintain");
220 assert.deepEqual(lib.value.agents, [], "only owners attach to agents");
221
222 // Their team: yes. Another team, an agent, the workspace: no.
223 assert.ok((await asMaintainer.attach("qa-triage", "team", "qa")).ok);
224 assert.equal((await asMaintainer.attach("qa-triage", "team", "web")).ok, false);
225 assert.equal((await asMaintainer.attach("qa-triage", "agent", "margo")).ok, false);
226 assert.equal((await asMaintainer.attach("qa-triage", "workspace", null)).ok, false);
227
228 // An owner's skill: a maintainer can't edit it, but can attach it to their team.
229 assert.ok((await asOwner.save(null, notes)).ok);
230 assert.equal((await asMaintainer.save("release-notes", { ...notes, description: "Use when x." })).ok, false);
231 const theirs = await asMaintainer.attach("release-notes", "team", "qa");
232 assert.ok(theirs.ok);
233 assert.equal(theirs.value.skill.can_edit, false);
234 // Owners attach anywhere; the maintainer can't detach the workspace-wide one.
235 const wide = await asOwner.attach("release-notes", "workspace", null);
236 assert.ok(wide.ok);
237 const wideId = wide.value.skill.attachments.find((a) => a.scope === "workspace")!.id;
238 assert.equal((await asMaintainer.detach("release-notes", wideId)).ok, false);
239 // Deleting: a maintainer's own skill used only by their team, yes; an owner's, no.
240 assert.equal((await asMaintainer.remove("release-notes")).ok, false);
241 assert.ok((await asMaintainer.remove("qa-triage")).ok);
242 assert.ok((await asOwner.remove("release-notes")).ok);
243 const after = await asOwner.library();
244 assert.deepEqual(after.ok && after.value.skills, []);
245 // A deleted skill's name is free again.
246 assert.ok((await asOwner.save(null, notes)).ok);
247});
248
249test("an agent's skills: once each, through its teams and the workspace, never past 100, published only", async () => {
250 const db = fakeD1();
251 await addAgent(db, "agt_margo", "margo", "qa");
252 const lib = library(db, owner, ports({ teamList: [{ slug: "qa", name: "QA", can_manage: true }] }));
253 assert.ok((await lib.save(null, notes)).ok);
254 assert.ok((await lib.save(null, { ...notes, name: "triage", description: "Use when a bug comes in." })).ok);
255 assert.ok((await lib.attach("release-notes", "team", "qa")).ok);
256 assert.ok((await lib.attach("triage", "workspace", null)).ok);
257 const rows = await attachedRows(db, WS, "agt_margo", ["qa"]);
258 assert.deepEqual(rows.map((r) => [r.name, r.scope]).sort(), [["release-notes", "team"], ["triage", "workspace"]]);
259 assert.deepEqual(await attachedRows(db, WS, "agt_margo", []).then((r) => r.map((x) => x.name)), ["triage"], "not on the team: not its skill");
260 const shelf = shelfFrom([], rows).skills.filter((s) => s.kind === "library").map((s) => s.name);
261 assert.deepEqual(shelf, ["release-notes", "triage"]);
262 const tab = await lib.agentSkills("margo");
263 assert.ok(tab.ok);
264 assert.deepEqual(
265 tab.value.skills.filter((s) => !s.foundational).map((s) => [s.name, s.via, s.via_label, s.on]),
266 [
267 ["release-notes", "team", "QA", true],
268 ["triage", "workspace", "Every agent", true],
269 ],
270 );
271});
272
273test("uploads: a SKILL.md, a zip of its folder (stored or deflated), and what is refused", async () => {
274 const skillMd = "---\nname: brand-voice\ndescription: >\n Use when writing anything customers read:\n posts, emails and docs.\nlicense: MIT\nmetadata:\n author: acme\n---\n\n# Brand voice\n\nWarm, plain words.\n";
275 const md = await readUpload("SKILL.md", Buffer.from(skillMd).toString("base64"));
276 assert.ok(md.ok);
277 const zipped = storedZip([
278 ["brand-voice/SKILL.md", skillMd],
279 ["brand-voice/resources/words.md", "Use: team. Avoid: synergy."],
280 ["brand-voice/scripts/lint.py", "print('ok')"],
281 ]);
282 const stored = await unzip(zipped);
283 assert.ok(stored.ok);
284 assert.deepEqual(stored.ok && stored.files.map((f) => f.path).sort(), ["SKILL.md", "resources/words.md", "scripts/lint.py"], "the one folder is the skill");
285 const deflated = await deflateZip([
286 { path: "SKILL.md", data: new TextEncoder().encode(skillMd) },
287 { path: "resources/words.md", data: new TextEncoder().encode("x ".repeat(5000)) },
288 { path: "resources/logo.png", data: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3]) },
289 ]);
290 const read = await unzip(deflated);
291 assert.ok(read.ok, !read.ok ? read.message : "");
292 assert.equal(read.ok && read.files.find((f) => f.path === "resources/words.md")!.content.length, 10000);
293 assert.equal(read.ok && read.files.find((f) => f.path === "resources/logo.png")!.encoding, "base64");
294
295 const db = fakeD1();
296 const lib = library(db, owner);
297 const imported = await lib.import({ kind: "upload", filename: "brand-voice.zip", data_base64: bytesBase64(zipped) }, false);
298 assert.ok(imported.ok, !imported.ok ? imported.error.message : "");
299 assert.equal(imported.value.skill.description, "Use when writing anything customers read: posts, emails and docs.");
300 assert.equal(imported.value.requires_computer, true, "it has scripts");
301 assert.deepEqual(imported.value.extra, { license: "MIT", metadata: { author: "acme" } });
302 assert.equal(imported.value.skill_md, skillMd, "kept as written");
303 assert.deepEqual(imported.value.files.map((f) => [f.path, f.script]), [["resources/words.md", false], ["scripts/lint.py", true]]);
304 assert.deepEqual(imported.value.skill.origin, { kind: "upload", filename: "brand-voice.zip" });
305 // The same name again: refused, unless as a new version.
306 const again = await lib.import({ kind: "upload", filename: "SKILL.md", data_base64: Buffer.from(skillMd.replace("Warm", "Kind")).toString("base64") }, false);
307 assert.equal(!again.ok && again.error.code, "conflict");
308 const replaced = await lib.import({ kind: "upload", filename: "SKILL.md", data_base64: Buffer.from(skillMd.replace("Warm", "Kind")).toString("base64") }, true);
309 assert.ok(replaced.ok && replaced.value.skill.version === 2);
310 // A new version from a bare SKILL.md keeps nothing else: an upload is the whole folder.
311 assert.deepEqual(replaced.ok && replaced.value.files, []);
312
313 const refuse = async (filename: string, data: Uint8Array, pattern: RegExp) => {
314 const result = await lib.import({ kind: "upload", filename, data_base64: bytesBase64(data) }, false);
315 assert.equal(result.ok, false, filename);
316 assert.match(!result.ok ? result.error.message : "", pattern);
317 };
318 await refuse("notes.pdf", new Uint8Array([1, 2, 3]), /Upload a SKILL\.md, or a zip/);
319 await refuse("big.zip", storedZip([["SKILL.md", skillMd], ["resources/big.txt", "x".repeat(1024 * 1024)]]), /at most 1 MB/);
320 await refuse("nested.zip", storedZip([["a/SKILL.md", skillMd], ["b/x.md", "x"]]), /SKILL\.md belongs at the top of the folder, not in a/);
321 await refuse("escape.zip", storedZip([["SKILL.md", skillMd], ["../x.md", "x"]]), /isn't a path a skill can hold/);
322 await refuse("SKILL.md", new TextEncoder().encode("# No front-matter"), /starts with front-matter/);
323});
324
325test("from a repository: a folder at one commit, read only where the viewer can read", async () => {
326 const db = fakeD1();
327 const repo: Repo = {
328 id: "rep_1",
329 full: "acme/handbook",
330 default_branch: "main",
331 commits: {
332 main: { "skills/oncall/SKILL.md": "---\nname: oncall\ndescription: Use when paged.\n---\n\nAcknowledge first.\n", "skills/oncall/resources/runbook.md": "Steps", "README.md": "x" },
333 v1: { "skills/oncall/SKILL.md": "---\nname: oncall\ndescription: Use when paged.\n---\n\nOld.\n" },
334 },
335 };
336 const lib = library(db, owner, ports({ repos: [repo] }));
337 const made = await lib.import({ kind: "repository", repo: "acme/handbook", path: "/skills/oncall/SKILL.md", ref: "v1" }, false);
338 assert.ok(made.ok, !made.ok ? made.error.message : "");
339 assert.deepEqual(made.value.skill.origin, { kind: "repository", repo: "acme/handbook", path: "skills/oncall", ref: "v1", commit: "v1-sha" });
340 const newer = await lib.import({ kind: "repository", repo: "acme/handbook", path: "skills/oncall" }, true);
341 assert.ok(newer.ok);
342 assert.equal(newer.value.skill.version, 2);
343 assert.deepEqual(newer.value.files.map((f) => f.path), ["resources/runbook.md"]);
344 const hidden = await lib.import({ kind: "repository", repo: "acme/secret", path: "x" }, false);
345 assert.equal(!hidden.ok && hidden.error.code, "not_found");
346 const noRef = await lib.import({ kind: "repository", repo: "acme/handbook", path: "skills/oncall", ref: "nope" }, false);
347 assert.match(!noRef.ok ? noRef.error.message : "", /has no branch, tag or commit called nope/);
348});
349
350test("a linked repository: its .g1t/skills folders become skills, pushes update them, and they're changed only there", async () => {
351 const db = fakeD1();
352 await addAgent(db, "agt_margo", "margo");
353 const repo: Repo = {
354 id: "rep_skills",
355 full: "acme/agents",
356 default_branch: "main",
357 commits: {
358 main: {
359 ".g1t/skills/triage/SKILL.md": "---\nname: triage\ndescription: Use when a bug comes in.\n---\n\nReproduce first.\n",
360 ".g1t/skills/wrong/SKILL.md": "---\nname: other\ndescription: Use when.\n---\n\nx\n",
361 ".g1t/skills/release-notes/SKILL.md": "---\nname: release-notes\ndescription: Use when.\n---\n\nx\n",
362 },
363 },
364 };
365 const p = ports({ repos: [repo] });
366 const lib = library(db, owner, p);
367 assert.ok((await lib.save(null, notes)).ok, "written in the library first");
368 const linked = await lib.setMirror("acme/agents");
369 assert.ok(linked.ok, !linked.ok ? linked.error.message : "");
370 assert.deepEqual(linked.value.changed, ["triage"]);
371 assert.equal(linked.value.problems.length, 2);
372 assert.match(linked.value.problems.join("\n"), /release-notes: the library already has a skill called release-notes that isn't from this repository/);
373 assert.match(linked.value.problems.join("\n"), /wrong: The folder is wrong, but its SKILL\.md is named other/);
374 assert.equal(linked.value.mirror?.commit, "main-sha");
375 const triage = await lib.detail("triage");
376 assert.ok(triage.ok);
377 assert.equal(triage.value.skill.mirrored, true);
378 assert.equal(triage.value.skill.can_edit, false, "changed in the repository, not here");
379 assert.equal((await lib.save("triage", { ...notes, name: "triage" })).ok, false);
380 assert.ok((await lib.attach("triage", "agent", "margo")).ok);
381
382 // A push: a new version, and its attachments follow.
383 repo.commits.main![".g1t/skills/triage/SKILL.md"] = "---\nname: triage\ndescription: Use when a bug comes in.\n---\n\nReproduce first, then label.\n";
384 assert.equal(await onPush(db, p, "rep_skills", at), 1);
385 const pushed = await lib.detail("triage");
386 assert.ok(pushed.ok);
387 assert.equal(pushed.value.skill.version, 2);
388 assert.deepEqual(pushed.value.skill.attachments.map((a) => a.version), [2]);
389 assert.equal(pushed.value.skill.origin.kind, "mirror");
390 assert.equal(await onPush(db, p, "rep_other", at), 0, "nobody follows that one");
391
392 // Gone from the repository: kept, and editable here again.
393 delete repo.commits.main![".g1t/skills/triage/SKILL.md"];
394 const synced = await lib.sync();
395 assert.ok(synced.ok);
396 assert.match(synced.value.problems.join("\n"), /triage is no longer in the repository/);
397 const kept = await lib.detail("triage");
398 assert.ok(kept.ok && !kept.value.skill.mirrored && kept.value.skill.can_edit);
399 // Only owners link one.
400 assert.equal((await library(db, maintainer, maintainerPorts()).setMirror("acme/agents")).ok, false);
401 const unlinked = await lib.setMirror(null);
402 assert.ok(unlinked.ok && unlinked.value.mirror === null);
403});
404
405test("save as skill: a draft from the transcript, never attached until a person publishes it", async () => {
406 const transcript = transcriptText(
407 { title: "Ship the Q3 notes", goal: "Write release notes for Q3.", result: "Done: the doc is linked." },
408 [
409 { kind: "text", by_name: "margo", body: "Reading merged pull requests.", tool: null },
410 { kind: "tool", by_name: "margo", body: '{"repo":"web"}', tool: "recent_activity" },
411 ],
412 );
413 assert.match(transcript, /^<untrusted source="session transcript">\nSession: Ship the Q3 notes/);
414 assert.match(transcript, /- used recent_activity \{"repo":"web"\}/);
415 assert.match(transcript, /Report:\nDone: the doc is linked\.\n<\/untrusted>\n\nWrite the SKILL\.md\.$/);
416 const long = transcriptText({ title: "t", goal: "g", result: null }, Array.from({ length: 200 }, (_, i) => ({ kind: "text", by_name: null, body: `${i} ${"x".repeat(1000)}`, tool: null })));
417 assert.ok(long.length < 62_000 && long.includes("[…]"), "cut in the middle");
418
419 const answer = "```markdown\n---\nname: release-notes\ndescription: Use when someone asks for release notes.\ntools: [recent_activity]\n---\n\n# Release notes\n\n1. Read merged pull requests.\n```";
420 const drafted = draftedSkill(answer);
421 assert.ok(drafted.ok);
422 assert.equal(draftedSkill("Sure! Here it is.").ok, false);
423
424 const db = fakeD1();
425 const lib = library(db, owner);
426 assert.ok((await lib.save(null, notes)).ok, "the name is taken, so the draft gets another");
427 const asMember = library(db, member);
428 const draft = await asMember.saveDraft((drafted as { ok: true; skill: CheckedSkill }).skill, { kind: "session", session_id: "ses_1", agent: "margo", title: "Ship the Q3 notes" });
429 assert.ok(draft.ok, !draft.ok ? draft.error.message : "");
430 assert.equal(draft.value.skill.name, "release-notes-2");
431 assert.match(draft.value.skill_md, /^---\nname: release-notes-2\n/);
432 assert.equal(draft.value.skill.status, "draft");
433 assert.equal(draft.value.skill.can_edit, false, "a member can't publish it");
434 assert.equal(draft.value.skill.can_delete, true, "but can discard their own draft");
435 assert.equal((await lib.attach("release-notes-2", "workspace", null)).ok, false, "a draft can't be attached");
436 assert.deepEqual(await attachedRows(db, WS, "agt_x", []), []);
437 const published = await lib.save("release-notes-2", { name: "q3-notes", description: "Use when someone asks for quarterly release notes.", instructions: "# Notes\n\nRead merged pull requests.", tools: ["recent_activity"] });
438 assert.ok(published.ok, !published.ok ? published.error.message : "");
439 assert.equal(published.value.skill.status, "published");
440 assert.equal(published.value.skill.version, 1, "published in place: a draft has no history");
441 assert.deepEqual(published.value.skill.origin, { kind: "session", session_id: "ses_1", agent: "margo", title: "Ship the Q3 notes" });
442 assert.ok((await lib.attach("q3-notes", "workspace", null)).ok);
443});
444
445test("limits: 100 library skills per agent", async () => {
446 const db = fakeD1();
447 await addAgent(db, "agt_margo", "margo");
448 const lib = library(db, owner);
449 for (let i = 0; i < 101; i++) {
450 const checked = checkSkillFolder([{ path: "SKILL.md", content: `---\nname: s-${i}\ndescription: Use when ${i}.\n---\n\nDo ${i}.\n` }]);
451 assert.ok(checked.ok);
452 const saved = await lib.save(null, { name: `s-${i}`, description: `Use when ${i}.`, instructions: `Do ${i}.` });
453 assert.ok(saved.ok);
454 const attached = await lib.attach(`s-${i}`, i % 2 ? "agent" : "workspace", i % 2 ? "margo" : null);
455 if (i < 100) assert.ok(attached.ok, `${i}: ${!attached.ok ? attached.error.message : ""}`);
456 else assert.match(!attached.ok ? attached.error.message : "", /at most 100 skills from the library/);
457 }
458});