| 1 | import assert from "node:assert/strict"; |
| 2 | import { readFileSync, readdirSync } from "node:fs"; |
| 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | import { test } from "node:test"; |
| 5 | |
| 6 | import { zip as deflateZip } from "../../../apps/web/app/lib/zip.ts"; |
| 7 | import { type CheckedSkill, checkSkillFolder } from "../../../packages/contracts/src/skill-format.ts"; |
| 8 | import { draftedSkill, transcriptText } from "./skill-draft.ts"; |
| 9 | import { Library, type LibraryPorts, onPush } from "./skill-library.ts"; |
| 10 | import { bytesBase64, readUpload, unzip } from "./skill-zip.ts"; |
| 11 | import { zip as storedZip } from "./ooxml.ts"; |
| 12 | import { attachedRows, shelfFrom } from "./skills.ts"; |
| 13 | |
| 14 | /** D1 over node's SQLite with the service's migrations: prepare, bind, first, run, all and batch. */ |
| 15 | function 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 | |
| 43 | const WS = "wsp_acme"; |
| 44 | const at = new Date("2026-10-10T12:00:00Z"); |
| 45 | |
| 46 | /** The teams each agent is on, by id: memberships, as identity holds them. */ |
| 47 | const onTeams = new Map<string, string[]>(); |
| 48 | |
| 49 | async 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 | |
| 60 | type Repo = { id: string; full: string; default_branch: string; commits: Record<string, Record<string, string>> }; |
| 61 | |
| 62 | function 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 | |
| 95 | function 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 | |
| 99 | const owner = { username: "chase", owner: true }; |
| 100 | const maintainer = { username: "mia", owner: false }; |
| 101 | const member = { username: "bo", owner: false }; |
| 102 | const maintainerPorts = () => ports({ teamList: [{ slug: "qa", name: "QA", can_manage: true }, { slug: "web", name: "Web", can_manage: false }] }); |
| 103 | |
| 104 | const 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 | |
| 111 | test("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 | |
| 167 | test("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 | |
| 199 | test("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 | |
| 249 | test("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 | |
| 273 | test("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 | |
| 325 | test("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 | |
| 350 | test("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 | |
| 405 | test("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 | |
| 445 | test("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 | }); |